Add giveaway, ticket, self-role, starboard, and suggestion features to the bot
- Introduced new models in the Prisma schema for giveaways, tickets, self-role panels, starboard configurations, and suggestions, enhancing the bot's functionality. - Implemented job handling for self-role expiration and ticket management, improving user experience. - Updated command localization to support new features in both German and English. - Enhanced the job processing system to include dedicated workers for managing tickets and self-role expirations. - Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
CREATE TABLE "Giveaway" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"messageId" TEXT,
|
||||
"prize" TEXT NOT NULL,
|
||||
"winnerCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"endsAt" TIMESTAMP(3) NOT NULL,
|
||||
"requiredRoleId" TEXT,
|
||||
"requiredLevel" INTEGER,
|
||||
"requiredMemberDays" INTEGER,
|
||||
"paused" BOOLEAN NOT NULL DEFAULT false,
|
||||
"endedAt" TIMESTAMP(3),
|
||||
"winners" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"entrants" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"hostId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "Giveaway_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "TicketConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"mode" TEXT NOT NULL DEFAULT 'CHANNEL',
|
||||
"categoryId" TEXT,
|
||||
"logChannelId" TEXT,
|
||||
"transcriptDm" BOOLEAN NOT NULL DEFAULT false,
|
||||
"inactivityHours" INTEGER NOT NULL DEFAULT 72,
|
||||
"ratingEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "TicketConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "TicketCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"emoji" TEXT,
|
||||
"supportRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"formFields" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "TicketCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Ticket" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"categoryId" TEXT,
|
||||
"openerId" TEXT NOT NULL,
|
||||
"claimedById" TEXT,
|
||||
"channelId" TEXT,
|
||||
"threadId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
||||
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
|
||||
"subject" TEXT,
|
||||
"formAnswers" JSONB,
|
||||
"transcriptHtml" TEXT,
|
||||
"rating" INTEGER,
|
||||
"closedAt" TIMESTAMP(3),
|
||||
"lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SelfRolePanel" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"messageId" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"mode" TEXT NOT NULL DEFAULT 'BUTTONS',
|
||||
"behavior" TEXT NOT NULL DEFAULT 'NORMAL',
|
||||
"roles" JSONB NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "SelfRolePanel_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Tag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"content" TEXT,
|
||||
"embed" JSONB,
|
||||
"responseType" TEXT NOT NULL DEFAULT 'TEXT',
|
||||
"triggerWord" TEXT,
|
||||
"allowedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"allowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"createdById" TEXT NOT NULL,
|
||||
"useCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "StarboardConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"channelId" TEXT,
|
||||
"emoji" TEXT NOT NULL DEFAULT E'⭐',
|
||||
"threshold" INTEGER NOT NULL DEFAULT 3,
|
||||
"allowSelfStar" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "StarboardConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "StarboardEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"sourceChannelId" TEXT NOT NULL,
|
||||
"sourceMessageId" TEXT NOT NULL,
|
||||
"starboardMessageId" TEXT NOT NULL,
|
||||
"starCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "StarboardEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SuggestionConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"openChannelId" TEXT,
|
||||
"approvedChannelId" TEXT,
|
||||
"deniedChannelId" TEXT,
|
||||
"createThread" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "SuggestionConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Suggestion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
||||
"messageId" TEXT,
|
||||
"channelId" TEXT,
|
||||
"threadId" TEXT,
|
||||
"upvotes" INTEGER NOT NULL DEFAULT 0,
|
||||
"downvotes" INTEGER NOT NULL DEFAULT 0,
|
||||
"voterUpIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"voterDownIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"staffReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "BirthdayConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"channelId" TEXT,
|
||||
"roleId" TEXT,
|
||||
"message" TEXT,
|
||||
"timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "BirthdayConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Birthday" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"day" INTEGER NOT NULL,
|
||||
"year" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "Birthday_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "TempVoiceConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hubChannelId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"defaultName" TEXT NOT NULL DEFAULT '{user}''s Channel',
|
||||
"defaultLimit" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "TempVoiceConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "TempVoiceChannel" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"controlMessageId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "TempVoiceChannel_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "Giveaway_guildId_endsAt_idx" ON "Giveaway"("guildId", "endsAt");
|
||||
CREATE UNIQUE INDEX "TicketConfig_guildId_key" ON "TicketConfig"("guildId");
|
||||
CREATE UNIQUE INDEX "TicketCategory_guildId_name_key" ON "TicketCategory"("guildId", "name");
|
||||
CREATE INDEX "TicketCategory_guildId_idx" ON "TicketCategory"("guildId");
|
||||
CREATE INDEX "Ticket_guildId_status_idx" ON "Ticket"("guildId", "status");
|
||||
CREATE INDEX "Ticket_channelId_idx" ON "Ticket"("channelId");
|
||||
CREATE INDEX "SelfRolePanel_guildId_idx" ON "SelfRolePanel"("guildId");
|
||||
CREATE UNIQUE INDEX "Tag_guildId_name_key" ON "Tag"("guildId", "name");
|
||||
CREATE INDEX "Tag_guildId_idx" ON "Tag"("guildId");
|
||||
CREATE UNIQUE INDEX "StarboardConfig_guildId_key" ON "StarboardConfig"("guildId");
|
||||
CREATE UNIQUE INDEX "StarboardEntry_guildId_sourceMessageId_key" ON "StarboardEntry"("guildId", "sourceMessageId");
|
||||
CREATE INDEX "StarboardEntry_guildId_idx" ON "StarboardEntry"("guildId");
|
||||
CREATE UNIQUE INDEX "SuggestionConfig_guildId_key" ON "SuggestionConfig"("guildId");
|
||||
CREATE INDEX "Suggestion_guildId_status_idx" ON "Suggestion"("guildId", "status");
|
||||
CREATE UNIQUE INDEX "BirthdayConfig_guildId_key" ON "BirthdayConfig"("guildId");
|
||||
CREATE UNIQUE INDEX "Birthday_guildId_userId_key" ON "Birthday"("guildId", "userId");
|
||||
CREATE INDEX "Birthday_guildId_month_day_idx" ON "Birthday"("guildId", "month", "day");
|
||||
CREATE UNIQUE INDEX "TempVoiceConfig_guildId_key" ON "TempVoiceConfig"("guildId");
|
||||
CREATE UNIQUE INDEX "TempVoiceChannel_channelId_key" ON "TempVoiceChannel"("channelId");
|
||||
CREATE INDEX "TempVoiceChannel_guildId_ownerId_idx" ON "TempVoiceChannel"("guildId", "ownerId");
|
||||
|
||||
ALTER TABLE "Giveaway" ADD CONSTRAINT "Giveaway_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TicketConfig" ADD CONSTRAINT "TicketConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TicketCategory" ADD CONSTRAINT "TicketCategory_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TicketCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "SelfRolePanel" ADD CONSTRAINT "SelfRolePanel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "StarboardConfig" ADD CONSTRAINT "StarboardConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "StarboardEntry" ADD CONSTRAINT "StarboardEntry_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "SuggestionConfig" ADD CONSTRAINT "SuggestionConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "BirthdayConfig" ADD CONSTRAINT "BirthdayConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Birthday" ADD CONSTRAINT "Birthday_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TempVoiceConfig" ADD CONSTRAINT "TempVoiceConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TempVoiceChannel" ADD CONSTRAINT "TempVoiceChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -30,6 +30,20 @@ model Guild {
|
||||
polls Poll[]
|
||||
reminders Reminder[]
|
||||
afkStatuses AfkStatus[]
|
||||
giveaways Giveaway[]
|
||||
ticketConfig TicketConfig?
|
||||
ticketCategories TicketCategory[]
|
||||
tickets Ticket[]
|
||||
selfRolePanels SelfRolePanel[]
|
||||
tags Tag[]
|
||||
starboardConfig StarboardConfig?
|
||||
starboardEntries StarboardEntry[]
|
||||
suggestionConfig SuggestionConfig?
|
||||
suggestions Suggestion[]
|
||||
birthdays Birthday[]
|
||||
birthdayConfig BirthdayConfig?
|
||||
tempVoiceConfig TempVoiceConfig?
|
||||
tempVoiceChannels TempVoiceChannel[]
|
||||
}
|
||||
|
||||
model GuildSettings {
|
||||
@@ -389,3 +403,239 @@ model AfkStatus {
|
||||
|
||||
@@unique([guildId, userId])
|
||||
}
|
||||
|
||||
model Giveaway {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
channelId String
|
||||
messageId String?
|
||||
prize String
|
||||
winnerCount Int @default(1)
|
||||
endsAt DateTime
|
||||
requiredRoleId String?
|
||||
requiredLevel Int?
|
||||
requiredMemberDays Int?
|
||||
paused Boolean @default(false)
|
||||
endedAt DateTime?
|
||||
winners String[] @default([])
|
||||
entrants String[] @default([])
|
||||
hostId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([guildId, endsAt])
|
||||
}
|
||||
|
||||
model TicketConfig {
|
||||
id String @id @default(cuid())
|
||||
guildId String @unique
|
||||
enabled Boolean @default(true)
|
||||
mode String @default("CHANNEL")
|
||||
categoryId String?
|
||||
logChannelId String?
|
||||
transcriptDm Boolean @default(false)
|
||||
inactivityHours Int @default(72)
|
||||
ratingEnabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TicketCategory {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
name String
|
||||
description String?
|
||||
emoji String?
|
||||
supportRoleIds String[] @default([])
|
||||
formFields Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
tickets Ticket[]
|
||||
|
||||
@@unique([guildId, name])
|
||||
@@index([guildId])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
categoryId String?
|
||||
openerId String
|
||||
claimedById String?
|
||||
channelId String?
|
||||
threadId String?
|
||||
status String @default("OPEN")
|
||||
priority String @default("NORMAL")
|
||||
subject String?
|
||||
formAnswers Json?
|
||||
transcriptHtml String?
|
||||
rating Int?
|
||||
closedAt DateTime?
|
||||
lastActivityAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
category TicketCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([guildId, status])
|
||||
@@index([channelId])
|
||||
}
|
||||
|
||||
model SelfRolePanel {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
channelId String
|
||||
messageId String?
|
||||
title String
|
||||
description String?
|
||||
mode String @default("BUTTONS")
|
||||
behavior String @default("NORMAL")
|
||||
roles Json
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([guildId])
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
name String
|
||||
content String?
|
||||
embed Json?
|
||||
responseType String @default("TEXT")
|
||||
triggerWord String?
|
||||
allowedRoleIds String[] @default([])
|
||||
allowedChannelIds String[] @default([])
|
||||
createdById String
|
||||
useCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([guildId, name])
|
||||
@@index([guildId])
|
||||
}
|
||||
|
||||
model StarboardConfig {
|
||||
id String @id @default(cuid())
|
||||
guildId String @unique
|
||||
enabled Boolean @default(false)
|
||||
channelId String?
|
||||
emoji String @default("⭐")
|
||||
threshold Int @default(3)
|
||||
allowSelfStar Boolean @default(false)
|
||||
ignoredChannelIds String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model StarboardEntry {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
sourceChannelId String
|
||||
sourceMessageId String
|
||||
starboardMessageId String
|
||||
starCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([guildId, sourceMessageId])
|
||||
@@index([guildId])
|
||||
}
|
||||
|
||||
model SuggestionConfig {
|
||||
id String @id @default(cuid())
|
||||
guildId String @unique
|
||||
enabled Boolean @default(true)
|
||||
openChannelId String?
|
||||
approvedChannelId String?
|
||||
deniedChannelId String?
|
||||
createThread Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Suggestion {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
authorId String
|
||||
content String
|
||||
status String @default("OPEN")
|
||||
messageId String?
|
||||
channelId String?
|
||||
threadId String?
|
||||
upvotes Int @default(0)
|
||||
downvotes Int @default(0)
|
||||
voterUpIds String[] @default([])
|
||||
voterDownIds String[] @default([])
|
||||
staffReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([guildId, status])
|
||||
}
|
||||
|
||||
model BirthdayConfig {
|
||||
id String @id @default(cuid())
|
||||
guildId String @unique
|
||||
enabled Boolean @default(true)
|
||||
channelId String?
|
||||
roleId String?
|
||||
message String?
|
||||
timezone String @default("Europe/Berlin")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Birthday {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
userId String
|
||||
month Int
|
||||
day Int
|
||||
year Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([guildId, userId])
|
||||
@@index([guildId, month, day])
|
||||
}
|
||||
|
||||
model TempVoiceConfig {
|
||||
id String @id @default(cuid())
|
||||
guildId String @unique
|
||||
enabled Boolean @default(false)
|
||||
hubChannelId String?
|
||||
categoryId String?
|
||||
defaultName String @default("{user}'s Channel")
|
||||
defaultLimit Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TempVoiceChannel {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
channelId String @unique
|
||||
ownerId String
|
||||
controlMessageId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([guildId, ownerId])
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||
import { completeVerification } from './modules/verification/handlers.js';
|
||||
import { closePoll } from './modules/utility/poll.js';
|
||||
import { sendReminder } from './modules/utility/reminders.js';
|
||||
import { expireSelfRole } from './modules/selfroles/service.js';
|
||||
import {
|
||||
automodQueue,
|
||||
automodQueueName,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
backupQueueName,
|
||||
moderationQueueName,
|
||||
reminderQueueName,
|
||||
ticketQueueName,
|
||||
verificationQueueName
|
||||
} from './queues.js';
|
||||
|
||||
@@ -49,6 +51,12 @@ type PollCloseJob = {
|
||||
pollId: string;
|
||||
};
|
||||
|
||||
type SelfRoleExpireJob = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export function startWorkers(context: BotContext): Worker[] {
|
||||
const moderationWorker = new Worker<TempBanJob>(
|
||||
moderationQueueName,
|
||||
@@ -135,7 +143,22 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
|
||||
});
|
||||
|
||||
return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker];
|
||||
const ticketWorker = new Worker<SelfRoleExpireJob>(
|
||||
ticketQueueName,
|
||||
async (job) => {
|
||||
if (job.name !== 'selfRoleExpire') {
|
||||
return;
|
||||
}
|
||||
await expireSelfRole(context, job.data.guildId, job.data.userId, job.data.roleId);
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
ticketWorker.on('failed', (job, error) => {
|
||||
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
||||
});
|
||||
|
||||
return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker, ticketWorker];
|
||||
}
|
||||
|
||||
export async function ensureRecurringJobs(): Promise<void> {
|
||||
|
||||
45
apps/bot/src/modules/birthdays/command-definitions.ts
Normal file
45
apps/bot/src/modules/birthdays/command-definitions.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ChannelType } from 'discord.js';
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const birthdayCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('birthday')
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('set'), 'birthdays.set.description')
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(o.setName('month').setRequired(true).setMinValue(1).setMaxValue(12), 'birthdays.set.options.month')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(o.setName('day').setRequired(true).setMinValue(1).setMaxValue(31), 'birthdays.set.options.day')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('year').setMinValue(1900).setMaxValue(new Date().getFullYear()),
|
||||
'birthdays.set.options.year'
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('remove'), 'birthdays.remove.description'))
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('next'), 'birthdays.next.description'))
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'birthdays.list.description'))
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('setup'), 'birthdays.setup.description')
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
||||
'birthdays.setup.options.channel'
|
||||
)
|
||||
)
|
||||
.addRoleOption((o) =>
|
||||
applyOptionDescription(o.setName('role'), 'birthdays.setup.options.role')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('timezone'), 'birthdays.setup.options.timezone')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('message'), 'birthdays.setup.options.message')
|
||||
)
|
||||
),
|
||||
'birthdays.description'
|
||||
);
|
||||
126
apps/bot/src/modules/birthdays/commands.ts
Normal file
126
apps/bot/src/modules/birthdays/commands.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { birthdayCommandData } from './command-definitions.js';
|
||||
import {
|
||||
BirthdaySetSchema,
|
||||
daysUntilBirthday,
|
||||
getBirthdayConfig,
|
||||
isValidTimezone,
|
||||
listBirthdays,
|
||||
removeBirthday,
|
||||
setBirthday,
|
||||
updateBirthdayConfig
|
||||
} from './service.js';
|
||||
|
||||
const birthdayCommand: SlashCommand = {
|
||||
data: birthdayCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = interaction.guildId!;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'setup') {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.options.getChannel('channel', true);
|
||||
const role = interaction.options.getRole('role');
|
||||
const timezone = interaction.options.getString('timezone') ?? 'Europe/Berlin';
|
||||
const message = interaction.options.getString('message');
|
||||
|
||||
if (!isValidTimezone(timezone)) {
|
||||
await interaction.reply({ content: t(locale, 'birthdays.error.invalidTimezone'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await updateBirthdayConfig(context.prisma, guildId, {
|
||||
enabled: true,
|
||||
channelId: channel.id,
|
||||
roleId: role?.id ?? null,
|
||||
message: message ?? null,
|
||||
timezone
|
||||
});
|
||||
|
||||
await interaction.reply({ content: t(locale, 'birthdays.setup.done'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getBirthdayConfig(context.prisma, guildId);
|
||||
|
||||
if (sub === 'set') {
|
||||
const month = interaction.options.getInteger('month', true);
|
||||
const day = interaction.options.getInteger('day', true);
|
||||
const year = interaction.options.getInteger('year') ?? undefined;
|
||||
const parsed = BirthdaySetSchema.safeParse({ month, day, year });
|
||||
|
||||
if (!parsed.success) {
|
||||
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setBirthday(context.prisma, guildId, interaction.user.id, parsed.data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'invalid_date') {
|
||||
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'birthdays.set.success', { month, day, year: year ?? '—' }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const removed = await removeBirthday(context.prisma, guildId, interaction.user.id);
|
||||
if (!removed) {
|
||||
await interaction.reply({ content: t(locale, 'birthdays.remove.none'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ content: t(locale, 'birthdays.remove.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'list' || sub === 'next') {
|
||||
const entries = await listBirthdays(context.prisma, guildId, config.timezone);
|
||||
if (entries.length === 0) {
|
||||
await interaction.reply({ content: t(locale, 'birthdays.list.empty'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const slice = sub === 'next' ? entries.slice(0, 10) : entries.slice(0, 25);
|
||||
const lines = slice.map((entry, index) => {
|
||||
const days = daysUntilBirthday(entry.month, entry.day, config.timezone);
|
||||
const dateLabel = `${String(entry.day).padStart(2, '0')}.${String(entry.month).padStart(2, '0')}`;
|
||||
const yearLabel = entry.year ? ` (${entry.year})` : '';
|
||||
return tf(locale, 'birthdays.list.line', {
|
||||
rank: index + 1,
|
||||
user: `<@${entry.userId}>`,
|
||||
date: `${dateLabel}${yearLabel}`,
|
||||
days
|
||||
});
|
||||
});
|
||||
|
||||
const header = t(locale, sub === 'next' ? 'birthdays.next.header' : 'birthdays.list.header');
|
||||
await interaction.reply({
|
||||
content: `${header}\n${lines.join('\n')}`,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const birthdayCommands: SlashCommand[] = [birthdayCommand];
|
||||
2
apps/bot/src/modules/birthdays/index.ts
Normal file
2
apps/bot/src/modules/birthdays/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { birthdayCommands } from './commands.js';
|
||||
export { runBirthdayCheck, expireBirthdayRole } from './service.js';
|
||||
304
apps/bot/src/modules/birthdays/service.ts
Normal file
304
apps/bot/src/modules/birthdays/service.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { PermissionFlagsBits, type Guild, type GuildMember, type TextChannel } from 'discord.js';
|
||||
import { z } from 'zod';
|
||||
import { applyTagPlaceholders, tf } from '@nexumi/shared';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { birthdayQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
|
||||
export const BirthdaySetSchema = z.object({
|
||||
month: z.number().int().min(1).max(12),
|
||||
day: z.number().int().min(1).max(31),
|
||||
year: z.number().int().min(1900).max(new Date().getFullYear()).optional()
|
||||
});
|
||||
|
||||
export type BirthdaySetInput = z.infer<typeof BirthdaySetSchema>;
|
||||
|
||||
const DEFAULT_MESSAGE = 'Happy birthday {user}! 🎂';
|
||||
const ROLE_EXPIRE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function congratsRedisKey(guildId: string, userId: string, dateKey: string): string {
|
||||
return `birthday:sent:${guildId}:${userId}:${dateKey}`;
|
||||
}
|
||||
|
||||
export function isValidTimezone(timezone: string): boolean {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDatePartsInTimezone(
|
||||
timezone: string,
|
||||
date = new Date()
|
||||
): { month: number; day: number; year: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
}).formatToParts(date);
|
||||
|
||||
return {
|
||||
year: Number(parts.find((p) => p.type === 'year')!.value),
|
||||
month: Number(parts.find((p) => p.type === 'month')!.value),
|
||||
day: Number(parts.find((p) => p.type === 'day')!.value)
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidCalendarDate(month: number, day: number, year?: number): boolean {
|
||||
const probeYear = year ?? 2000;
|
||||
const date = new Date(probeYear, month - 1, day);
|
||||
return date.getFullYear() === probeYear && date.getMonth() === month - 1 && date.getDate() === day;
|
||||
}
|
||||
|
||||
export function computeAge(year: number, month: number, day: number, timezone: string): number {
|
||||
const today = getDatePartsInTimezone(timezone);
|
||||
let age = today.year - year;
|
||||
if (today.month < month || (today.month === month && today.day < day)) {
|
||||
age -= 1;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
export function daysUntilBirthday(month: number, day: number, timezone: string): number {
|
||||
const today = getDatePartsInTimezone(timezone);
|
||||
let year = today.year;
|
||||
let candidate = new Date(Date.UTC(year, month - 1, day));
|
||||
const todayUtc = new Date(Date.UTC(today.year, today.month - 1, today.day));
|
||||
|
||||
if (candidate.getTime() < todayUtc.getTime()) {
|
||||
year += 1;
|
||||
candidate = new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
return Math.round((candidate.getTime() - todayUtc.getTime()) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
export async function getBirthdayConfig(prisma: BotContext['prisma'], guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
let config = await prisma.birthdayConfig.findUnique({ where: { guildId } });
|
||||
if (!config) {
|
||||
config = await prisma.birthdayConfig.create({ data: { guildId } });
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function updateBirthdayConfig(
|
||||
prisma: BotContext['prisma'],
|
||||
guildId: string,
|
||||
data: {
|
||||
enabled?: boolean;
|
||||
channelId?: string | null;
|
||||
roleId?: string | null;
|
||||
message?: string | null;
|
||||
timezone?: string;
|
||||
}
|
||||
) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
return prisma.birthdayConfig.upsert({
|
||||
where: { guildId },
|
||||
update: data,
|
||||
create: { guildId, ...data }
|
||||
});
|
||||
}
|
||||
|
||||
export async function setBirthday(
|
||||
prisma: BotContext['prisma'],
|
||||
guildId: string,
|
||||
userId: string,
|
||||
input: BirthdaySetInput
|
||||
) {
|
||||
if (!isValidCalendarDate(input.month, input.day, input.year)) {
|
||||
throw new Error('invalid_date');
|
||||
}
|
||||
|
||||
return prisma.birthday.upsert({
|
||||
where: { guildId_userId: { guildId, userId } },
|
||||
update: {
|
||||
month: input.month,
|
||||
day: input.day,
|
||||
year: input.year ?? null
|
||||
},
|
||||
create: {
|
||||
guildId,
|
||||
userId,
|
||||
month: input.month,
|
||||
day: input.day,
|
||||
year: input.year ?? null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeBirthday(prisma: BotContext['prisma'], guildId: string, userId: string) {
|
||||
const existing = await prisma.birthday.findUnique({
|
||||
where: { guildId_userId: { guildId, userId } }
|
||||
});
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
await prisma.birthday.delete({ where: { guildId_userId: { guildId, userId } } });
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function listBirthdays(prisma: BotContext['prisma'], guildId: string, timezone: string) {
|
||||
const birthdays = await prisma.birthday.findMany({ where: { guildId } });
|
||||
return birthdays.sort((a, b) => {
|
||||
const diff = daysUntilBirthday(a.month, a.day, timezone) - daysUntilBirthday(b.month, b.day, timezone);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
return a.userId.localeCompare(b.userId);
|
||||
});
|
||||
}
|
||||
|
||||
export async function scheduleBirthdayRoleExpire(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
await birthdayQueue.add(
|
||||
'birthdayRoleExpire',
|
||||
{ guildId, userId, roleId },
|
||||
{
|
||||
delay: ROLE_EXPIRE_MS,
|
||||
jobId: `birthday-role-${guildId}-${userId}-${Date.now()}`,
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 500
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function renderBirthdayMessage(
|
||||
template: string | null | undefined,
|
||||
member: GuildMember,
|
||||
age?: number
|
||||
): string {
|
||||
const vars: Record<string, string> = {
|
||||
user: `<@${member.id}>`,
|
||||
'user.name': member.displayName,
|
||||
'user.tag': member.user.tag
|
||||
};
|
||||
if (age !== undefined) {
|
||||
vars.age = String(age);
|
||||
}
|
||||
return applyTagPlaceholders(template?.trim() || DEFAULT_MESSAGE, vars);
|
||||
}
|
||||
|
||||
async function sendBirthdayCongrats(
|
||||
context: BotContext,
|
||||
guild: Guild,
|
||||
config: Awaited<ReturnType<typeof getBirthdayConfig>>,
|
||||
birthday: { userId: string; month: number; day: number; year: number | null },
|
||||
dateKey: string
|
||||
): Promise<void> {
|
||||
if (!config.channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupeKey = congratsRedisKey(guild.id, birthday.userId, dateKey);
|
||||
const alreadySent = await context.redis.get(dedupeKey);
|
||||
if (alreadySent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await guild.channels.fetch(config.channelId).catch(() => null);
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
logger.warn({ guildId: guild.id, channelId: config.channelId }, 'Birthday channel invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await guild.members.fetch(birthday.userId).catch(() => null);
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
|
||||
const age =
|
||||
birthday.year !== null
|
||||
? computeAge(birthday.year, birthday.month, birthday.day, config.timezone)
|
||||
: undefined;
|
||||
const content = renderBirthdayMessage(config.message, member, age);
|
||||
|
||||
await (channel as TextChannel).send({ content });
|
||||
|
||||
if (config.roleId) {
|
||||
const me = guild.members.me;
|
||||
const role = guild.roles.cache.get(config.roleId);
|
||||
if (
|
||||
me?.permissions.has(PermissionFlagsBits.ManageRoles) &&
|
||||
role &&
|
||||
role.position < me.roles.highest.position &&
|
||||
!member.roles.cache.has(role.id)
|
||||
) {
|
||||
await member.roles.add(role).catch((error) => {
|
||||
logger.warn({ error, guildId: guild.id, userId: birthday.userId }, 'Failed to assign birthday role');
|
||||
});
|
||||
await scheduleBirthdayRoleExpire(guild.id, birthday.userId, role.id);
|
||||
}
|
||||
}
|
||||
|
||||
await context.redis.set(dedupeKey, '1', 'EX', 60 * 60 * 26);
|
||||
logger.info({ guildId: guild.id, userId: birthday.userId }, 'Birthday congratulations sent');
|
||||
}
|
||||
|
||||
export async function expireBirthdayRole(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = guild.members.me;
|
||||
const role = guild.roles.cache.get(roleId);
|
||||
if (
|
||||
!me?.permissions.has(PermissionFlagsBits.ManageRoles) ||
|
||||
!role ||
|
||||
role.position >= me.roles.highest.position
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.roles.cache.has(roleId)) {
|
||||
await member.roles.remove(roleId).catch((error) => {
|
||||
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove birthday role');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function runBirthdayCheck(context: BotContext): Promise<void> {
|
||||
const configs = await context.prisma.birthdayConfig.findMany({
|
||||
where: { enabled: true, channelId: { not: null } }
|
||||
});
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
const guild = await context.client.guilds.fetch(config.guildId).catch(() => null);
|
||||
if (!guild) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { month, day, year } = getDatePartsInTimezone(config.timezone);
|
||||
const dateKey = `${year}-${month}-${day}`;
|
||||
const birthdays = await context.prisma.birthday.findMany({
|
||||
where: { guildId: config.guildId, month, day }
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
birthdays.map((birthday) => sendBirthdayCongrats(context, guild, config, birthday, dateKey))
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error, guildId: config.guildId }, 'Birthday check failed for guild');
|
||||
}
|
||||
}
|
||||
}
|
||||
42
apps/bot/src/modules/giveaways/buttons.ts
Normal file
42
apps/bot/src/modules/giveaways/buttons.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ButtonInteraction } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { GiveawayError, joinGiveaway } from './service.js';
|
||||
|
||||
export const GIVEAWAY_JOIN_PREFIX = 'gw:join:';
|
||||
|
||||
export function joinButtonId(giveawayId: string): string {
|
||||
return `${GIVEAWAY_JOIN_PREFIX}${giveawayId}`;
|
||||
}
|
||||
|
||||
export function isGiveawayButton(customId: string): boolean {
|
||||
return customId.startsWith(GIVEAWAY_JOIN_PREFIX);
|
||||
}
|
||||
|
||||
export async function handleGiveawayButton(
|
||||
interaction: ButtonInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
const giveawayId = interaction.customId.slice(GIVEAWAY_JOIN_PREFIX.length);
|
||||
|
||||
if (!giveawayId || !interaction.guildId) {
|
||||
await interaction.reply({ content: t(locale, 'giveaway.error.not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await joinGiveaway(context, giveawayId, interaction.user.id, locale);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.joined'), ephemeral: true });
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `giveaway.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
61
apps/bot/src/modules/giveaways/command-definitions.ts
Normal file
61
apps/bot/src/modules/giveaways/command-definitions.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const giveawayCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('giveaway')
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('duration').setRequired(true), 'giveaway.start.options.duration')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('winners').setRequired(true).setMinValue(1).setMaxValue(25),
|
||||
'giveaway.start.options.winners'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('prize').setRequired(true), 'giveaway.start.options.prize')
|
||||
)
|
||||
.addRoleOption((o) =>
|
||||
applyOptionDescription(o.setName('role'), 'giveaway.start.options.role')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('level').setMinValue(0).setMaxValue(500),
|
||||
'giveaway.start.options.level'
|
||||
)
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('member_days').setMinValue(0).setMaxValue(3650),
|
||||
'giveaway.start.options.member_days'
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('end'), 'giveaway.end.description').addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('reroll'), 'giveaway.reroll.description').addStringOption(
|
||||
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('list'), 'giveaway.list.description')
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('delete'), 'giveaway.delete.description').addStringOption(
|
||||
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('pause'), 'giveaway.pause.description').addStringOption(
|
||||
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||
)
|
||||
),
|
||||
'giveaway.description'
|
||||
);
|
||||
166
apps/bot/src/modules/giveaways/commands.ts
Normal file
166
apps/bot/src/modules/giveaways/commands.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { parseDuration } from '../moderation/duration.js';
|
||||
import { giveawayCommandData } from './command-definitions.js';
|
||||
import {
|
||||
deleteGiveaway,
|
||||
endGiveaway,
|
||||
GiveawayError,
|
||||
listActiveGiveaways,
|
||||
pauseGiveaway,
|
||||
rerollGiveaway,
|
||||
startGiveaway
|
||||
} from './service.js';
|
||||
|
||||
async function ensureManageGuild(
|
||||
interaction: Parameters<SlashCommand['execute']>[0],
|
||||
locale: 'de' | 'en'
|
||||
): Promise<boolean> {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||
}
|
||||
|
||||
const giveawayCommand: SlashCommand = {
|
||||
data: giveawayCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'start') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const durationInput = interaction.options.getString('duration', true);
|
||||
let durationMs: number;
|
||||
try {
|
||||
durationMs = parseDuration(durationInput);
|
||||
} catch {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'giveaway.error.invalid_duration'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationMs < 60_000) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'giveaway.error.duration_too_short'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'giveaway.error.invalid_channel'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const giveaway = await startGiveaway(
|
||||
context,
|
||||
{
|
||||
guildId: interaction.guildId!,
|
||||
channelId: channel.id,
|
||||
hostId: interaction.user.id,
|
||||
prize: interaction.options.getString('prize', true),
|
||||
winnerCount: interaction.options.getInteger('winners', true),
|
||||
durationMs,
|
||||
requiredRoleId: interaction.options.getRole('role')?.id,
|
||||
requiredLevel: interaction.options.getInteger('level'),
|
||||
requiredMemberDays: interaction.options.getInteger('member_days')
|
||||
},
|
||||
locale
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (sub === 'end') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
await endGiveaway(context, id);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'reroll') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const updated = await rerollGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'giveaway.rerolled', {
|
||||
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
||||
}),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const active = await listActiveGiveaways(context, interaction.guildId!);
|
||||
if (active.length === 0) {
|
||||
await interaction.reply({ content: t(locale, 'giveaway.list.empty'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const lines = active.map(
|
||||
(g) =>
|
||||
`\`${g.id}\` — **${g.prize}** (${g.entrants.length} ${t(locale, 'giveaway.list.entrants')}, <t:${Math.floor(g.endsAt.getTime() / 1000)}:R>)`
|
||||
);
|
||||
await interaction.reply({
|
||||
content: `${t(locale, 'giveaway.list.header')}\n${lines.join('\n')}`,
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'delete') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
await deleteGiveaway(context, id);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'pause') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const updated = await pauseGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `giveaway.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const giveawayCommands = [giveawayCommand];
|
||||
3
apps/bot/src/modules/giveaways/index.ts
Normal file
3
apps/bot/src/modules/giveaways/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { giveawayCommands } from './commands.js';
|
||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||
export { endGiveaway, scheduleGiveawayEnd } from './service.js';
|
||||
419
apps/bot/src/modules/giveaways/service.ts
Normal file
419
apps/bot/src/modules/giveaways/service.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
type Guild,
|
||||
type GuildMember,
|
||||
type TextChannel
|
||||
} from 'discord.js';
|
||||
import { pickRandomWinners, t, tf } from '@nexumi/shared';
|
||||
import type { Giveaway } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { giveawayQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { joinButtonId } from './buttons.js';
|
||||
|
||||
export class GiveawayError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
|
||||
const delay = Math.max(0, endsAt.getTime() - Date.now());
|
||||
await giveawayQueue.add(
|
||||
'giveawayEnd',
|
||||
{ giveawayId },
|
||||
{
|
||||
delay,
|
||||
jobId: `giveaway-end-${giveawayId}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
||||
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
|
||||
await job?.remove();
|
||||
}
|
||||
|
||||
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
||||
const lines: string[] = [];
|
||||
if (giveaway.requiredRoleId) {
|
||||
lines.push(tf(locale, 'giveaway.requirement.role', { role: `<@&${giveaway.requiredRoleId}>` }));
|
||||
}
|
||||
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
|
||||
lines.push(tf(locale, 'giveaway.requirement.level', { level: giveaway.requiredLevel }));
|
||||
}
|
||||
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
|
||||
lines.push(
|
||||
tf(locale, 'giveaway.requirement.memberDays', { days: giveaway.requiredMemberDays })
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
|
||||
const ended = giveaway.endedAt !== null;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(t(locale, 'giveaway.embed.title'))
|
||||
.setColor(ended ? 0x6b7280 : 0x6366f1)
|
||||
.addFields(
|
||||
{ name: t(locale, 'giveaway.embed.prize'), value: giveaway.prize, inline: true },
|
||||
{
|
||||
name: t(locale, 'giveaway.embed.winners'),
|
||||
value: String(giveaway.winnerCount),
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: t(locale, 'giveaway.embed.entrants'),
|
||||
value: String(giveaway.entrants.length),
|
||||
inline: true
|
||||
}
|
||||
);
|
||||
|
||||
if (ended) {
|
||||
embed.setDescription(
|
||||
giveaway.winners.length > 0
|
||||
? tf(locale, 'giveaway.embed.endedWinners', {
|
||||
winners: giveaway.winners.map((id) => `<@${id}>`).join(', ')
|
||||
})
|
||||
: t(locale, 'giveaway.embed.endedNoWinners')
|
||||
);
|
||||
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
|
||||
} else if (giveaway.paused) {
|
||||
embed.setDescription(t(locale, 'giveaway.embed.paused'));
|
||||
embed.setFooter({
|
||||
text: tf(locale, 'giveaway.embed.endsAt', {
|
||||
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
|
||||
})
|
||||
});
|
||||
} else {
|
||||
embed.setDescription(t(locale, 'giveaway.embed.active'));
|
||||
embed.setFooter({
|
||||
text: tf(locale, 'giveaway.embed.endsAt', {
|
||||
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const requirements = requirementLines(locale, giveaway);
|
||||
if (requirements.length > 0) {
|
||||
embed.addFields({
|
||||
name: t(locale, 'giveaway.embed.requirements'),
|
||||
value: requirements.join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
embed.addFields({
|
||||
name: t(locale, 'giveaway.embed.host'),
|
||||
value: `<@${giveaway.hostId}>`,
|
||||
inline: true
|
||||
});
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function buildGiveawayComponents(
|
||||
locale: 'de' | 'en',
|
||||
giveaway: Giveaway
|
||||
): ActionRowBuilder<ButtonBuilder>[] {
|
||||
if (giveaway.endedAt) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(joinButtonId(giveaway.id))
|
||||
.setLabel(t(locale, 'giveaway.button.join'))
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setDisabled(giveaway.paused)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
async function updateGiveawayMessage(
|
||||
context: BotContext,
|
||||
giveaway: Giveaway,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<void> {
|
||||
if (!giveaway.messageId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(giveaway.messageId);
|
||||
await message.edit({
|
||||
embeds: [buildGiveawayEmbed(locale, giveaway)],
|
||||
components: buildGiveawayComponents(locale, giveaway)
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, giveawayId: giveaway.id }, 'Failed to update giveaway message');
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkGiveawayRequirements(
|
||||
context: BotContext,
|
||||
guild: Guild,
|
||||
member: GuildMember,
|
||||
giveaway: Giveaway
|
||||
): Promise<string | null> {
|
||||
if (giveaway.requiredRoleId && !member.roles.cache.has(giveaway.requiredRoleId)) {
|
||||
return 'missing_role';
|
||||
}
|
||||
|
||||
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
|
||||
const levelRow = await context.prisma.memberLevel.findUnique({
|
||||
where: { guildId_userId: { guildId: guild.id, userId: member.id } }
|
||||
});
|
||||
const level = levelRow?.level ?? 0;
|
||||
if (level < giveaway.requiredLevel) {
|
||||
return 'insufficient_level';
|
||||
}
|
||||
}
|
||||
|
||||
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
|
||||
const joinedAt = member.joinedAt;
|
||||
const days = joinedAt
|
||||
? Math.floor((Date.now() - joinedAt.getTime()) / 86_400_000)
|
||||
: 0;
|
||||
if (days < giveaway.requiredMemberDays) {
|
||||
return 'insufficient_member_days';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function startGiveaway(
|
||||
context: BotContext,
|
||||
params: {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
hostId: string;
|
||||
prize: string;
|
||||
winnerCount: number;
|
||||
durationMs: number;
|
||||
requiredRoleId?: string | null;
|
||||
requiredLevel?: number | null;
|
||||
requiredMemberDays?: number | null;
|
||||
},
|
||||
locale: 'de' | 'en'
|
||||
): Promise<Giveaway> {
|
||||
await ensureGuild(context.prisma, params.guildId);
|
||||
const endsAt = new Date(Date.now() + params.durationMs);
|
||||
|
||||
const giveaway = await context.prisma.giveaway.create({
|
||||
data: {
|
||||
guildId: params.guildId,
|
||||
channelId: params.channelId,
|
||||
prize: params.prize,
|
||||
winnerCount: params.winnerCount,
|
||||
endsAt,
|
||||
requiredRoleId: params.requiredRoleId ?? null,
|
||||
requiredLevel: params.requiredLevel ?? null,
|
||||
requiredMemberDays: params.requiredMemberDays ?? null,
|
||||
hostId: params.hostId
|
||||
}
|
||||
});
|
||||
|
||||
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
|
||||
const message = await channel.send({
|
||||
embeds: [buildGiveawayEmbed(locale, giveaway)],
|
||||
components: buildGiveawayComponents(locale, giveaway)
|
||||
});
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveaway.id },
|
||||
data: { messageId: message.id }
|
||||
});
|
||||
|
||||
await scheduleGiveawayEnd(updated.id, updated.endsAt);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function joinGiveaway(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
userId: string,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<Giveaway> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway || giveaway.endedAt) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
if (giveaway.paused) {
|
||||
throw new GiveawayError('paused');
|
||||
}
|
||||
if (giveaway.entrants.includes(userId)) {
|
||||
throw new GiveawayError('already_joined');
|
||||
}
|
||||
|
||||
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
if (!member) {
|
||||
throw new GiveawayError('member_not_found');
|
||||
}
|
||||
|
||||
const requirementError = await checkGiveawayRequirements(context, guild, member, giveaway);
|
||||
if (requirementError) {
|
||||
throw new GiveawayError(requirementError);
|
||||
}
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveawayId },
|
||||
data: { entrants: { push: userId } }
|
||||
});
|
||||
|
||||
await updateGiveawayMessage(context, updated, locale);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function filterEligibleEntrants(
|
||||
context: BotContext,
|
||||
guild: Guild,
|
||||
giveaway: Giveaway
|
||||
): Promise<string[]> {
|
||||
const eligible: string[] = [];
|
||||
for (const userId of giveaway.entrants) {
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
const error = await checkGiveawayRequirements(context, guild, member, giveaway);
|
||||
if (!error) {
|
||||
eligible.push(userId);
|
||||
}
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
|
||||
async function dmWinners(
|
||||
context: BotContext,
|
||||
giveaway: Giveaway,
|
||||
winnerIds: string[],
|
||||
locale: 'de' | 'en'
|
||||
): Promise<void> {
|
||||
for (const userId of winnerIds) {
|
||||
try {
|
||||
const user = await context.client.users.fetch(userId);
|
||||
await user.send(
|
||||
tf(locale, 'giveaway.dm.won', {
|
||||
prize: giveaway.prize,
|
||||
guild: (await context.client.guilds.fetch(giveaway.guildId)).name
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn({ error, userId, giveawayId: giveaway.id }, 'Failed to DM giveaway winner');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway || giveaway.endedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
await cancelGiveawayJob(giveawayId);
|
||||
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
||||
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveawayId },
|
||||
data: {
|
||||
endedAt: new Date(),
|
||||
winners,
|
||||
paused: false
|
||||
}
|
||||
});
|
||||
|
||||
await updateGiveawayMessage(context, updated, locale);
|
||||
if (winners.length > 0) {
|
||||
await dmWinners(context, updated, winners, locale);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rerollGiveaway(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<Giveaway> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway || !giveaway.endedAt) {
|
||||
throw new GiveawayError('not_ended');
|
||||
}
|
||||
|
||||
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||
const eligible = (await filterEligibleEntrants(context, guild, giveaway)).filter(
|
||||
(id) => !giveaway.winners.includes(id)
|
||||
);
|
||||
const newWinners = pickRandomWinners(eligible, giveaway.winnerCount);
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveawayId },
|
||||
data: { winners: newWinners }
|
||||
});
|
||||
|
||||
await updateGiveawayMessage(context, updated, locale);
|
||||
if (newWinners.length > 0) {
|
||||
await dmWinners(context, updated, newWinners, locale);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function pauseGiveaway(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<Giveaway> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway || giveaway.endedAt) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveawayId },
|
||||
data: { paused: !giveaway.paused }
|
||||
});
|
||||
|
||||
await updateGiveawayMessage(context, updated, locale);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
|
||||
await cancelGiveawayJob(giveawayId);
|
||||
|
||||
if (giveaway.messageId) {
|
||||
try {
|
||||
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(giveaway.messageId);
|
||||
await message.delete();
|
||||
} catch {
|
||||
// Message may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
await context.prisma.giveaway.delete({ where: { id: giveawayId } });
|
||||
}
|
||||
|
||||
export async function listActiveGiveaways(
|
||||
context: BotContext,
|
||||
guildId: string
|
||||
): Promise<Giveaway[]> {
|
||||
return context.prisma.giveaway.findMany({
|
||||
where: { guildId, endedAt: null },
|
||||
orderBy: { endsAt: 'asc' }
|
||||
});
|
||||
}
|
||||
95
apps/bot/src/modules/selfroles/command-definitions.ts
Normal file
95
apps/bot/src/modules/selfroles/command-definitions.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
applyCommandDescription,
|
||||
applyOptionDescription,
|
||||
localizedChoice
|
||||
} from '@nexumi/shared';
|
||||
import { ChannelType, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const selfrolesCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('selfroles')
|
||||
.addSubcommandGroup((group) =>
|
||||
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('create'), 'selfroles.panel.create.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('title').setRequired(true), 'selfroles.panel.create.options.title')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('mode').setRequired(true), 'selfroles.panel.create.options.mode')
|
||||
.addChoices(
|
||||
localizedChoice('selfroles.choice.mode.buttons', 'BUTTONS'),
|
||||
localizedChoice('selfroles.choice.mode.dropdown', 'DROPDOWN'),
|
||||
localizedChoice('selfroles.choice.mode.reactions', 'REACTIONS')
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('behavior').setRequired(true),
|
||||
'selfroles.panel.create.options.behavior'
|
||||
)
|
||||
.addChoices(
|
||||
localizedChoice('selfroles.choice.behavior.normal', 'NORMAL'),
|
||||
localizedChoice('selfroles.choice.behavior.unique', 'UNIQUE'),
|
||||
localizedChoice('selfroles.choice.behavior.verify', 'VERIFY'),
|
||||
localizedChoice('selfroles.choice.behavior.temporary', 'TEMPORARY')
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('roles').setRequired(true), 'selfroles.panel.create.options.roles')
|
||||
)
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(o.setName('channel').setRequired(true), 'selfroles.panel.create.options.channel')
|
||||
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration')
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('edit'), 'selfroles.panel.edit.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('panel_id').setRequired(true), 'selfroles.panel.common.options.panel_id')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('title'), 'selfroles.panel.create.options.title')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('mode'), 'selfroles.panel.create.options.mode')
|
||||
.addChoices(
|
||||
localizedChoice('selfroles.choice.mode.buttons', 'BUTTONS'),
|
||||
localizedChoice('selfroles.choice.mode.dropdown', 'DROPDOWN'),
|
||||
localizedChoice('selfroles.choice.mode.reactions', 'REACTIONS')
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('behavior'), 'selfroles.panel.create.options.behavior')
|
||||
.addChoices(
|
||||
localizedChoice('selfroles.choice.behavior.normal', 'NORMAL'),
|
||||
localizedChoice('selfroles.choice.behavior.unique', 'UNIQUE'),
|
||||
localizedChoice('selfroles.choice.behavior.verify', 'VERIFY'),
|
||||
localizedChoice('selfroles.choice.behavior.temporary', 'TEMPORARY')
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('roles'), 'selfroles.panel.create.options.roles')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration')
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('delete'), 'selfroles.panel.delete.description').addStringOption(
|
||||
(o) =>
|
||||
applyOptionDescription(o.setName('panel_id').setRequired(true), 'selfroles.panel.common.options.panel_id')
|
||||
)
|
||||
)
|
||||
),
|
||||
'selfroles.description'
|
||||
);
|
||||
203
apps/bot/src/modules/selfroles/commands.ts
Normal file
203
apps/bot/src/modules/selfroles/commands.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import {
|
||||
SelfRoleBehaviorSchema,
|
||||
SelfRoleModeSchema,
|
||||
t,
|
||||
tf
|
||||
} from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { parseDuration } from '../moderation/duration.js';
|
||||
import { selfrolesCommandData } from './command-definitions.js';
|
||||
import {
|
||||
createSelfRolePanel,
|
||||
deleteSelfRolePanel,
|
||||
parseRolesInput,
|
||||
SelfRoleError,
|
||||
updateSelfRolePanel
|
||||
} from './service.js';
|
||||
|
||||
async function ensureManageGuild(
|
||||
interaction: Parameters<SlashCommand['execute']>[0],
|
||||
locale: 'de' | 'en'
|
||||
): Promise<boolean> {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||
}
|
||||
|
||||
function parseOptionalDuration(
|
||||
input: string | null,
|
||||
locale: 'de' | 'en',
|
||||
interaction: Parameters<SlashCommand['execute']>[0]
|
||||
): number | undefined | null {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return parseDuration(input);
|
||||
} catch {
|
||||
interaction.reply({
|
||||
content: t(locale, 'selfroles.error.invalid_duration'),
|
||||
ephemeral: true
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const selfrolesCommand: SlashCommand = {
|
||||
data: selfrolesCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = interaction.options.getSubcommandGroup(true);
|
||||
const sub = interaction.options.getSubcommand(true);
|
||||
|
||||
if (group !== 'panel') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (sub === 'create') {
|
||||
const behavior = SelfRoleBehaviorSchema.parse(
|
||||
interaction.options.getString('behavior', true)
|
||||
);
|
||||
const mode = SelfRoleModeSchema.parse(interaction.options.getString('mode', true));
|
||||
const channel = interaction.options.getChannel('channel', true);
|
||||
|
||||
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'selfroles.error.invalid_channel'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let temporaryDurationMs: number | undefined;
|
||||
if (behavior === 'TEMPORARY') {
|
||||
const durationInput = interaction.options.getString('duration', true);
|
||||
const parsed = parseOptionalDuration(durationInput, locale, interaction);
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
if (!parsed || parsed < 60_000) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'selfroles.error.duration_too_short'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
temporaryDurationMs = parsed;
|
||||
}
|
||||
|
||||
const roles = parseRolesInput(interaction.options.getString('roles', true));
|
||||
const panel = await createSelfRolePanel(
|
||||
context,
|
||||
{
|
||||
guildId: interaction.guildId!,
|
||||
channelId: channel.id,
|
||||
title: interaction.options.getString('title', true),
|
||||
description: interaction.options.getString('description'),
|
||||
mode,
|
||||
behavior,
|
||||
roles,
|
||||
temporaryDurationMs
|
||||
},
|
||||
locale
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'selfroles.panel.created', { id: panel.id }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'edit') {
|
||||
const panelId = interaction.options.getString('panel_id', true);
|
||||
const behaviorRaw = interaction.options.getString('behavior');
|
||||
const behavior = behaviorRaw ? SelfRoleBehaviorSchema.parse(behaviorRaw) : undefined;
|
||||
const modeRaw = interaction.options.getString('mode');
|
||||
const mode = modeRaw ? SelfRoleModeSchema.parse(modeRaw) : undefined;
|
||||
|
||||
let roles;
|
||||
const rolesInput = interaction.options.getString('roles');
|
||||
if (rolesInput) {
|
||||
roles = parseRolesInput(rolesInput);
|
||||
}
|
||||
|
||||
let temporaryDurationMs: number | undefined;
|
||||
const durationInput = interaction.options.getString('duration');
|
||||
if (durationInput) {
|
||||
const parsed = parseOptionalDuration(durationInput, locale, interaction);
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
if (!parsed || parsed < 60_000) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'selfroles.error.duration_too_short'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
temporaryDurationMs = parsed;
|
||||
} else if (behavior === 'TEMPORARY') {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'selfroles.error.duration_required'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionValue = interaction.options.getString('description');
|
||||
const panel = await updateSelfRolePanel(
|
||||
context,
|
||||
panelId,
|
||||
interaction.guildId!,
|
||||
{
|
||||
title: interaction.options.getString('title') ?? undefined,
|
||||
description: descriptionValue !== null ? descriptionValue : undefined,
|
||||
mode,
|
||||
behavior,
|
||||
roles,
|
||||
temporaryDurationMs
|
||||
},
|
||||
locale
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'selfroles.panel.updated', { id: panel.id }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'delete') {
|
||||
const panelId = interaction.options.getString('panel_id', true);
|
||||
await deleteSelfRolePanel(context, panelId, interaction.guildId!);
|
||||
await interaction.reply({
|
||||
content: t(locale, 'selfroles.panel.deleted'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRoleError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `selfroles.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const selfrolesCommands = [selfrolesCommand];
|
||||
8
apps/bot/src/modules/selfroles/index.ts
Normal file
8
apps/bot/src/modules/selfroles/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { selfrolesCommands } from './commands.js';
|
||||
export { isSelfRoleInteraction, handleSelfRoleInteraction } from './interactions.js';
|
||||
export { registerSelfRoleEvents } from './reactions.js';
|
||||
export {
|
||||
expireSelfRole,
|
||||
scheduleSelfRoleExpiry,
|
||||
cancelSelfRoleExpiry
|
||||
} from './service.js';
|
||||
132
apps/bot/src/modules/selfroles/interactions.ts
Normal file
132
apps/bot/src/modules/selfroles/interactions.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { ButtonInteraction, StringSelectMenuInteraction } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
SELF_ROLE_BTN_PREFIX,
|
||||
SELF_ROLE_SEL_PREFIX,
|
||||
SelfRoleError,
|
||||
toggleSelfRole
|
||||
} from './service.js';
|
||||
|
||||
export function isSelfRoleInteraction(customId: string): boolean {
|
||||
return customId.startsWith(SELF_ROLE_BTN_PREFIX) || customId.startsWith(SELF_ROLE_SEL_PREFIX);
|
||||
}
|
||||
|
||||
function parseButtonCustomId(customId: string): { panelId: string; roleId: string } | null {
|
||||
if (!customId.startsWith(SELF_ROLE_BTN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = customId.slice(SELF_ROLE_BTN_PREFIX.length);
|
||||
const colon = rest.indexOf(':');
|
||||
if (colon === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelId = rest.slice(0, colon);
|
||||
const roleId = rest.slice(colon + 1);
|
||||
if (!panelId || !roleId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { panelId, roleId };
|
||||
}
|
||||
|
||||
function parseSelectCustomId(customId: string): string | null {
|
||||
if (!customId.startsWith(SELF_ROLE_SEL_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return customId.slice(SELF_ROLE_SEL_PREFIX.length) || null;
|
||||
}
|
||||
|
||||
async function replySelfRoleResult(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||
locale: 'de' | 'en',
|
||||
result: 'added' | 'removed' | 'unchanged',
|
||||
behavior: string
|
||||
): Promise<void> {
|
||||
let key: string;
|
||||
if (result === 'added') {
|
||||
key = behavior === 'VERIFY' ? 'selfroles.role.verified' : 'selfroles.role.added';
|
||||
} else if (result === 'removed') {
|
||||
key = 'selfroles.role.removed';
|
||||
} else {
|
||||
key = 'selfroles.role.unchanged';
|
||||
}
|
||||
|
||||
const content = t(locale, key);
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ content, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoleToggle(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||
context: BotContext,
|
||||
panelId: string,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = await context.prisma.selfRolePanel.findFirst({
|
||||
where: { id: panelId, guildId: interaction.guild.id }
|
||||
});
|
||||
|
||||
if (!panel) {
|
||||
await interaction.reply({ content: t(locale, 'selfroles.error.not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({ content: t(locale, 'selfroles.error.member_not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toggleSelfRole(context, panel, member, roleId);
|
||||
await replySelfRoleResult(interaction, locale, result, panel.behavior);
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRoleError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `selfroles.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSelfRoleInteraction(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
if (interaction.isButton()) {
|
||||
const parsed = parseButtonCustomId(interaction.customId);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
await handleRoleToggle(interaction, context, parsed.panelId, parsed.roleId);
|
||||
return;
|
||||
}
|
||||
|
||||
const panelId = parseSelectCustomId(interaction.customId);
|
||||
if (!panelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = interaction.values[0];
|
||||
if (!roleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleRoleToggle(interaction, context, panelId, roleId);
|
||||
}
|
||||
132
apps/bot/src/modules/selfroles/reactions.ts
Normal file
132
apps/bot/src/modules/selfroles/reactions.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Events, type Client, type PartialUser, type User } from 'discord.js';
|
||||
import { SelfRoleModeSchema, t } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
findPanelByMessageId,
|
||||
matchReactionEntry,
|
||||
parseRolesJson,
|
||||
SelfRoleError,
|
||||
toggleSelfRole
|
||||
} from './service.js';
|
||||
|
||||
async function handleReactionToggle(
|
||||
context: BotContext,
|
||||
messageId: string,
|
||||
userId: string,
|
||||
emojiId: string | null,
|
||||
emojiName: string,
|
||||
add: boolean
|
||||
): Promise<void> {
|
||||
const panel = await findPanelByMessageId(context, messageId);
|
||||
if (!panel || SelfRoleModeSchema.parse(panel.mode) !== 'REACTIONS') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { entries } = parseRolesJson(panel.roles);
|
||||
const entry = matchReactionEntry(entries, emojiId, emojiName);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = await context.client.guilds.fetch(panel.guildId).catch(() => null);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
if (!member || member.user.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const behavior = panel.behavior;
|
||||
const hasRole = member.roles.cache.has(entry.roleId);
|
||||
|
||||
if (add) {
|
||||
if (behavior === 'VERIFY' || behavior === 'TEMPORARY') {
|
||||
if (hasRole) {
|
||||
return;
|
||||
}
|
||||
} else if (behavior === 'NORMAL' && hasRole) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (behavior === 'VERIFY') {
|
||||
return;
|
||||
}
|
||||
if (!hasRole) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await toggleSelfRole(context, panel, member, entry.roleId);
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRoleError) {
|
||||
const locale = await getGuildLocale(context.prisma, panel.guildId);
|
||||
try {
|
||||
const user = await context.client.users.fetch(userId);
|
||||
await user.send(t(locale, `selfroles.error.${error.code}`));
|
||||
} catch {
|
||||
// User may have DMs disabled
|
||||
}
|
||||
return;
|
||||
}
|
||||
logger.error({ error, panelId: panel.id, userId }, 'Self-role reaction handler failed');
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSelfRoleEvents(client: Client, context: BotContext): void {
|
||||
client.on(Events.MessageReactionAdd, async (reaction, user: User | PartialUser) => {
|
||||
try {
|
||||
if (user.bot || !reaction.message.guildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = reaction.message.partial
|
||||
? await reaction.message.fetch().catch(() => null)
|
||||
: reaction.message;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleReactionToggle(
|
||||
context,
|
||||
message.id,
|
||||
user.id,
|
||||
reaction.emoji.id,
|
||||
reaction.emoji.name ?? '',
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Self-role reaction add failed');
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.MessageReactionRemove, async (reaction: PartialMessageReaction, user: PartialUser) => {
|
||||
try {
|
||||
if (user.bot || !reaction.message.guildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = reaction.message.partial
|
||||
? await reaction.message.fetch().catch(() => null)
|
||||
: reaction.message;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleReactionToggle(
|
||||
context,
|
||||
message.id,
|
||||
user.id,
|
||||
reaction.emoji.id,
|
||||
reaction.emoji.name ?? '',
|
||||
false
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Self-role reaction remove failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
602
apps/bot/src/modules/selfroles/service.ts
Normal file
602
apps/bot/src/modules/selfroles/service.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
PermissionFlagsBits,
|
||||
StringSelectMenuBuilder,
|
||||
type APIMessageComponentEmoji,
|
||||
type GuildMember,
|
||||
type Message,
|
||||
type TextChannel
|
||||
} from 'discord.js';
|
||||
import {
|
||||
SelfRoleBehaviorSchema,
|
||||
SelfRoleEntrySchema,
|
||||
SelfRoleModeSchema,
|
||||
type SelfRoleBehavior,
|
||||
type SelfRoleEntry,
|
||||
type SelfRoleMode,
|
||||
t
|
||||
} from '@nexumi/shared';
|
||||
import type { SelfRolePanel } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { ticketQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
|
||||
export class SelfRoleError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export const SELF_ROLE_BTN_PREFIX = 'sr:btn:';
|
||||
export const SELF_ROLE_SEL_PREFIX = 'sr:sel:';
|
||||
|
||||
type RolesStorage = {
|
||||
entries: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
};
|
||||
|
||||
export function parseRolesInput(input: string): SelfRoleEntry[] {
|
||||
const parts = input
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
throw new SelfRoleError('invalid_roles');
|
||||
}
|
||||
|
||||
const entries: SelfRoleEntry[] = [];
|
||||
for (const part of parts) {
|
||||
const segments = part.split(':');
|
||||
if (segments.length < 2) {
|
||||
throw new SelfRoleError('invalid_roles');
|
||||
}
|
||||
const [roleId, label, emoji] = segments;
|
||||
if (!roleId || !label) {
|
||||
throw new SelfRoleError('invalid_roles');
|
||||
}
|
||||
entries.push(
|
||||
SelfRoleEntrySchema.parse({
|
||||
roleId: roleId.trim(),
|
||||
label: label.trim(),
|
||||
emoji: emoji?.trim() || undefined
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length > 25) {
|
||||
throw new SelfRoleError('too_many_roles');
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function parseRolesJson(raw: unknown): RolesStorage {
|
||||
if (Array.isArray(raw)) {
|
||||
return {
|
||||
entries: raw
|
||||
.map((item) => SelfRoleEntrySchema.safeParse(item))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.data!)
|
||||
};
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object') {
|
||||
const obj = raw as { entries?: unknown; temporaryDurationMs?: unknown };
|
||||
const entries = Array.isArray(obj.entries)
|
||||
? obj.entries
|
||||
.map((item) => SelfRoleEntrySchema.safeParse(item))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.data!)
|
||||
: [];
|
||||
const temporaryDurationMs =
|
||||
typeof obj.temporaryDurationMs === 'number' && obj.temporaryDurationMs > 0
|
||||
? obj.temporaryDurationMs
|
||||
: undefined;
|
||||
return { entries, temporaryDurationMs };
|
||||
}
|
||||
|
||||
return { entries: [] };
|
||||
}
|
||||
|
||||
export function serializeRolesJson(
|
||||
entries: SelfRoleEntry[],
|
||||
temporaryDurationMs?: number
|
||||
): RolesStorage {
|
||||
if (temporaryDurationMs !== undefined) {
|
||||
return { entries, temporaryDurationMs };
|
||||
}
|
||||
return { entries };
|
||||
}
|
||||
|
||||
function parseButtonEmoji(
|
||||
emoji?: string
|
||||
): APIMessageComponentEmoji | undefined {
|
||||
if (!emoji) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
|
||||
if (customMatch) {
|
||||
return {
|
||||
id: customMatch[3]!,
|
||||
name: customMatch[2],
|
||||
animated: Boolean(customMatch[1])
|
||||
};
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(emoji)) {
|
||||
return { id: emoji };
|
||||
}
|
||||
|
||||
return { name: emoji };
|
||||
}
|
||||
|
||||
function parseReactionEmoji(emoji?: string): string {
|
||||
if (!emoji) {
|
||||
throw new SelfRoleError('reaction_emoji_required');
|
||||
}
|
||||
|
||||
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
|
||||
if (customMatch) {
|
||||
return customMatch[3]!;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(emoji)) {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
return emoji;
|
||||
}
|
||||
|
||||
export function buildPanelEmbed(panel: SelfRolePanel, locale: 'de' | 'en'): EmbedBuilder {
|
||||
const { entries } = parseRolesJson(panel.roles);
|
||||
const roleLines = entries.map((entry) => {
|
||||
const prefix = entry.emoji ? `${entry.emoji} ` : '';
|
||||
return `${prefix}**${entry.label}** — <@&${entry.roleId}>`;
|
||||
});
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(panel.title)
|
||||
.setColor(0x6366f1)
|
||||
.setDescription(panel.description ?? t(locale, 'selfroles.panel.defaultDescription'));
|
||||
|
||||
if (roleLines.length > 0) {
|
||||
embed.addFields({
|
||||
name: t(locale, 'selfroles.panel.rolesField'),
|
||||
value: roleLines.join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
const behavior = SelfRoleBehaviorSchema.parse(panel.behavior);
|
||||
if (behavior === 'TEMPORARY') {
|
||||
const { temporaryDurationMs } = parseRolesJson(panel.roles);
|
||||
if (temporaryDurationMs) {
|
||||
embed.setFooter({
|
||||
text: t(locale, 'selfroles.panel.temporaryFooter')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function buildPanelComponents(panel: SelfRolePanel): ActionRowBuilder<
|
||||
ButtonBuilder | StringSelectMenuBuilder
|
||||
>[] {
|
||||
const mode = SelfRoleModeSchema.parse(panel.mode);
|
||||
const { entries } = parseRolesJson(panel.roles);
|
||||
|
||||
if (mode === 'REACTIONS' || entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (mode === 'DROPDOWN') {
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId(`${SELF_ROLE_SEL_PREFIX}${panel.id}`)
|
||||
.setPlaceholder('Select a role')
|
||||
.setMinValues(1)
|
||||
.setMaxValues(1)
|
||||
.addOptions(
|
||||
entries.map((entry) => ({
|
||||
label: entry.label.slice(0, 100),
|
||||
value: entry.roleId,
|
||||
description: entry.description?.slice(0, 100),
|
||||
emoji: parseButtonEmoji(entry.emoji)
|
||||
}))
|
||||
);
|
||||
|
||||
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
|
||||
}
|
||||
|
||||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
let currentRow = new ActionRowBuilder<ButtonBuilder>();
|
||||
let buttonsInRow = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (buttonsInRow >= 5) {
|
||||
rows.push(currentRow);
|
||||
currentRow = new ActionRowBuilder<ButtonBuilder>();
|
||||
buttonsInRow = 0;
|
||||
}
|
||||
|
||||
const button = new ButtonBuilder()
|
||||
.setCustomId(`${SELF_ROLE_BTN_PREFIX}${panel.id}:${entry.roleId}`)
|
||||
.setLabel(entry.label.slice(0, 80))
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const emoji = parseButtonEmoji(entry.emoji);
|
||||
if (emoji) {
|
||||
button.setEmoji(emoji);
|
||||
}
|
||||
|
||||
currentRow.addComponents(button);
|
||||
buttonsInRow += 1;
|
||||
|
||||
if (rows.length >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (buttonsInRow > 0 && rows.length < 5) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function applyReactionEmojis(message: Message, entries: SelfRoleEntry[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const emoji = parseReactionEmoji(entry.emoji);
|
||||
await message.react(emoji);
|
||||
} catch (error) {
|
||||
logger.warn({ error, panelId: message.id, roleId: entry.roleId }, 'Failed to add self-role reaction');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleSelfRoleExpiry(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
expiresAt: Date
|
||||
): Promise<void> {
|
||||
const delay = Math.max(0, expiresAt.getTime() - Date.now());
|
||||
await ticketQueue.add(
|
||||
'selfRoleExpire',
|
||||
{ guildId, userId, roleId },
|
||||
{
|
||||
delay,
|
||||
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelSelfRoleExpiry(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
|
||||
await job?.remove();
|
||||
}
|
||||
|
||||
export async function expireSelfRole(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
if (!member?.roles.cache.has(roleId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = guild.members.me;
|
||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||
logger.warn({ guildId, roleId }, 'Missing ManageRoles for self-role expiry');
|
||||
return;
|
||||
}
|
||||
|
||||
const role = guild.roles.cache.get(roleId);
|
||||
if (!role || role.position >= me.roles.highest.position) {
|
||||
logger.warn({ guildId, roleId }, 'Cannot remove self-role above bot hierarchy');
|
||||
return;
|
||||
}
|
||||
|
||||
await member.roles.remove(roleId, 'Temporary self-role expired').catch((error) => {
|
||||
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove expired self-role');
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAssignableRole(member: GuildMember, roleId: string): Promise<void> {
|
||||
const guild = member.guild;
|
||||
const me = guild.members.me;
|
||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||
throw new SelfRoleError('bot_missing_permissions');
|
||||
}
|
||||
|
||||
const role = guild.roles.cache.get(roleId);
|
||||
if (!role) {
|
||||
throw new SelfRoleError('role_not_found');
|
||||
}
|
||||
|
||||
if (role.position >= me.roles.highest.position) {
|
||||
throw new SelfRoleError('role_hierarchy');
|
||||
}
|
||||
|
||||
if (role.managed) {
|
||||
throw new SelfRoleError('role_managed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleSelfRole(
|
||||
context: BotContext,
|
||||
panel: SelfRolePanel,
|
||||
member: GuildMember,
|
||||
roleId: string
|
||||
): Promise<'added' | 'removed' | 'unchanged'> {
|
||||
const { entries, temporaryDurationMs } = parseRolesJson(panel.roles);
|
||||
const entry = entries.find((item) => item.roleId === roleId);
|
||||
if (!entry) {
|
||||
throw new SelfRoleError('role_not_in_panel');
|
||||
}
|
||||
|
||||
await assertAssignableRole(member, roleId);
|
||||
|
||||
const behavior = SelfRoleBehaviorSchema.parse(panel.behavior);
|
||||
const panelRoleIds = entries.map((item) => item.roleId);
|
||||
const hasRole = member.roles.cache.has(roleId);
|
||||
|
||||
if (behavior === 'VERIFY') {
|
||||
if (hasRole) {
|
||||
return 'unchanged';
|
||||
}
|
||||
await member.roles.add(roleId);
|
||||
return 'added';
|
||||
}
|
||||
|
||||
if (behavior === 'UNIQUE') {
|
||||
if (hasRole) {
|
||||
await member.roles.remove(roleId);
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
const toRemove = panelRoleIds.filter((id) => id !== roleId && member.roles.cache.has(id));
|
||||
if (toRemove.length > 0) {
|
||||
await member.roles.remove(toRemove);
|
||||
}
|
||||
|
||||
await member.roles.add(roleId);
|
||||
return 'added';
|
||||
}
|
||||
|
||||
if (behavior === 'TEMPORARY') {
|
||||
if (hasRole) {
|
||||
await member.roles.remove(roleId);
|
||||
await cancelSelfRoleExpiry(member.guild.id, member.id, roleId);
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
await member.roles.add(roleId);
|
||||
if (temporaryDurationMs) {
|
||||
const expiresAt = new Date(Date.now() + temporaryDurationMs);
|
||||
await scheduleSelfRoleExpiry(member.guild.id, member.id, roleId, expiresAt);
|
||||
}
|
||||
return 'added';
|
||||
}
|
||||
|
||||
if (hasRole) {
|
||||
await member.roles.remove(roleId);
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
await member.roles.add(roleId);
|
||||
return 'added';
|
||||
}
|
||||
|
||||
export async function findPanelByMessageId(
|
||||
context: BotContext,
|
||||
messageId: string
|
||||
): Promise<SelfRolePanel | null> {
|
||||
return context.prisma.selfRolePanel.findFirst({
|
||||
where: { messageId }
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSelfRolePanel(
|
||||
context: BotContext,
|
||||
params: {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
mode: SelfRoleMode;
|
||||
behavior: SelfRoleBehavior;
|
||||
roles: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
},
|
||||
locale: 'de' | 'en'
|
||||
): Promise<SelfRolePanel> {
|
||||
await ensureGuild(context.prisma, params.guildId);
|
||||
|
||||
if (params.behavior === 'TEMPORARY' && !params.temporaryDurationMs) {
|
||||
throw new SelfRoleError('duration_required');
|
||||
}
|
||||
|
||||
if (params.mode === 'REACTIONS') {
|
||||
const missingEmoji = params.roles.some((entry) => !entry.emoji);
|
||||
if (missingEmoji) {
|
||||
throw new SelfRoleError('reaction_emoji_required');
|
||||
}
|
||||
}
|
||||
|
||||
const panel = await context.prisma.selfRolePanel.create({
|
||||
data: {
|
||||
guildId: params.guildId,
|
||||
channelId: params.channelId,
|
||||
title: params.title,
|
||||
description: params.description ?? null,
|
||||
mode: params.mode,
|
||||
behavior: params.behavior,
|
||||
roles: serializeRolesJson(params.roles, params.temporaryDurationMs)
|
||||
}
|
||||
});
|
||||
|
||||
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
|
||||
const message = await channel.send({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
|
||||
if (params.mode === 'REACTIONS') {
|
||||
await applyReactionEmojis(message, params.roles);
|
||||
}
|
||||
|
||||
return context.prisma.selfRolePanel.update({
|
||||
where: { id: panel.id },
|
||||
data: { messageId: message.id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSelfRolePanel(
|
||||
context: BotContext,
|
||||
panelId: string,
|
||||
guildId: string,
|
||||
updates: {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
mode?: SelfRoleMode;
|
||||
behavior?: SelfRoleBehavior;
|
||||
roles?: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
},
|
||||
locale: 'de' | 'en'
|
||||
): Promise<SelfRolePanel> {
|
||||
const existing = await context.prisma.selfRolePanel.findFirst({
|
||||
where: { id: panelId, guildId }
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new SelfRoleError('not_found');
|
||||
}
|
||||
|
||||
const mode = updates.mode ?? SelfRoleModeSchema.parse(existing.mode);
|
||||
const behavior = updates.behavior ?? SelfRoleBehaviorSchema.parse(existing.behavior);
|
||||
const roles = updates.roles ?? parseRolesJson(existing.roles).entries;
|
||||
const { temporaryDurationMs: existingDuration } = parseRolesJson(existing.roles);
|
||||
const temporaryDurationMs = updates.temporaryDurationMs ?? existingDuration;
|
||||
|
||||
if (behavior === 'TEMPORARY' && !temporaryDurationMs) {
|
||||
throw new SelfRoleError('duration_required');
|
||||
}
|
||||
|
||||
if (mode === 'REACTIONS' && roles.some((entry) => !entry.emoji)) {
|
||||
throw new SelfRoleError('reaction_emoji_required');
|
||||
}
|
||||
|
||||
const panel = await context.prisma.selfRolePanel.update({
|
||||
where: { id: panelId },
|
||||
data: {
|
||||
title: updates.title ?? existing.title,
|
||||
description: updates.description !== undefined ? updates.description : existing.description,
|
||||
mode,
|
||||
behavior,
|
||||
roles: serializeRolesJson(roles, temporaryDurationMs)
|
||||
}
|
||||
});
|
||||
|
||||
if (panel.messageId) {
|
||||
try {
|
||||
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(panel.messageId);
|
||||
await message.edit({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
|
||||
if (mode === 'REACTIONS') {
|
||||
const existingReactions = message.reactions.cache;
|
||||
for (const reaction of existingReactions.values()) {
|
||||
await reaction.remove().catch(() => undefined);
|
||||
}
|
||||
await applyReactionEmojis(message, roles);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
|
||||
}
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
export async function deleteSelfRolePanel(
|
||||
context: BotContext,
|
||||
panelId: string,
|
||||
guildId: string
|
||||
): Promise<void> {
|
||||
const panel = await context.prisma.selfRolePanel.findFirst({
|
||||
where: { id: panelId, guildId }
|
||||
});
|
||||
|
||||
if (!panel) {
|
||||
throw new SelfRoleError('not_found');
|
||||
}
|
||||
|
||||
if (panel.messageId) {
|
||||
try {
|
||||
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(panel.messageId);
|
||||
await message.delete();
|
||||
} catch {
|
||||
// Message may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
await context.prisma.selfRolePanel.delete({ where: { id: panelId } });
|
||||
}
|
||||
|
||||
export function matchReactionEntry(
|
||||
entries: SelfRoleEntry[],
|
||||
emojiId: string | null,
|
||||
emojiName: string
|
||||
): SelfRoleEntry | null {
|
||||
for (const entry of entries) {
|
||||
if (!entry.emoji) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const customMatch = entry.emoji.match(/^<(a)?:(\w+):(\d+)>$/);
|
||||
if (customMatch) {
|
||||
if (emojiId === customMatch[3]) {
|
||||
return entry;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(entry.emoji)) {
|
||||
if (emojiId === entry.emoji) {
|
||||
return entry;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!emojiId && entry.emoji === emojiName) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
33
apps/bot/src/modules/starboard/command-definitions.ts
Normal file
33
apps/bot/src/modules/starboard/command-definitions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ChannelType } from 'discord.js';
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const starboardCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('starboard')
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
||||
'starboard.setup.options.channel'
|
||||
)
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(o.setName('threshold'), 'starboard.setup.options.threshold')
|
||||
.setMinValue(1)
|
||||
.setMaxValue(100)
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('emoji'), 'starboard.setup.options.emoji')
|
||||
)
|
||||
.addBooleanOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('allow_self_star'),
|
||||
'starboard.setup.options.allow_self_star'
|
||||
)
|
||||
)
|
||||
),
|
||||
'starboard.description'
|
||||
);
|
||||
49
apps/bot/src/modules/starboard/commands.ts
Normal file
49
apps/bot/src/modules/starboard/commands.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { starboardCommandData } from './command-definitions.js';
|
||||
import { getStarboardConfig, updateStarboardConfig } from './service.js';
|
||||
|
||||
const starboardCommand: SlashCommand = {
|
||||
data: starboardCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = interaction.guildId!;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'setup') {
|
||||
const channel = interaction.options.getChannel('channel', true);
|
||||
const threshold = interaction.options.getInteger('threshold', true);
|
||||
const emoji = interaction.options.getString('emoji') ?? '⭐';
|
||||
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false;
|
||||
|
||||
await updateStarboardConfig(context.prisma, guildId, {
|
||||
enabled: true,
|
||||
channelId: channel.id,
|
||||
emoji,
|
||||
threshold,
|
||||
allowSelfStar
|
||||
});
|
||||
|
||||
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getStarboardConfig(context.prisma, guildId);
|
||||
if (!config.enabled || !config.channelId) {
|
||||
await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const starboardCommands: SlashCommand[] = [starboardCommand];
|
||||
29
apps/bot/src/modules/starboard/events.ts
Normal file
29
apps/bot/src/modules/starboard/events.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { processStarboardReaction } from './service.js';
|
||||
|
||||
export function registerStarboardEvents(context: BotContext): void {
|
||||
const handleReaction = async (
|
||||
reaction: Parameters<typeof processStarboardReaction>[1]
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await processStarboardReaction(context, reaction);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Starboard reaction handler failed');
|
||||
}
|
||||
};
|
||||
|
||||
context.client.on('messageReactionAdd', async (_reaction, user) => {
|
||||
if (user.bot) {
|
||||
return;
|
||||
}
|
||||
await handleReaction(_reaction);
|
||||
});
|
||||
|
||||
context.client.on('messageReactionRemove', async (_reaction, user) => {
|
||||
if (user.bot) {
|
||||
return;
|
||||
}
|
||||
await handleReaction(_reaction);
|
||||
});
|
||||
}
|
||||
2
apps/bot/src/modules/starboard/index.ts
Normal file
2
apps/bot/src/modules/starboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { starboardCommands } from './commands.js';
|
||||
export { registerStarboardEvents } from './events.js';
|
||||
251
apps/bot/src/modules/starboard/service.ts
Normal file
251
apps/bot/src/modules/starboard/service.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
EmbedBuilder,
|
||||
type Emoji,
|
||||
type Message,
|
||||
type PartialMessageReaction,
|
||||
type MessageReaction,
|
||||
type TextChannel
|
||||
} from 'discord.js';
|
||||
import type { PrismaClient, StarboardConfig } from '@prisma/client';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { Locale } from '@nexumi/shared';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
|
||||
export async function getStarboardConfig(prisma: PrismaClient, guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
let config = await prisma.starboardConfig.findUnique({ where: { guildId } });
|
||||
if (!config) {
|
||||
config = await prisma.starboardConfig.create({ data: { guildId } });
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function updateStarboardConfig(
|
||||
prisma: PrismaClient,
|
||||
guildId: string,
|
||||
data: {
|
||||
enabled: boolean;
|
||||
channelId: string;
|
||||
emoji: string;
|
||||
threshold: number;
|
||||
allowSelfStar: boolean;
|
||||
}
|
||||
) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
return prisma.starboardConfig.upsert({
|
||||
where: { guildId },
|
||||
update: data,
|
||||
create: { guildId, ...data }
|
||||
});
|
||||
}
|
||||
|
||||
export function emojiMatches(configEmoji: string, reactionEmoji: Emoji): boolean {
|
||||
if (reactionEmoji.id) {
|
||||
return (
|
||||
configEmoji === reactionEmoji.identifier ||
|
||||
configEmoji === reactionEmoji.toString() ||
|
||||
configEmoji === `<:${reactionEmoji.name}:${reactionEmoji.id}>` ||
|
||||
configEmoji === `<a:${reactionEmoji.name}:${reactionEmoji.id}>`
|
||||
);
|
||||
}
|
||||
return configEmoji === reactionEmoji.name || configEmoji === reactionEmoji.toString();
|
||||
}
|
||||
|
||||
export function shouldIgnoreMessage(message: Message, config: StarboardConfig): boolean {
|
||||
if (message.author.bot) {
|
||||
return true;
|
||||
}
|
||||
if (!message.guild || !message.channel.isTextBased() || message.channel.isDMBased()) {
|
||||
return true;
|
||||
}
|
||||
if ('nsfw' in message.channel && message.channel.nsfw) {
|
||||
return true;
|
||||
}
|
||||
if (config.ignoredChannelIds.includes(message.channel.id)) {
|
||||
return true;
|
||||
}
|
||||
if (config.channelId && message.channel.id === config.channelId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function countMatchingStars(
|
||||
message: Message,
|
||||
config: StarboardConfig
|
||||
): Promise<number> {
|
||||
const reaction = message.reactions.cache.find((r) => emojiMatches(config.emoji, r.emoji));
|
||||
if (!reaction || reaction.count === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const fetchedReaction = reaction.partial ? await reaction.fetch() : reaction;
|
||||
const users = await fetchedReaction.users.fetch();
|
||||
let count = 0;
|
||||
for (const [, user] of users) {
|
||||
if (user.bot) {
|
||||
continue;
|
||||
}
|
||||
if (!config.allowSelfStar && user.id === message.author.id) {
|
||||
continue;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function resolveImageUrl(message: Message): string | undefined {
|
||||
const attachment = message.attachments.find((a) => {
|
||||
const type = a.contentType ?? '';
|
||||
return type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/i.test(a.name);
|
||||
});
|
||||
if (attachment) {
|
||||
return attachment.url;
|
||||
}
|
||||
const embedImage = message.embeds.find((e) => e.image?.url)?.image?.url;
|
||||
return embedImage ?? undefined;
|
||||
}
|
||||
|
||||
export function buildStarboardEmbed(
|
||||
message: Message,
|
||||
starCount: number,
|
||||
config: StarboardConfig,
|
||||
locale: Locale
|
||||
): EmbedBuilder {
|
||||
const content = message.content?.trim() || t(locale, 'starboard.noContent');
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x6366f1)
|
||||
.setAuthor({
|
||||
name: message.author.tag,
|
||||
iconURL: message.author.displayAvatarURL()
|
||||
})
|
||||
.setDescription(content.slice(0, 4096))
|
||||
.addFields({
|
||||
name: t(locale, 'starboard.field.stars'),
|
||||
value: `${config.emoji} **${starCount}**`,
|
||||
inline: true
|
||||
})
|
||||
.addFields({
|
||||
name: t(locale, 'starboard.field.source'),
|
||||
value: `[${t(locale, 'starboard.jumpLink')}](${message.url})`,
|
||||
inline: true
|
||||
})
|
||||
.setTimestamp(message.createdAt);
|
||||
|
||||
const imageUrl = resolveImageUrl(message);
|
||||
if (imageUrl) {
|
||||
embed.setImage(imageUrl);
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
|
||||
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getStarboardConfig(context.prisma, entry.guildId);
|
||||
if (config.channelId) {
|
||||
const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
|
||||
const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null);
|
||||
await starboardMessage?.delete().catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ error, entryId }, 'Failed to delete starboard message');
|
||||
}
|
||||
|
||||
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
|
||||
if (!message.guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getStarboardConfig(context.prisma, message.guild.id);
|
||||
if (!config.enabled || !config.channelId) {
|
||||
return;
|
||||
}
|
||||
if (shouldIgnoreMessage(message, config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const starCount = await countMatchingStars(message, config);
|
||||
const locale = await getGuildLocale(context.prisma, message.guild.id);
|
||||
const existing = await context.prisma.starboardEntry.findUnique({
|
||||
where: {
|
||||
guildId_sourceMessageId: {
|
||||
guildId: message.guild.id,
|
||||
sourceMessageId: message.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (starCount < config.threshold) {
|
||||
if (existing) {
|
||||
await removeStarboardEntry(context, existing.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = buildStarboardEmbed(message, starCount, config, locale);
|
||||
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
const starboardMessage = await starboardChannel.messages.fetch(existing.starboardMessageId);
|
||||
await starboardMessage.edit({ embeds: [embed] });
|
||||
await context.prisma.starboardEntry.update({
|
||||
where: { id: existing.id },
|
||||
data: { starCount }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, messageId: message.id }, 'Failed to update starboard message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
|
||||
await context.prisma.starboardEntry.create({
|
||||
data: {
|
||||
guildId: message.guild.id,
|
||||
sourceChannelId: message.channel.id,
|
||||
sourceMessageId: message.id,
|
||||
starboardMessageId: starboardMessage.id,
|
||||
starCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function processStarboardReaction(
|
||||
context: BotContext,
|
||||
reaction: MessageReaction | PartialMessageReaction
|
||||
): Promise<void> {
|
||||
if (reaction.partial) {
|
||||
await reaction.fetch();
|
||||
}
|
||||
|
||||
const message = reaction.message;
|
||||
if (message.partial) {
|
||||
await message.fetch();
|
||||
}
|
||||
if (!message.guild || !message.author) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getStarboardConfig(context.prisma, message.guild.id);
|
||||
if (!config.enabled || !config.channelId) {
|
||||
return;
|
||||
}
|
||||
if (!emojiMatches(config.emoji, reaction.emoji)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processStarboardMessage(context, message as Message);
|
||||
}
|
||||
60
apps/bot/src/modules/suggestions/buttons.ts
Normal file
60
apps/bot/src/modules/suggestions/buttons.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { ButtonInteraction } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
applySuggestionVote,
|
||||
SUGGESTION_VOTE_PREFIX,
|
||||
SuggestionError
|
||||
} from './service.js';
|
||||
|
||||
export { isSuggestionVoteButton as isSuggestionButton } from './service.js';
|
||||
|
||||
function parseSuggestionVoteButton(
|
||||
customId: string
|
||||
): { suggestionId: string; direction: 'up' | 'down' } | null {
|
||||
if (!customId.startsWith(SUGGESTION_VOTE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const rest = customId.slice(SUGGESTION_VOTE_PREFIX.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
if (lastColon === -1) {
|
||||
return null;
|
||||
}
|
||||
const suggestionId = rest.slice(0, lastColon);
|
||||
const direction = rest.slice(lastColon + 1);
|
||||
if (!suggestionId || (direction !== 'up' && direction !== 'down')) {
|
||||
return null;
|
||||
}
|
||||
return { suggestionId, direction };
|
||||
}
|
||||
|
||||
export async function handleSuggestionButton(
|
||||
interaction: ButtonInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
const parsed = parseSuggestionVoteButton(interaction.customId);
|
||||
|
||||
if (!parsed || !interaction.guildId) {
|
||||
await interaction.reply({ content: t(locale, 'suggestion.error.not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await applySuggestionVote(context, parsed.suggestionId, interaction.user.id, parsed.direction, locale);
|
||||
await interaction.reply({
|
||||
content: t(locale, parsed.direction === 'up' ? 'suggestion.votedUp' : 'suggestion.votedDown'),
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `suggestion.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
90
apps/bot/src/modules/suggestions/command-definitions.ts
Normal file
90
apps/bot/src/modules/suggestions/command-definitions.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ChannelType } from 'discord.js';
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const suggestCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('suggest')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('content').setRequired(true), 'suggest.options.content')
|
||||
),
|
||||
'suggest.description'
|
||||
);
|
||||
|
||||
export const suggestionCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('suggestion')
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('setup'), 'suggestion.setup.description')
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('open_channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
||||
'suggestion.setup.options.open_channel'
|
||||
)
|
||||
)
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('approved_channel').addChannelTypes(ChannelType.GuildText),
|
||||
'suggestion.setup.options.approved_channel'
|
||||
)
|
||||
)
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('denied_channel').addChannelTypes(ChannelType.GuildText),
|
||||
'suggestion.setup.options.denied_channel'
|
||||
)
|
||||
)
|
||||
.addBooleanOption((o) =>
|
||||
applyOptionDescription(o.setName('create_thread'), 'suggestion.setup.options.create_thread')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('approve'), 'suggestion.approve.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('suggestion_id').setRequired(true),
|
||||
'suggestion.options.suggestion_id'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('deny'), 'suggestion.deny.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('suggestion_id').setRequired(true),
|
||||
'suggestion.options.suggestion_id'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('consider'), 'suggestion.consider.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('suggestion_id').setRequired(true),
|
||||
'suggestion.options.suggestion_id'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('implement'), 'suggestion.implement.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('suggestion_id').setRequired(true),
|
||||
'suggestion.options.suggestion_id'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
|
||||
)
|
||||
),
|
||||
'suggestion.description'
|
||||
);
|
||||
121
apps/bot/src/modules/suggestions/commands.ts
Normal file
121
apps/bot/src/modules/suggestions/commands.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { suggestCommandData, suggestionCommandData } from './command-definitions.js';
|
||||
import {
|
||||
createSuggestion,
|
||||
SuggestionError,
|
||||
updateSuggestionConfig,
|
||||
updateSuggestionStatus
|
||||
} from './service.js';
|
||||
|
||||
const suggestCommand: SlashCommand = {
|
||||
data: suggestCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = interaction.options.getString('content', true);
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
const suggestion = await createSuggestion(
|
||||
context,
|
||||
{
|
||||
guildId: interaction.guildId!,
|
||||
authorId: interaction.user.id,
|
||||
content
|
||||
},
|
||||
locale
|
||||
);
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'suggestion.created', { id: suggestion.id })
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionError) {
|
||||
await interaction.editReply({
|
||||
content: t(locale, `suggestion.error.${error.code}`)
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const suggestionCommand: SlashCommand = {
|
||||
data: suggestionCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = interaction.guildId!;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'setup') {
|
||||
const openChannel = interaction.options.getChannel('open_channel', true);
|
||||
const approvedChannel = interaction.options.getChannel('approved_channel');
|
||||
const deniedChannel = interaction.options.getChannel('denied_channel');
|
||||
const createThread = interaction.options.getBoolean('create_thread') ?? true;
|
||||
|
||||
await updateSuggestionConfig(context.prisma, guildId, {
|
||||
enabled: true,
|
||||
openChannelId: openChannel.id,
|
||||
approvedChannelId: approvedChannel?.id ?? null,
|
||||
deniedChannelId: deniedChannel?.id ?? null,
|
||||
createThread
|
||||
});
|
||||
|
||||
await interaction.reply({ content: t(locale, 'suggestion.setupDone'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestionId = interaction.options.getString('suggestion_id', true);
|
||||
const reason = interaction.options.getString('reason', true);
|
||||
const statusMap = {
|
||||
approve: 'APPROVED',
|
||||
deny: 'DENIED',
|
||||
consider: 'CONSIDERED',
|
||||
implement: 'IMPLEMENTED'
|
||||
} as const;
|
||||
|
||||
try {
|
||||
await updateSuggestionStatus(
|
||||
context,
|
||||
{
|
||||
suggestionId,
|
||||
guildId,
|
||||
status: statusMap[sub as keyof typeof statusMap],
|
||||
reason
|
||||
},
|
||||
locale
|
||||
);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'suggestion.staffUpdated', { status: sub }),
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `suggestion.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const suggestionsCommands: SlashCommand[] = [suggestCommand, suggestionCommand];
|
||||
2
apps/bot/src/modules/suggestions/index.ts
Normal file
2
apps/bot/src/modules/suggestions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { suggestionsCommands } from './commands.js';
|
||||
export { isSuggestionButton, handleSuggestionButton } from './buttons.js';
|
||||
341
apps/bot/src/modules/suggestions/service.ts
Normal file
341
apps/bot/src/modules/suggestions/service.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
type TextChannel
|
||||
} from 'discord.js';
|
||||
import type { PrismaClient, Suggestion } from '@prisma/client';
|
||||
import { SuggestionStatusSchema, t, tf, type SuggestionStatus } from '@nexumi/shared';
|
||||
import type { Locale } from '@nexumi/shared';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
|
||||
export const SUGGESTION_VOTE_PREFIX = 'sug:vote:';
|
||||
|
||||
export function upvoteButtonId(suggestionId: string): string {
|
||||
return `${SUGGESTION_VOTE_PREFIX}${suggestionId}:up`;
|
||||
}
|
||||
|
||||
export function downvoteButtonId(suggestionId: string): string {
|
||||
return `${SUGGESTION_VOTE_PREFIX}${suggestionId}:down`;
|
||||
}
|
||||
|
||||
export function isSuggestionVoteButton(customId: string): boolean {
|
||||
return customId.startsWith(SUGGESTION_VOTE_PREFIX);
|
||||
}
|
||||
|
||||
export class SuggestionError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<SuggestionStatus, number> = {
|
||||
OPEN: 0x6366f1,
|
||||
APPROVED: 0x22c55e,
|
||||
DENIED: 0xef4444,
|
||||
CONSIDERED: 0xeab308,
|
||||
IMPLEMENTED: 0x3b82f6
|
||||
};
|
||||
|
||||
export async function getSuggestionConfig(prisma: PrismaClient, guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
let config = await prisma.suggestionConfig.findUnique({ where: { guildId } });
|
||||
if (!config) {
|
||||
config = await prisma.suggestionConfig.create({ data: { guildId } });
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function updateSuggestionConfig(
|
||||
prisma: PrismaClient,
|
||||
guildId: string,
|
||||
data: {
|
||||
enabled: boolean;
|
||||
openChannelId: string;
|
||||
approvedChannelId?: string | null;
|
||||
deniedChannelId?: string | null;
|
||||
createThread: boolean;
|
||||
}
|
||||
) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
return prisma.suggestionConfig.upsert({
|
||||
where: { guildId },
|
||||
update: data,
|
||||
create: { guildId, ...data }
|
||||
});
|
||||
}
|
||||
|
||||
function statusLabel(locale: Locale, status: SuggestionStatus): string {
|
||||
return t(locale, `suggestion.status.${status.toLowerCase()}`);
|
||||
}
|
||||
|
||||
export function buildSuggestionEmbed(
|
||||
locale: Locale,
|
||||
suggestion: Suggestion,
|
||||
authorTag?: string
|
||||
): EmbedBuilder {
|
||||
const status = SuggestionStatusSchema.parse(suggestion.status);
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(STATUS_COLORS[status])
|
||||
.setTitle(tf(locale, 'suggestion.embed.title', { id: suggestion.id.slice(-8) }))
|
||||
.setDescription(suggestion.content.slice(0, 4096))
|
||||
.addFields(
|
||||
{
|
||||
name: t(locale, 'suggestion.embed.status'),
|
||||
value: statusLabel(locale, status),
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: t(locale, 'suggestion.embed.votes'),
|
||||
value: `👍 ${suggestion.upvotes} · 👎 ${suggestion.downvotes}`,
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
.setFooter({
|
||||
text: tf(locale, 'suggestion.embed.author', {
|
||||
author: authorTag ?? `<@${suggestion.authorId}>`
|
||||
})
|
||||
})
|
||||
.setTimestamp(suggestion.createdAt);
|
||||
|
||||
if (suggestion.staffReason) {
|
||||
embed.addFields({
|
||||
name: t(locale, 'suggestion.embed.staffReason'),
|
||||
value: suggestion.staffReason.slice(0, 1024),
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function buildVoteButtons(suggestion: Suggestion, disabled = false): ActionRowBuilder<ButtonBuilder> {
|
||||
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(upvoteButtonId(suggestion.id))
|
||||
.setLabel(`👍 ${suggestion.upvotes}`)
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(disabled),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(downvoteButtonId(suggestion.id))
|
||||
.setLabel(`👎 ${suggestion.downvotes}`)
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
.setDisabled(disabled)
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSuggestion(
|
||||
context: BotContext,
|
||||
params: {
|
||||
guildId: string;
|
||||
authorId: string;
|
||||
content: string;
|
||||
},
|
||||
locale: Locale
|
||||
): Promise<Suggestion> {
|
||||
const config = await getSuggestionConfig(context.prisma, params.guildId);
|
||||
if (!config.enabled || !config.openChannelId) {
|
||||
throw new SuggestionError('not_configured');
|
||||
}
|
||||
|
||||
const channel = (await context.client.channels.fetch(config.openChannelId)) as TextChannel;
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
throw new SuggestionError('invalid_channel');
|
||||
}
|
||||
|
||||
const suggestion = await context.prisma.suggestion.create({
|
||||
data: {
|
||||
guildId: params.guildId,
|
||||
authorId: params.authorId,
|
||||
content: params.content,
|
||||
status: 'OPEN',
|
||||
channelId: config.openChannelId
|
||||
}
|
||||
});
|
||||
|
||||
const author = await context.client.users.fetch(params.authorId).catch(() => null);
|
||||
const embed = buildSuggestionEmbed(locale, suggestion, author?.tag);
|
||||
const components = [buildVoteButtons(suggestion)];
|
||||
|
||||
const message = await channel.send({ embeds: [embed], components });
|
||||
let threadId: string | null = null;
|
||||
|
||||
if (config.createThread) {
|
||||
const thread = await message
|
||||
.startThread({
|
||||
name: tf(locale, 'suggestion.threadName', { id: suggestion.id.slice(-8) }),
|
||||
autoArchiveDuration: 1440
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn({ error, suggestionId: suggestion.id }, 'Failed to create suggestion thread');
|
||||
return null;
|
||||
});
|
||||
threadId = thread?.id ?? null;
|
||||
}
|
||||
|
||||
return context.prisma.suggestion.update({
|
||||
where: { id: suggestion.id },
|
||||
data: {
|
||||
messageId: message.id,
|
||||
threadId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function applySuggestionVote(
|
||||
context: BotContext,
|
||||
suggestionId: string,
|
||||
userId: string,
|
||||
direction: 'up' | 'down',
|
||||
locale: Locale
|
||||
): Promise<Suggestion> {
|
||||
const suggestion = await context.prisma.suggestion.findUnique({ where: { id: suggestionId } });
|
||||
if (!suggestion || suggestion.status !== 'OPEN') {
|
||||
throw new SuggestionError('not_open');
|
||||
}
|
||||
if (!suggestion.messageId || !suggestion.channelId) {
|
||||
throw new SuggestionError('not_found');
|
||||
}
|
||||
|
||||
const upIds = suggestion.voterUpIds.filter((id) => id !== userId);
|
||||
const downIds = suggestion.voterDownIds.filter((id) => id !== userId);
|
||||
|
||||
if (direction === 'up') {
|
||||
if (suggestion.voterUpIds.includes(userId)) {
|
||||
throw new SuggestionError('already_voted');
|
||||
}
|
||||
upIds.push(userId);
|
||||
} else {
|
||||
if (suggestion.voterDownIds.includes(userId)) {
|
||||
throw new SuggestionError('already_voted');
|
||||
}
|
||||
downIds.push(userId);
|
||||
}
|
||||
|
||||
const upvotes = upIds.length;
|
||||
const downvotes = downIds.length;
|
||||
|
||||
const updated = await context.prisma.suggestion.update({
|
||||
where: { id: suggestionId },
|
||||
data: {
|
||||
voterUpIds: upIds,
|
||||
voterDownIds: downIds,
|
||||
upvotes,
|
||||
downvotes
|
||||
}
|
||||
});
|
||||
|
||||
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(suggestion.messageId).catch(() => null);
|
||||
if (message) {
|
||||
const author = await context.client.users.fetch(updated.authorId).catch(() => null);
|
||||
await message.edit({
|
||||
embeds: [buildSuggestionEmbed(locale, updated, author?.tag)],
|
||||
components: [buildVoteButtons(updated)]
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function notifyAuthor(
|
||||
context: BotContext,
|
||||
suggestion: Suggestion,
|
||||
locale: Locale,
|
||||
status: SuggestionStatus,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const user = await context.client.users.fetch(suggestion.authorId).catch(() => null);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
await user
|
||||
.send({
|
||||
content: tf(locale, 'suggestion.dm.statusUpdate', {
|
||||
status: statusLabel(locale, status),
|
||||
reason
|
||||
})
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function updateSuggestionStatus(
|
||||
context: BotContext,
|
||||
params: {
|
||||
suggestionId: string;
|
||||
guildId: string;
|
||||
status: SuggestionStatus;
|
||||
reason: string;
|
||||
},
|
||||
locale: Locale
|
||||
): Promise<Suggestion> {
|
||||
const suggestion = await context.prisma.suggestion.findFirst({
|
||||
where: { id: params.suggestionId, guildId: params.guildId }
|
||||
});
|
||||
if (!suggestion) {
|
||||
throw new SuggestionError('not_found');
|
||||
}
|
||||
|
||||
const config = await getSuggestionConfig(context.prisma, params.guildId);
|
||||
const author = await context.client.users.fetch(suggestion.authorId).catch(() => null);
|
||||
const updatedData = {
|
||||
status: params.status,
|
||||
staffReason: params.reason
|
||||
};
|
||||
|
||||
let targetChannelId = suggestion.channelId;
|
||||
if (params.status === 'APPROVED' || params.status === 'IMPLEMENTED') {
|
||||
targetChannelId = config.approvedChannelId ?? suggestion.channelId;
|
||||
} else if (params.status === 'DENIED') {
|
||||
targetChannelId = config.deniedChannelId ?? suggestion.channelId;
|
||||
}
|
||||
|
||||
const embed = buildSuggestionEmbed(
|
||||
locale,
|
||||
{ ...suggestion, ...updatedData },
|
||||
author?.tag
|
||||
);
|
||||
const components = [buildVoteButtons({ ...suggestion, ...updatedData }, true)];
|
||||
|
||||
let messageId = suggestion.messageId;
|
||||
let channelId = suggestion.channelId;
|
||||
|
||||
if (targetChannelId && targetChannelId !== suggestion.channelId) {
|
||||
const targetChannel = (await context.client.channels.fetch(targetChannelId)) as TextChannel;
|
||||
if (targetChannel?.isTextBased() && !targetChannel.isDMBased()) {
|
||||
const newMessage = await targetChannel.send({ embeds: [embed], components });
|
||||
messageId = newMessage.id;
|
||||
channelId = targetChannelId;
|
||||
|
||||
if (suggestion.messageId && suggestion.channelId) {
|
||||
const oldChannel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
|
||||
const oldMessage = await oldChannel?.messages.fetch(suggestion.messageId).catch(() => null);
|
||||
await oldMessage?.delete().catch(() => undefined);
|
||||
}
|
||||
} else if (suggestion.messageId && suggestion.channelId) {
|
||||
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
|
||||
const message = await channel?.messages.fetch(suggestion.messageId).catch(() => null);
|
||||
await message?.edit({ embeds: [embed], components });
|
||||
}
|
||||
} else if (suggestion.messageId && suggestion.channelId) {
|
||||
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
|
||||
const message = await channel?.messages.fetch(suggestion.messageId).catch(() => null);
|
||||
await message?.edit({ embeds: [embed], components });
|
||||
}
|
||||
|
||||
const updated = await context.prisma.suggestion.update({
|
||||
where: { id: suggestion.id },
|
||||
data: {
|
||||
...updatedData,
|
||||
messageId,
|
||||
channelId
|
||||
}
|
||||
});
|
||||
|
||||
await notifyAuthor(context, updated, locale, params.status, params.reason);
|
||||
return updated;
|
||||
}
|
||||
107
apps/bot/src/modules/tags/command-definitions.ts
Normal file
107
apps/bot/src/modules/tags/command-definitions.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const tagCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('tag')
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('create'), 'tag.create.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('response_type').setRequired(true), 'tag.create.options.response_type')
|
||||
.addChoices(
|
||||
localizedChoice('tag.choice.response_type.text', 'TEXT'),
|
||||
localizedChoice('tag.choice.response_type.embed', 'EMBED')
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('content'), 'tag.create.options.content')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('embed_title'), 'tag.create.options.embed_title')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('embed_description'), 'tag.create.options.embed_description')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
|
||||
'tag.create.options.embed_color'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('trigger_word'), 'tag.create.options.trigger_word')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('allowed_roles'), 'tag.create.options.allowed_roles')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('allowed_channels'), 'tag.create.options.allowed_channels')
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('edit'), 'tag.edit.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('new_name'), 'tag.edit.options.new_name')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('response_type'), 'tag.create.options.response_type')
|
||||
.addChoices(
|
||||
localizedChoice('tag.choice.response_type.text', 'TEXT'),
|
||||
localizedChoice('tag.choice.response_type.embed', 'EMBED')
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('content'), 'tag.create.options.content')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('embed_title'), 'tag.create.options.embed_title')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('embed_description'), 'tag.create.options.embed_description')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
|
||||
'tag.create.options.embed_color'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('trigger_word'), 'tag.create.options.trigger_word')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('allowed_roles'), 'tag.create.options.allowed_roles')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('allowed_channels'), 'tag.create.options.allowed_channels')
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('delete'), 'tag.delete.description').addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('list'), 'tag.list.description')
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('info'), 'tag.info.description').addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('run'), 'tag.run.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('args'), 'tag.run.options.args')
|
||||
)
|
||||
),
|
||||
'tag.description'
|
||||
);
|
||||
199
apps/bot/src/modules/tags/commands.ts
Normal file
199
apps/bot/src/modules/tags/commands.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { tagCommandData } from './command-definitions.js';
|
||||
import {
|
||||
createTag,
|
||||
deleteTag,
|
||||
executeTag,
|
||||
getTagByName,
|
||||
listTags,
|
||||
parseIdList,
|
||||
TagError,
|
||||
updateTag
|
||||
} from './service.js';
|
||||
|
||||
async function ensureManageGuild(
|
||||
interaction: Parameters<SlashCommand['execute']>[0],
|
||||
locale: 'de' | 'en'
|
||||
): Promise<boolean> {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||
}
|
||||
|
||||
function buildEmbedPayload(interaction: Parameters<SlashCommand['execute']>[0]) {
|
||||
const title = interaction.options.getString('embed_title');
|
||||
const description = interaction.options.getString('embed_description');
|
||||
const color = interaction.options.getInteger('embed_color');
|
||||
|
||||
if (!title && !description && color === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
title: title ?? undefined,
|
||||
description: description ?? undefined,
|
||||
color: color ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
const tagCommand: SlashCommand = {
|
||||
data: tagCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = interaction.options.getSubcommand(true);
|
||||
|
||||
try {
|
||||
if (sub === 'run') {
|
||||
const name = interaction.options.getString('name', true);
|
||||
const args = interaction.options.getString('args') ?? '';
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
|
||||
const payload = await executeTag(
|
||||
context,
|
||||
interaction.guildId!,
|
||||
name,
|
||||
member,
|
||||
interaction.guild,
|
||||
interaction.channelId!,
|
||||
args
|
||||
);
|
||||
await interaction.reply(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'create') {
|
||||
const responseType = TagResponseTypeSchema.parse(
|
||||
interaction.options.getString('response_type', true)
|
||||
);
|
||||
const tag = await createTag(context, {
|
||||
guildId: interaction.guildId!,
|
||||
name: interaction.options.getString('name', true),
|
||||
responseType,
|
||||
content: interaction.options.getString('content'),
|
||||
embed: buildEmbedPayload(interaction) ?? null,
|
||||
triggerWord: interaction.options.getString('trigger_word'),
|
||||
allowedRoleIds: parseIdList(interaction.options.getString('allowed_roles')),
|
||||
allowedChannelIds: parseIdList(interaction.options.getString('allowed_channels')),
|
||||
createdById: interaction.user.id
|
||||
});
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tag.created', { name: tag.name }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'edit') {
|
||||
const tag = await updateTag(context, interaction.guildId!, interaction.options.getString('name', true), {
|
||||
newName: interaction.options.getString('new_name') ?? undefined,
|
||||
responseType: interaction.options.getString('response_type')
|
||||
? TagResponseTypeSchema.parse(interaction.options.getString('response_type'))
|
||||
: undefined,
|
||||
content: interaction.options.has('content')
|
||||
? interaction.options.getString('content')
|
||||
: undefined,
|
||||
embed: interaction.options.has('embed_title') ||
|
||||
interaction.options.has('embed_description') ||
|
||||
interaction.options.has('embed_color')
|
||||
? buildEmbedPayload(interaction) ?? null
|
||||
: undefined,
|
||||
triggerWord: interaction.options.has('trigger_word')
|
||||
? interaction.options.getString('trigger_word')
|
||||
: undefined,
|
||||
allowedRoleIds: interaction.options.has('allowed_roles')
|
||||
? parseIdList(interaction.options.getString('allowed_roles'))
|
||||
: undefined,
|
||||
allowedChannelIds: interaction.options.has('allowed_channels')
|
||||
? parseIdList(interaction.options.getString('allowed_channels'))
|
||||
: undefined
|
||||
});
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tag.updated', { name: tag.name }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'delete') {
|
||||
await deleteTag(context, interaction.guildId!, interaction.options.getString('name', true));
|
||||
await interaction.reply({ content: t(locale, 'tag.deleted'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const tags = await listTags(context, interaction.guildId!);
|
||||
if (tags.length === 0) {
|
||||
await interaction.reply({ content: t(locale, 'tag.list.empty'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = tags.map(
|
||||
(tag) =>
|
||||
`\`${tag.name}\` — ${TagResponseTypeSchema.parse(tag.responseType)} (${tag.useCount} ${t(locale, 'tag.list.uses')})`
|
||||
);
|
||||
await interaction.reply({
|
||||
content: `${t(locale, 'tag.list.header')}\n${lines.join('\n')}`,
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'info') {
|
||||
const tag = await getTagByName(context, interaction.guildId!, interaction.options.getString('name', true));
|
||||
if (!tag) {
|
||||
await interaction.reply({ content: t(locale, 'tag.error.not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
tf(locale, 'tag.info.name', { name: tag.name }),
|
||||
tf(locale, 'tag.info.type', { type: tag.responseType }),
|
||||
tf(locale, 'tag.info.uses', { count: tag.useCount }),
|
||||
tag.triggerWord
|
||||
? tf(locale, 'tag.info.trigger', { word: tag.triggerWord })
|
||||
: t(locale, 'tag.info.noTrigger'),
|
||||
tag.allowedRoleIds.length > 0
|
||||
? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') })
|
||||
: t(locale, 'tag.info.allRoles'),
|
||||
tag.allowedChannelIds.length > 0
|
||||
? tf(locale, 'tag.info.channels', {
|
||||
channels: tag.allowedChannelIds.map((id) => `<#${id}>`).join(', ')
|
||||
})
|
||||
: t(locale, 'tag.info.allChannels')
|
||||
];
|
||||
|
||||
await interaction.reply({
|
||||
content: lines.join('\n'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TagError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `tag.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const tagsCommands = [tagCommand];
|
||||
50
apps/bot/src/modules/tags/events.ts
Normal file
50
apps/bot/src/modules/tags/events.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Events, type Client } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
buildTagReply,
|
||||
findTriggeredTag,
|
||||
memberCanUseTag,
|
||||
TagError
|
||||
} from './service.js';
|
||||
|
||||
export function registerTagEvents(client: Client, context: BotContext): void {
|
||||
client.on(Events.MessageCreate, async (message) => {
|
||||
try {
|
||||
if (!message.guild || message.author.bot || !message.content.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = await findTriggeredTag(context, message.guild.id, message.content);
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await message.guild.members.fetch(message.author.id).catch(() => null);
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!memberCanUseTag(tag, member, message.channel.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.prisma.tag.update({
|
||||
where: { id: tag.id },
|
||||
data: { useCount: { increment: 1 } }
|
||||
});
|
||||
|
||||
const payload = buildTagReply(tag, member, message.guild, '');
|
||||
await message.reply(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof TagError) {
|
||||
const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null);
|
||||
logger.debug({ code: error.code, tag: error.message }, t(locale, `tag.error.${error.code}`));
|
||||
return;
|
||||
}
|
||||
logger.error({ error, messageId: message.id }, 'Tag auto-responder failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
2
apps/bot/src/modules/tags/index.ts
Normal file
2
apps/bot/src/modules/tags/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { tagsCommands } from './commands.js';
|
||||
export { registerTagEvents } from './events.js';
|
||||
286
apps/bot/src/modules/tags/service.ts
Normal file
286
apps/bot/src/modules/tags/service.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { EmbedBuilder, type Guild, type GuildMember, type MessageReplyOptions } from 'discord.js';
|
||||
import {
|
||||
applyTagPlaceholders,
|
||||
TagResponseTypeSchema,
|
||||
WelcomeEmbedSchema,
|
||||
type TagResponseType
|
||||
} from '@nexumi/shared';
|
||||
import type { Tag } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
|
||||
export class TagError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTagName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function parseIdList(input: string | null | undefined): string[] {
|
||||
if (!input?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return input
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function applyRandomPlaceholders(template: string): string {
|
||||
return template.replace(/\{random:([^}]+)\}/g, (_match, group: string) => {
|
||||
const options = group.split('|').map((part) => part.trim()).filter(Boolean);
|
||||
if (options.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return options[Math.floor(Math.random() * options.length)]!;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildTagPlaceholderVars(
|
||||
member: GuildMember | null,
|
||||
guild: Guild,
|
||||
args: string
|
||||
): Record<string, string> {
|
||||
return {
|
||||
user: member ? `<@${member.id}>` : '',
|
||||
'user.name': member?.displayName ?? '',
|
||||
'user.tag': member?.user.tag ?? '',
|
||||
'user.id': member?.id ?? '',
|
||||
server: guild.name,
|
||||
args
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTagContent(template: string, vars: Record<string, string>): string {
|
||||
const withRandom = applyRandomPlaceholders(template);
|
||||
return applyTagPlaceholders(withRandom, vars);
|
||||
}
|
||||
|
||||
export function parseTagEmbed(raw: unknown) {
|
||||
const parsed = WelcomeEmbedSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function buildTagReply(
|
||||
tag: Tag,
|
||||
member: GuildMember | null,
|
||||
guild: Guild,
|
||||
args: string
|
||||
): MessageReplyOptions {
|
||||
const vars = buildTagPlaceholderVars(member, guild, args);
|
||||
const responseType = TagResponseTypeSchema.parse(tag.responseType);
|
||||
|
||||
if (responseType === 'EMBED') {
|
||||
const embedData = parseTagEmbed(tag.embed);
|
||||
const embed = new EmbedBuilder().setColor(embedData?.color ?? 0x6366f1);
|
||||
if (embedData?.title) {
|
||||
embed.setTitle(renderTagContent(embedData.title, vars));
|
||||
}
|
||||
if (embedData?.description) {
|
||||
embed.setDescription(renderTagContent(embedData.description, vars));
|
||||
}
|
||||
return { embeds: [embed] };
|
||||
}
|
||||
|
||||
const content = renderTagContent(tag.content ?? '', vars);
|
||||
return { content: content || '—' };
|
||||
}
|
||||
|
||||
export function memberCanUseTag(tag: Tag, member: GuildMember, channelId: string): boolean {
|
||||
if (tag.allowedRoleIds.length > 0) {
|
||||
const hasRole = tag.allowedRoleIds.some((roleId) => member.roles.cache.has(roleId));
|
||||
if (!hasRole) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (tag.allowedChannelIds.length > 0 && !tag.allowedChannelIds.includes(channelId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getTagByName(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
name: string
|
||||
): Promise<Tag | null> {
|
||||
return context.prisma.tag.findUnique({
|
||||
where: {
|
||||
guildId_name: {
|
||||
guildId,
|
||||
name: normalizeTagName(name)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTag(
|
||||
context: BotContext,
|
||||
params: {
|
||||
guildId: string;
|
||||
name: string;
|
||||
responseType: TagResponseType;
|
||||
content?: string | null;
|
||||
embed?: { title?: string; description?: string; color?: number } | null;
|
||||
triggerWord?: string | null;
|
||||
allowedRoleIds?: string[];
|
||||
allowedChannelIds?: string[];
|
||||
createdById: string;
|
||||
}
|
||||
): Promise<Tag> {
|
||||
await ensureGuild(context.prisma, params.guildId);
|
||||
const name = normalizeTagName(params.name);
|
||||
|
||||
if (!name || !/^[a-z0-9_-]{1,32}$/.test(name)) {
|
||||
throw new TagError('invalid_name');
|
||||
}
|
||||
|
||||
if (params.responseType === 'TEXT' && !params.content?.trim()) {
|
||||
throw new TagError('content_required');
|
||||
}
|
||||
|
||||
if (params.responseType === 'EMBED' && !params.embed?.title && !params.embed?.description) {
|
||||
throw new TagError('embed_required');
|
||||
}
|
||||
|
||||
return context.prisma.tag.create({
|
||||
data: {
|
||||
guildId: params.guildId,
|
||||
name,
|
||||
responseType: params.responseType,
|
||||
content: params.content ?? null,
|
||||
embed: params.embed ?? null,
|
||||
triggerWord: params.triggerWord?.trim() || null,
|
||||
allowedRoleIds: params.allowedRoleIds ?? [],
|
||||
allowedChannelIds: params.allowedChannelIds ?? [],
|
||||
createdById: params.createdById
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTag(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
name: string,
|
||||
updates: {
|
||||
newName?: string;
|
||||
responseType?: TagResponseType;
|
||||
content?: string | null;
|
||||
embed?: { title?: string; description?: string; color?: number } | null;
|
||||
triggerWord?: string | null;
|
||||
allowedRoleIds?: string[];
|
||||
allowedChannelIds?: string[];
|
||||
}
|
||||
): Promise<Tag> {
|
||||
const existing = await getTagByName(context, guildId, name);
|
||||
if (!existing) {
|
||||
throw new TagError('not_found');
|
||||
}
|
||||
|
||||
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
|
||||
const content = updates.content !== undefined ? updates.content : existing.content;
|
||||
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed);
|
||||
|
||||
if (responseType === 'TEXT' && !content?.trim()) {
|
||||
throw new TagError('content_required');
|
||||
}
|
||||
|
||||
if (responseType === 'EMBED' && !embed?.title && !embed?.description) {
|
||||
throw new TagError('embed_required');
|
||||
}
|
||||
|
||||
let newName: string | undefined;
|
||||
if (updates.newName) {
|
||||
newName = normalizeTagName(updates.newName);
|
||||
if (!newName || !/^[a-z0-9_-]{1,32}$/.test(newName)) {
|
||||
throw new TagError('invalid_name');
|
||||
}
|
||||
}
|
||||
|
||||
return context.prisma.tag.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: newName ?? existing.name,
|
||||
responseType,
|
||||
content,
|
||||
embed: embed ?? null,
|
||||
triggerWord:
|
||||
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
|
||||
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
|
||||
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTag(context: BotContext, guildId: string, name: string): Promise<void> {
|
||||
const existing = await getTagByName(context, guildId, name);
|
||||
if (!existing) {
|
||||
throw new TagError('not_found');
|
||||
}
|
||||
await context.prisma.tag.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
export async function listTags(context: BotContext, guildId: string): Promise<Tag[]> {
|
||||
return context.prisma.tag.findMany({
|
||||
where: { guildId },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeTag(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
name: string,
|
||||
member: GuildMember | null,
|
||||
guild: Guild,
|
||||
channelId: string,
|
||||
args: string
|
||||
): Promise<MessageReplyOptions> {
|
||||
const tag = await getTagByName(context, guildId, name);
|
||||
if (!tag) {
|
||||
throw new TagError('not_found');
|
||||
}
|
||||
|
||||
if (member && !memberCanUseTag(tag, member, channelId)) {
|
||||
throw new TagError('not_allowed');
|
||||
}
|
||||
|
||||
await context.prisma.tag.update({
|
||||
where: { id: tag.id },
|
||||
data: { useCount: { increment: 1 } }
|
||||
});
|
||||
|
||||
return buildTagReply(tag, member, guild, args);
|
||||
}
|
||||
|
||||
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
|
||||
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
|
||||
return pattern.test(content);
|
||||
}
|
||||
|
||||
export async function findTriggeredTag(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
content: string
|
||||
): Promise<Tag | null> {
|
||||
const tags = await context.prisma.tag.findMany({
|
||||
where: {
|
||||
guildId,
|
||||
triggerWord: { not: null }
|
||||
}
|
||||
});
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.triggerWord && matchesTriggerWord(content, tag.triggerWord)) {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
64
apps/bot/src/modules/tempvoice/command-definitions.ts
Normal file
64
apps/bot/src/modules/tempvoice/command-definitions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ChannelType } from 'discord.js';
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const voiceCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('voice')
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('setup'), 'tempvoice.setup.description')
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('hub_channel').addChannelTypes(ChannelType.GuildVoice).setRequired(true),
|
||||
'tempvoice.setup.options.hub_channel'
|
||||
)
|
||||
)
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('category').addChannelTypes(ChannelType.GuildCategory).setRequired(true),
|
||||
'tempvoice.setup.options.category'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('default_name'), 'tempvoice.setup.options.default_name')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('limit').setMinValue(0).setMaxValue(99),
|
||||
'tempvoice.setup.options.limit'
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('rename'), 'tempvoice.rename.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'tempvoice.rename.options.name')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('limit'), 'tempvoice.limit.description')
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('limit').setRequired(true).setMinValue(0).setMaxValue(99),
|
||||
'tempvoice.limit.options.limit'
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('lock'), 'tempvoice.lock.description'))
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('hide'), 'tempvoice.hide.description'))
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('kick'), 'tempvoice.kick.description')
|
||||
.addUserOption((o) =>
|
||||
applyOptionDescription(o.setName('user').setRequired(true), 'tempvoice.kick.options.user')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('transfer'), 'tempvoice.transfer.description')
|
||||
.addUserOption((o) =>
|
||||
applyOptionDescription(o.setName('user').setRequired(true), 'tempvoice.transfer.options.user')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('claim'), 'tempvoice.claim.description'))
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('panel'), 'tempvoice.panel.description')),
|
||||
'tempvoice.description'
|
||||
);
|
||||
201
apps/bot/src/modules/tempvoice/commands.ts
Normal file
201
apps/bot/src/modules/tempvoice/commands.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { voiceCommandData } from './command-definitions.js';
|
||||
import {
|
||||
buildControlPanelEmbed,
|
||||
buildControlPanelRows,
|
||||
claimTempChannel,
|
||||
fetchCategory,
|
||||
getTempVoiceChannelForMember,
|
||||
getTempVoiceConfig,
|
||||
hideTempChannel,
|
||||
kickFromTempChannel,
|
||||
lockTempChannel,
|
||||
renameTempChannel,
|
||||
setTempChannelLimit,
|
||||
transferTempChannelOwnership,
|
||||
updateTempVoiceConfig
|
||||
} from './service.js';
|
||||
|
||||
async function requireOwnedTempChannel(
|
||||
interaction: Parameters<SlashCommand['execute']>[0],
|
||||
context: Parameters<SlashCommand['execute']>[1],
|
||||
locale: 'de' | 'en'
|
||||
) {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
const guildMember =
|
||||
interaction.guild!.members.cache.get(interaction.user.id) ??
|
||||
(await interaction.guild!.members.fetch(interaction.user.id));
|
||||
const owned = await getTempVoiceChannelForMember(context.prisma, interaction.guildId!, guildMember);
|
||||
if (!owned || owned.record.ownerId !== interaction.user.id) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
|
||||
return null;
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
const voiceCommand: SlashCommand = {
|
||||
data: voiceCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = interaction.guildId!;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'setup') {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hub = interaction.options.getChannel('hub_channel', true);
|
||||
const category = interaction.options.getChannel('category', true);
|
||||
const defaultName = interaction.options.getString('default_name');
|
||||
const limit = interaction.options.getInteger('limit');
|
||||
|
||||
const categoryChannel = await fetchCategory(interaction.guild!, category.id);
|
||||
if (!categoryChannel) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidCategory'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await updateTempVoiceConfig(context.prisma, guildId, {
|
||||
enabled: true,
|
||||
hubChannelId: hub.id,
|
||||
categoryId: category.id,
|
||||
defaultName: defaultName ?? undefined,
|
||||
defaultLimit: limit ?? undefined
|
||||
});
|
||||
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.setup.done'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getTempVoiceConfig(context.prisma, guildId);
|
||||
if (!config.enabled) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.disabled'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'panel') {
|
||||
const owned = await requireOwnedTempChannel(interaction, context, locale);
|
||||
if (!owned) {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
embeds: [buildControlPanelEmbed(owned.channel.name, locale)],
|
||||
components: buildControlPanelRows(owned.channel.id, locale),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'claim') {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const inChannel = await getTempVoiceChannelForMember(context.prisma, guildId, member);
|
||||
if (!inChannel) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await claimTempChannel(context, inChannel.record, inChannel.channel, member, locale);
|
||||
if (result === 'owner_present') {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.claim.ownerPresent'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (result === 'not_in_channel') {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.claim.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const owned = await requireOwnedTempChannel(interaction, context, locale);
|
||||
if (!owned) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'rename') {
|
||||
const name = interaction.options.getString('name', true);
|
||||
await renameTempChannel(owned.channel, name);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tempvoice.rename.success', { name: name.slice(0, 100) }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'limit') {
|
||||
const limit = interaction.options.getInteger('limit', true);
|
||||
await setTempChannelLimit(owned.channel, limit);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tempvoice.limit.success', { limit }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'lock') {
|
||||
await lockTempChannel(owned.channel);
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.lock.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'hide') {
|
||||
await hideTempChannel(owned.channel);
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.hide.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'kick') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.kick.self'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const ok = await kickFromTempChannel(owned.channel, user.id);
|
||||
if (!ok) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.kick.notInChannel'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tempvoice.kick.success', { user: `<@${user.id}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'transfer') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.transfer.self'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
|
||||
if (!target || !owned.channel.members.has(target.id)) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.transfer.notInChannel'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await transferTempChannelOwnership(context, owned.record, owned.channel, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tempvoice.transfer.success', { user: `<@${user.id}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const tempVoiceCommands: SlashCommand[] = [voiceCommand];
|
||||
28
apps/bot/src/modules/tempvoice/events.ts
Normal file
28
apps/bot/src/modules/tempvoice/events.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { resolveHubJoin, resolveVoiceLeave } from './service.js';
|
||||
|
||||
export function registerTempVoiceEvents(context: BotContext): void {
|
||||
context.client.on('voiceStateUpdate', async (oldState, newState) => {
|
||||
try {
|
||||
const guild = newState.guild;
|
||||
const member = newState.member;
|
||||
if (!member || member.user.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldChannelId = oldState.channelId;
|
||||
const newChannelId = newState.channelId;
|
||||
|
||||
if (newChannelId && newChannelId !== oldChannelId) {
|
||||
await resolveHubJoin(context, member, newChannelId);
|
||||
}
|
||||
|
||||
if (oldChannelId && oldChannelId !== newChannelId) {
|
||||
await resolveVoiceLeave(context, guild, oldChannelId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, userId: newState.id }, 'Temp voice state handler failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
3
apps/bot/src/modules/tempvoice/index.ts
Normal file
3
apps/bot/src/modules/tempvoice/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { tempVoiceCommands } from './commands.js';
|
||||
export { registerTempVoiceEvents } from './events.js';
|
||||
export { isTempVoiceInteraction, isTempVoiceModal, handleTempVoiceInteraction } from './interactions.js';
|
||||
268
apps/bot/src/modules/tempvoice/interactions.ts
Normal file
268
apps/bot/src/modules/tempvoice/interactions.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
type ButtonInteraction,
|
||||
type ModalSubmitInteraction,
|
||||
type VoiceChannel
|
||||
} from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
assertTempVoiceOwner,
|
||||
claimTempChannel,
|
||||
hideTempChannel,
|
||||
kickFromTempChannel,
|
||||
lockTempChannel,
|
||||
parseTempVoiceButton,
|
||||
parseTempVoiceModal,
|
||||
tempVoiceModalId,
|
||||
TV_BUTTON_PREFIX,
|
||||
TV_MODAL_PREFIX,
|
||||
transferTempChannelOwnership
|
||||
} from './service.js';
|
||||
|
||||
export function isTempVoiceInteraction(customId: string): boolean {
|
||||
return customId.startsWith(TV_BUTTON_PREFIX);
|
||||
}
|
||||
|
||||
export function isTempVoiceModal(customId: string): boolean {
|
||||
return customId.startsWith(TV_MODAL_PREFIX);
|
||||
}
|
||||
|
||||
async function showRenameModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(tempVoiceModalId('rename', channelId))
|
||||
.setTitle('Rename channel')
|
||||
.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('name')
|
||||
.setLabel('New name')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true)
|
||||
.setMaxLength(100)
|
||||
)
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
async function showLimitModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(tempVoiceModalId('limit', channelId))
|
||||
.setTitle('Set user limit')
|
||||
.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('limit')
|
||||
.setLabel('Limit (0 = unlimited)')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true)
|
||||
.setMaxLength(2)
|
||||
)
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
async function showKickModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(tempVoiceModalId('kick', channelId))
|
||||
.setTitle('Kick user')
|
||||
.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('user_id')
|
||||
.setLabel('User ID')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true)
|
||||
.setMaxLength(20)
|
||||
)
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
async function showTransferModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(tempVoiceModalId('transfer', channelId))
|
||||
.setTitle('Transfer ownership')
|
||||
.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('user_id')
|
||||
.setLabel('New owner user ID')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true)
|
||||
.setMaxLength(20)
|
||||
)
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
async function handleTempVoiceButton(
|
||||
interaction: ButtonInteraction,
|
||||
context: BotContext,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<void> {
|
||||
const parsed = parseTempVoiceButton(interaction.customId);
|
||||
if (!parsed || !interaction.inGuild()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const owned = await assertTempVoiceOwner(context, member, parsed.channelId);
|
||||
|
||||
if (parsed.action === 'claim') {
|
||||
const channel = interaction.guild!.channels.cache.get(parsed.channelId);
|
||||
if (!channel?.isVoiceBased() || channel.isDMBased()) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.notFound'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const record = await context.prisma.tempVoiceChannel.findUnique({ where: { channelId: parsed.channelId } });
|
||||
if (!record) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.notFound'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const result = await claimTempChannel(context, record, channel as VoiceChannel, member, locale);
|
||||
if (result === 'owner_present') {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.claim.ownerPresent'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (result === 'not_in_channel') {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.claim.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!owned) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (parsed.action) {
|
||||
case 'rename':
|
||||
await showRenameModal(interaction, parsed.channelId);
|
||||
return;
|
||||
case 'limit':
|
||||
await showLimitModal(interaction, parsed.channelId);
|
||||
return;
|
||||
case 'kick':
|
||||
await showKickModal(interaction, parsed.channelId);
|
||||
return;
|
||||
case 'transfer':
|
||||
await showTransferModal(interaction, parsed.channelId);
|
||||
return;
|
||||
case 'lock':
|
||||
await lockTempChannel(owned.channel);
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.lock.success'), ephemeral: true });
|
||||
return;
|
||||
case 'hide':
|
||||
await hideTempChannel(owned.channel);
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.hide.success'), ephemeral: true });
|
||||
return;
|
||||
default:
|
||||
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTempVoiceModal(
|
||||
interaction: ModalSubmitInteraction,
|
||||
context: BotContext,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<void> {
|
||||
const parsed = parseTempVoiceModal(interaction.customId);
|
||||
if (!parsed || !interaction.inGuild()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const owned = await assertTempVoiceOwner(context, member, parsed.channelId);
|
||||
if (!owned) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === 'rename') {
|
||||
const name = interaction.fields.getTextInputValue('name').trim();
|
||||
if (!name) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidName'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await owned.channel.setName(name.slice(0, 100));
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tempvoice.rename.success', { name: name.slice(0, 100) }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === 'limit') {
|
||||
const raw = interaction.fields.getTextInputValue('limit').trim();
|
||||
const limit = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(limit) || limit < 0 || limit > 99) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidLimit'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await owned.channel.setUserLimit(limit);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tempvoice.limit.success', { limit }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === 'kick') {
|
||||
const userId = interaction.fields.getTextInputValue('user_id').trim();
|
||||
if (userId === interaction.user.id) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.kick.self'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const ok = await kickFromTempChannel(owned.channel, userId);
|
||||
if (!ok) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.kick.notInChannel'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tempvoice.kick.success', { user: `<@${userId}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === 'transfer') {
|
||||
const userId = interaction.fields.getTextInputValue('user_id').trim();
|
||||
if (userId === interaction.user.id) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.transfer.self'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const target = await interaction.guild!.members.fetch(userId).catch(() => null);
|
||||
if (!target || !owned.channel.members.has(target.id)) {
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.transfer.notInChannel'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await transferTempChannelOwnership(context, owned.record, owned.channel, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'tempvoice.transfer.success', { user: `<@${userId}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTempVoiceInteraction(
|
||||
interaction: ButtonInteraction | ModalSubmitInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
|
||||
if (interaction.isButton()) {
|
||||
await handleTempVoiceButton(interaction, context, locale);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(TV_MODAL_PREFIX)) {
|
||||
await handleTempVoiceModal(interaction, context, locale);
|
||||
}
|
||||
}
|
||||
389
apps/bot/src/modules/tempvoice/service.ts
Normal file
389
apps/bot/src/modules/tempvoice/service.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
OverwriteType,
|
||||
PermissionFlagsBits,
|
||||
type CategoryChannel,
|
||||
type Guild,
|
||||
type GuildMember,
|
||||
type VoiceChannel
|
||||
} from 'discord.js';
|
||||
import { applyTagPlaceholders, t, tf, type Locale } from '@nexumi/shared';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
|
||||
export const TV_BUTTON_PREFIX = 'tv:';
|
||||
export const TV_MODAL_PREFIX = 'tv:modal:';
|
||||
|
||||
export async function getTempVoiceConfig(prisma: BotContext['prisma'], guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
let config = await prisma.tempVoiceConfig.findUnique({ where: { guildId } });
|
||||
if (!config) {
|
||||
config = await prisma.tempVoiceConfig.create({ data: { guildId } });
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function updateTempVoiceConfig(
|
||||
prisma: BotContext['prisma'],
|
||||
guildId: string,
|
||||
data: {
|
||||
enabled?: boolean;
|
||||
hubChannelId?: string | null;
|
||||
categoryId?: string | null;
|
||||
defaultName?: string;
|
||||
defaultLimit?: number;
|
||||
}
|
||||
) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
return prisma.tempVoiceConfig.upsert({
|
||||
where: { guildId },
|
||||
update: data,
|
||||
create: { guildId, ...data }
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTempVoiceChannel(prisma: BotContext['prisma'], channelId: string) {
|
||||
return prisma.tempVoiceChannel.findUnique({ where: { channelId } });
|
||||
}
|
||||
|
||||
export async function getTempVoiceChannelForMember(
|
||||
prisma: BotContext['prisma'],
|
||||
guildId: string,
|
||||
member: GuildMember
|
||||
): Promise<{ record: NonNullable<Awaited<ReturnType<typeof getTempVoiceChannel>>>; channel: VoiceChannel } | null> {
|
||||
const voiceChannelId = member.voice.channelId;
|
||||
if (!voiceChannelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = await getTempVoiceChannel(prisma, voiceChannelId);
|
||||
if (!record || record.guildId !== guildId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = member.guild.channels.cache.get(voiceChannelId);
|
||||
if (!channel?.isVoiceBased() || channel.isDMBased()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { record, channel: channel as VoiceChannel };
|
||||
}
|
||||
|
||||
export function renderDefaultChannelName(template: string, member: GuildMember): string {
|
||||
const name = applyTagPlaceholders(template, {
|
||||
user: member.displayName,
|
||||
'user.name': member.displayName,
|
||||
'user.tag': member.user.username
|
||||
});
|
||||
return name.slice(0, 100);
|
||||
}
|
||||
|
||||
export function buildControlPanelRows(channelId: string, locale: Locale): ActionRowBuilder<ButtonBuilder>[] {
|
||||
const mk = (action: string, labelKey: string, style: ButtonStyle = ButtonStyle.Secondary) =>
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`${TV_BUTTON_PREFIX}${action}:${channelId}`)
|
||||
.setLabel(t(locale, labelKey))
|
||||
.setStyle(style);
|
||||
|
||||
return [
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
mk('rename', 'tempvoice.button.rename'),
|
||||
mk('limit', 'tempvoice.button.limit'),
|
||||
mk('lock', 'tempvoice.button.lock'),
|
||||
mk('hide', 'tempvoice.button.hide')
|
||||
),
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
mk('kick', 'tempvoice.button.kick', ButtonStyle.Danger),
|
||||
mk('transfer', 'tempvoice.button.transfer'),
|
||||
mk('claim', 'tempvoice.button.claim', ButtonStyle.Success)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
export function buildControlPanelEmbed(channelName: string, locale: Locale): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle(t(locale, 'tempvoice.panel.title'))
|
||||
.setDescription(tf(locale, 'tempvoice.panel.description', { channel: channelName }))
|
||||
.setColor(0x6366f1);
|
||||
}
|
||||
|
||||
export async function sendOwnerControlPanelDm(
|
||||
member: GuildMember,
|
||||
channelId: string,
|
||||
channelName: string,
|
||||
locale: Locale
|
||||
): Promise<void> {
|
||||
const embed = buildControlPanelEmbed(channelName, locale);
|
||||
const rows = buildControlPanelRows(channelId, locale);
|
||||
await member.send({ embeds: [embed], components: rows }).catch((error) => {
|
||||
logger.debug({ error, userId: member.id }, 'Could not DM temp voice control panel');
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTempVoiceChannel(
|
||||
context: BotContext,
|
||||
member: GuildMember,
|
||||
config: Awaited<ReturnType<typeof getTempVoiceConfig>>
|
||||
): Promise<VoiceChannel | null> {
|
||||
if (!config.categoryId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const category = await member.guild.channels.fetch(config.categoryId).catch(() => null);
|
||||
if (!category || category.type !== ChannelType.GuildCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelName = renderDefaultChannelName(config.defaultName, member);
|
||||
const limit = config.defaultLimit > 0 ? config.defaultLimit : undefined;
|
||||
|
||||
const channel = await member.guild.channels.create({
|
||||
name: channelName,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category.id,
|
||||
userLimit: limit,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: member.guild.roles.everyone.id,
|
||||
type: OverwriteType.Role,
|
||||
allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: member.id,
|
||||
type: OverwriteType.Member,
|
||||
allow: [
|
||||
PermissionFlagsBits.Connect,
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.MoveMembers
|
||||
]
|
||||
},
|
||||
{
|
||||
id: member.guild.members.me!.id,
|
||||
type: OverwriteType.Member,
|
||||
allow: [
|
||||
PermissionFlagsBits.Connect,
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.MoveMembers
|
||||
]
|
||||
}
|
||||
],
|
||||
reason: 'Temp voice join-to-create'
|
||||
});
|
||||
|
||||
await context.prisma.tempVoiceChannel.create({
|
||||
data: {
|
||||
guildId: member.guild.id,
|
||||
channelId: channel.id,
|
||||
ownerId: member.id
|
||||
}
|
||||
});
|
||||
|
||||
await member.voice.setChannel(channel).catch(async (error) => {
|
||||
logger.warn({ error, channelId: channel.id }, 'Failed to move member into temp voice channel');
|
||||
await channel.delete('Temp voice move failed').catch(() => undefined);
|
||||
await context.prisma.tempVoiceChannel.delete({ where: { channelId: channel.id } }).catch(() => undefined);
|
||||
});
|
||||
|
||||
if (!member.voice.channelId || member.voice.channelId !== channel.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { getGuildLocale } = await import('../../i18n.js');
|
||||
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||
await sendOwnerControlPanelDm(member, channel.id, channel.name, locale);
|
||||
return channel;
|
||||
}
|
||||
|
||||
export async function deleteTempVoiceChannel(context: BotContext, channelId: string): Promise<void> {
|
||||
await context.prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => undefined);
|
||||
const channel = await context.client.channels.fetch(channelId).catch(() => null);
|
||||
if (channel?.isVoiceBased()) {
|
||||
await channel.delete('Temp voice channel empty').catch((error) => {
|
||||
logger.warn({ error, channelId }, 'Failed to delete temp voice channel');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertTempVoiceOwner(
|
||||
context: BotContext,
|
||||
member: GuildMember,
|
||||
channelId: string
|
||||
): Promise<{ record: NonNullable<Awaited<ReturnType<typeof getTempVoiceChannel>>>; channel: VoiceChannel } | null> {
|
||||
const record = await getTempVoiceChannel(context.prisma, channelId);
|
||||
if (!record || record.guildId !== member.guild.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = member.guild.channels.cache.get(channelId);
|
||||
if (!channel?.isVoiceBased() || channel.isDMBased()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (record.ownerId !== member.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { record, channel: channel as VoiceChannel };
|
||||
}
|
||||
|
||||
export async function renameTempChannel(channel: VoiceChannel, name: string): Promise<void> {
|
||||
await channel.setName(name.slice(0, 100));
|
||||
}
|
||||
|
||||
export async function setTempChannelLimit(channel: VoiceChannel, limit: number): Promise<void> {
|
||||
await channel.setUserLimit(limit);
|
||||
}
|
||||
|
||||
export async function lockTempChannel(channel: VoiceChannel): Promise<boolean> {
|
||||
await channel.permissionOverwrites.edit(channel.guild.roles.everyone, {
|
||||
Connect: false
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function hideTempChannel(channel: VoiceChannel): Promise<boolean> {
|
||||
await channel.permissionOverwrites.edit(channel.guild.roles.everyone, {
|
||||
ViewChannel: false
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function kickFromTempChannel(channel: VoiceChannel, targetId: string): Promise<boolean> {
|
||||
const target = channel.members.get(targetId);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
await target.voice.disconnect('Temp voice kick');
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function transferTempChannelOwnership(
|
||||
context: BotContext,
|
||||
record: { channelId: string; ownerId: string },
|
||||
channel: VoiceChannel,
|
||||
newOwner: GuildMember,
|
||||
locale: Locale
|
||||
): Promise<void> {
|
||||
await context.prisma.tempVoiceChannel.update({
|
||||
where: { channelId: record.channelId },
|
||||
data: { ownerId: newOwner.id }
|
||||
});
|
||||
|
||||
await channel.permissionOverwrites.edit(record.ownerId, {
|
||||
ManageChannels: null,
|
||||
MoveMembers: null
|
||||
});
|
||||
await channel.permissionOverwrites.edit(newOwner.id, {
|
||||
Connect: true,
|
||||
ViewChannel: true,
|
||||
ManageChannels: true,
|
||||
MoveMembers: true
|
||||
});
|
||||
|
||||
await sendOwnerControlPanelDm(newOwner, channel.id, channel.name, locale);
|
||||
}
|
||||
|
||||
export async function claimTempChannel(
|
||||
context: BotContext,
|
||||
record: { channelId: string; ownerId: string },
|
||||
channel: VoiceChannel,
|
||||
claimer: GuildMember,
|
||||
locale: Locale
|
||||
): Promise<'owner_present' | 'claimed' | 'not_in_channel'> {
|
||||
if (channel.members.has(record.ownerId)) {
|
||||
return 'owner_present';
|
||||
}
|
||||
if (!channel.members.has(claimer.id)) {
|
||||
return 'not_in_channel';
|
||||
}
|
||||
|
||||
await transferTempChannelOwnership(context, record, channel, claimer, locale);
|
||||
return 'claimed';
|
||||
}
|
||||
|
||||
export async function resolveHubJoin(
|
||||
context: BotContext,
|
||||
member: GuildMember,
|
||||
hubChannelId: string
|
||||
): Promise<void> {
|
||||
const config = await getTempVoiceConfig(context.prisma, member.guild.id);
|
||||
if (!config.enabled || config.hubChannelId !== hubChannelId || !config.categoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await createTempVoiceChannel(context, member, config);
|
||||
}
|
||||
|
||||
export async function resolveVoiceLeave(
|
||||
context: BotContext,
|
||||
guild: Guild,
|
||||
channelId: string
|
||||
): Promise<void> {
|
||||
const record = await getTempVoiceChannel(context.prisma, channelId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = guild.channels.cache.get(channelId);
|
||||
if (!channel?.isVoiceBased()) {
|
||||
await context.prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel.members.size === 0) {
|
||||
await deleteTempVoiceChannel(context, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTempVoiceButton(customId: string): { action: string; channelId: string } | null {
|
||||
if (!customId.startsWith(TV_BUTTON_PREFIX) || customId.startsWith(TV_MODAL_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const body = customId.slice(TV_BUTTON_PREFIX.length);
|
||||
const sep = body.indexOf(':');
|
||||
if (sep <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
action: body.slice(0, sep),
|
||||
channelId: body.slice(sep + 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTempVoiceModal(customId: string): { action: string; channelId: string } | null {
|
||||
if (!customId.startsWith(TV_MODAL_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const body = customId.slice(TV_MODAL_PREFIX.length);
|
||||
const sep = body.indexOf(':');
|
||||
if (sep <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
action: body.slice(0, sep),
|
||||
channelId: body.slice(sep + 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function tempVoiceModalId(action: string, channelId: string): string {
|
||||
return `${TV_MODAL_PREFIX}${action}:${channelId}`;
|
||||
}
|
||||
|
||||
export function tempVoiceButtonId(action: string, channelId: string): string {
|
||||
return `${TV_BUTTON_PREFIX}${action}:${channelId}`;
|
||||
}
|
||||
|
||||
export async function fetchCategory(guild: Guild, categoryId: string): Promise<CategoryChannel | null> {
|
||||
const channel = await guild.channels.fetch(categoryId).catch(() => null);
|
||||
if (!channel || channel.type !== ChannelType.GuildCategory) {
|
||||
return null;
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
78
apps/bot/src/modules/tickets/command-definitions.ts
Normal file
78
apps/bot/src/modules/tickets/command-definitions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
applyCommandDescription,
|
||||
applyOptionDescription,
|
||||
localizedChoice
|
||||
} from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const ticketCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('ticket')
|
||||
.addSubcommandGroup((group) =>
|
||||
applyCommandDescription(group.setName('panel'), 'ticket.panel.description').addSubcommand(
|
||||
(s) =>
|
||||
applyCommandDescription(s.setName('create'), 'ticket.panel.create.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'ticket.panel.create.options.name')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('description'),
|
||||
'ticket.panel.create.options.description'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommandGroup((group) =>
|
||||
applyCommandDescription(group.setName('category'), 'ticket.category.description').addSubcommand(
|
||||
(s) =>
|
||||
applyCommandDescription(s.setName('create'), 'ticket.category.create.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('name').setRequired(true),
|
||||
'ticket.category.create.options.name'
|
||||
)
|
||||
)
|
||||
.addRoleOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('support_role').setRequired(true),
|
||||
'ticket.category.create.options.support_role'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('close'), 'ticket.close.description').addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('reason'), 'ticket.close.options.reason')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('claim'), 'ticket.claim.description'))
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('add'), 'ticket.add.description').addUserOption((o) =>
|
||||
applyOptionDescription(o.setName('user').setRequired(true), 'ticket.common.options.user')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('remove'), 'ticket.remove.description').addUserOption((o) =>
|
||||
applyOptionDescription(o.setName('user').setRequired(true), 'ticket.common.options.user')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('rename'), 'ticket.rename.description').addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'ticket.rename.options.name')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('priority'), 'ticket.priority.description').addStringOption(
|
||||
(o) =>
|
||||
applyOptionDescription(o.setName('level').setRequired(true), 'ticket.priority.options.level')
|
||||
.addChoices(
|
||||
localizedChoice('ticket.priority.choice.low', 'LOW'),
|
||||
localizedChoice('ticket.priority.choice.normal', 'NORMAL'),
|
||||
localizedChoice('ticket.priority.choice.high', 'HIGH'),
|
||||
localizedChoice('ticket.priority.choice.urgent', 'URGENT')
|
||||
)
|
||||
)
|
||||
),
|
||||
'ticket.description'
|
||||
);
|
||||
190
apps/bot/src/modules/tickets/commands.ts
Normal file
190
apps/bot/src/modules/tickets/commands.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { ticketCommandData } from './command-definitions.js';
|
||||
import {
|
||||
addUserToTicket,
|
||||
assertTicketStaff,
|
||||
buildPanelComponents,
|
||||
buildPanelEmbed,
|
||||
claimTicket,
|
||||
closeTicket,
|
||||
createCategoryWithRole,
|
||||
findTicketByChannel,
|
||||
getGuildCategories,
|
||||
removeUserFromTicket,
|
||||
renameTicket,
|
||||
setTicketPriority,
|
||||
TicketError,
|
||||
upsertCategoryByName
|
||||
} from './service.js';
|
||||
|
||||
async function ensureManageGuild(
|
||||
interaction: Parameters<SlashCommand['execute']>[0],
|
||||
locale: 'de' | 'en'
|
||||
): Promise<boolean> {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||
}
|
||||
|
||||
const ticketCommand: SlashCommand = {
|
||||
data: ticketCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const group = interaction.options.getSubcommandGroup(false);
|
||||
const sub = interaction.options.getSubcommand(false);
|
||||
|
||||
if (group === 'panel' && sub === 'create') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = interaction.options.getString('name', true);
|
||||
const description = interaction.options.getString('description');
|
||||
await upsertCategoryByName(context, interaction.guildId!, name, description);
|
||||
|
||||
const categories = await getGuildCategories(context, interaction.guildId!);
|
||||
if (categories.length === 0) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.invalid_channel'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = buildPanelEmbed(locale, name, description);
|
||||
const components = buildPanelComponents(categories);
|
||||
await channel.send({ embeds: [embed], components });
|
||||
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (group === 'category' && sub === 'create') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = interaction.options.getString('name', true);
|
||||
const role = interaction.options.getRole('support_role', true);
|
||||
const category = await createCategoryWithRole(
|
||||
context,
|
||||
interaction.guildId!,
|
||||
name,
|
||||
role.id
|
||||
);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.category.created', { name: category.name }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
|
||||
try {
|
||||
if (sub === 'close') {
|
||||
const isStaff = await (async () => {
|
||||
try {
|
||||
await assertTicketStaff(member, ticket);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const isOpener = ticket.openerId === interaction.user.id;
|
||||
|
||||
if (!isStaff && !isOpener) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = interaction.options.getString('reason') ?? undefined;
|
||||
await closeTicket(context, ticket, interaction.user.id, locale, reason);
|
||||
await interaction.reply({ content: t(locale, 'ticket.close.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'claim') {
|
||||
await claimTicket(context, ticket, member, locale);
|
||||
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const target = await interaction.guild!.members.fetch(user.id);
|
||||
await addUserToTicket(context, ticket, member, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const target = await interaction.guild!.members.fetch(user.id);
|
||||
await removeUserFromTicket(context, ticket, member, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'rename') {
|
||||
const name = interaction.options.getString('name', true);
|
||||
await renameTicket(context, ticket, member, name, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.rename.success', { name }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'priority') {
|
||||
const level = interaction.options.getString('level', true);
|
||||
await setTicketPriority(context, ticket, member, level, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.priority.success', {
|
||||
priority: t(locale, `ticket.priority.${level}`)
|
||||
}),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `ticket.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ticketsCommands = [ticketCommand];
|
||||
12
apps/bot/src/modules/tickets/events.ts
Normal file
12
apps/bot/src/modules/tickets/events.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Events } from 'discord.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { touchTicketActivity } from './service.js';
|
||||
|
||||
export function registerTicketEvents(context: BotContext): void {
|
||||
context.client.on(Events.MessageCreate, async (message) => {
|
||||
if (message.author.bot || !message.guildId) {
|
||||
return;
|
||||
}
|
||||
await touchTicketActivity(context, message.channelId);
|
||||
});
|
||||
}
|
||||
4
apps/bot/src/modules/tickets/index.ts
Normal file
4
apps/bot/src/modules/tickets/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ticketsCommands } from './commands.js';
|
||||
export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
|
||||
export { closeInactiveTickets, checkInactiveTickets } from './service.js';
|
||||
export { registerTicketEvents } from './events.js';
|
||||
182
apps/bot/src/modules/tickets/interactions.ts
Normal file
182
apps/bot/src/modules/tickets/interactions.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
ButtonInteraction,
|
||||
ModalSubmitInteraction,
|
||||
StringSelectMenuInteraction
|
||||
} from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
buildOpenModal,
|
||||
openTicketForCategory,
|
||||
parseFormFields,
|
||||
parseRateButton,
|
||||
rateTicket,
|
||||
TICKET_MODAL_PREFIX,
|
||||
TICKET_OPEN_PREFIX,
|
||||
TICKET_RATE_PREFIX,
|
||||
TICKET_SELECT_ID,
|
||||
TicketError
|
||||
} from './service.js';
|
||||
|
||||
export function isTicketInteraction(customId: string): boolean {
|
||||
return (
|
||||
customId.startsWith(TICKET_OPEN_PREFIX) ||
|
||||
customId === TICKET_SELECT_ID ||
|
||||
customId.startsWith(TICKET_MODAL_PREFIX) ||
|
||||
customId.startsWith(TICKET_RATE_PREFIX)
|
||||
);
|
||||
}
|
||||
|
||||
async function replyTicketError(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
|
||||
locale: 'de' | 'en',
|
||||
code: string
|
||||
): Promise<void> {
|
||||
const content = t(locale, `ticket.error.${code}`);
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ content, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOpenTicketRequest(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||
context: BotContext,
|
||||
categoryId: string
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!category || category.guildId !== interaction.guildId) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = parseFormFields(category.formFields);
|
||||
if (fields.length > 0) {
|
||||
await interaction.showModal(buildOpenModal(category, locale));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||
const ticket = await openTicketForCategory(
|
||||
context,
|
||||
interaction.guild,
|
||||
member,
|
||||
categoryId,
|
||||
interaction.channelId ?? undefined
|
||||
);
|
||||
const channelRef = ticket.channelId ?? ticket.threadId;
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await replyTicketError(interaction, locale, error.code);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTicketInteraction(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
|
||||
const categoryId = interaction.values[0];
|
||||
if (!categoryId) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await handleOpenTicketRequest(interaction, context, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
|
||||
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
|
||||
await handleOpenTicketRequest(interaction, context, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedRate = parseRateButton(interaction.customId);
|
||||
if (parsedRate) {
|
||||
try {
|
||||
await rateTicket(context, parsedRate.ticketId, interaction.user.id, parsedRate.rating, locale);
|
||||
await interaction.update({
|
||||
content: tf(locale, 'ticket.rated', { rating: parsedRate.rating }),
|
||||
components: []
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `ticket.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
|
||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!category) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = parseFormFields(category.formFields);
|
||||
const formAnswers: Record<string, string> = {};
|
||||
if (fields.length > 0) {
|
||||
for (const field of fields) {
|
||||
formAnswers[field.label] = interaction.fields.getTextInputValue(field.id);
|
||||
}
|
||||
} else {
|
||||
formAnswers[t(locale, 'ticket.modal.subject')] = interaction.fields.getTextInputValue('subject');
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||
const ticket = await openTicketForCategory(
|
||||
context,
|
||||
interaction.guild,
|
||||
member,
|
||||
categoryId,
|
||||
interaction.channelId ?? undefined,
|
||||
formAnswers,
|
||||
formAnswers[t(locale, 'ticket.modal.subject')] ?? category.name
|
||||
);
|
||||
const channelRef = ticket.channelId ?? ticket.threadId;
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await replyTicketError(interaction, locale, error.code);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
886
apps/bot/src/modules/tickets/service.ts
Normal file
886
apps/bot/src/modules/tickets/service.ts
Normal file
@@ -0,0 +1,886 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
ModalBuilder,
|
||||
PermissionFlagsBits,
|
||||
StringSelectMenuBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
type Guild,
|
||||
type GuildMember,
|
||||
type Message,
|
||||
type SendableChannels,
|
||||
type TextChannel,
|
||||
type ThreadChannel
|
||||
} from 'discord.js';
|
||||
import {
|
||||
buildTicketTranscriptHtml,
|
||||
TicketModeSchema,
|
||||
TicketPrioritySchema,
|
||||
t,
|
||||
tf
|
||||
} from '@nexumi/shared';
|
||||
import type { Ticket, TicketCategory, TicketConfig } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
|
||||
export const TICKET_OPEN_PREFIX = 'ticket:open:';
|
||||
export const TICKET_SELECT_ID = 'ticket:select';
|
||||
export const TICKET_MODAL_PREFIX = 'ticket:modal:';
|
||||
export const TICKET_RATE_PREFIX = 'ticket:rate:';
|
||||
|
||||
export const TicketFormFieldSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
style: z.enum(['SHORT', 'PARAGRAPH']).default('SHORT'),
|
||||
required: z.boolean().default(true),
|
||||
placeholder: z.string().optional()
|
||||
});
|
||||
|
||||
export type TicketFormField = z.infer<typeof TicketFormFieldSchema>;
|
||||
|
||||
export class TicketError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSendableChannel(
|
||||
context: BotContext,
|
||||
channelId: string
|
||||
): Promise<SendableChannels | null> {
|
||||
const channel = await context.client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
return null;
|
||||
}
|
||||
return channel as SendableChannels;
|
||||
}
|
||||
|
||||
export async function getTicketConfig(
|
||||
prisma: BotContext['prisma'],
|
||||
guildId: string
|
||||
): Promise<TicketConfig> {
|
||||
await ensureGuild(prisma, guildId);
|
||||
return prisma.ticketConfig.upsert({
|
||||
where: { guildId },
|
||||
update: {},
|
||||
create: { guildId }
|
||||
});
|
||||
}
|
||||
|
||||
export function parseFormFields(raw: unknown): TicketFormField[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.map((item) => TicketFormFieldSchema.safeParse(item))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.data!)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
export function openButtonId(categoryId: string): string {
|
||||
return `${TICKET_OPEN_PREFIX}${categoryId}`;
|
||||
}
|
||||
|
||||
export function modalId(categoryId: string): string {
|
||||
return `${TICKET_MODAL_PREFIX}${categoryId}`;
|
||||
}
|
||||
|
||||
export function rateButtonId(ticketId: string, rating: number): string {
|
||||
return `${TICKET_RATE_PREFIX}${ticketId}:${rating}`;
|
||||
}
|
||||
|
||||
export function parseRateButton(customId: string): { ticketId: string; rating: number } | null {
|
||||
if (!customId.startsWith(TICKET_RATE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const rest = customId.slice(TICKET_RATE_PREFIX.length);
|
||||
const colon = rest.lastIndexOf(':');
|
||||
if (colon === -1) {
|
||||
return null;
|
||||
}
|
||||
const ticketId = rest.slice(0, colon);
|
||||
const rating = Number.parseInt(rest.slice(colon + 1), 10);
|
||||
if (!ticketId || Number.isNaN(rating) || rating < 1 || rating > 5) {
|
||||
return null;
|
||||
}
|
||||
return { ticketId, rating };
|
||||
}
|
||||
|
||||
export async function findTicketByChannel(
|
||||
prisma: BotContext['prisma'],
|
||||
channelId: string
|
||||
): Promise<(Ticket & { category: TicketCategory | null }) | null> {
|
||||
return prisma.ticket.findFirst({
|
||||
where: {
|
||||
OR: [{ channelId }, { threadId: channelId }],
|
||||
status: { not: 'CLOSED' }
|
||||
},
|
||||
include: { category: true }
|
||||
});
|
||||
}
|
||||
|
||||
export async function findOpenTicketForUser(
|
||||
prisma: BotContext['prisma'],
|
||||
guildId: string,
|
||||
userId: string
|
||||
): Promise<Ticket | null> {
|
||||
return prisma.ticket.findFirst({
|
||||
where: {
|
||||
guildId,
|
||||
openerId: userId,
|
||||
status: { in: ['OPEN', 'CLAIMED'] }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isTicketStaff(member: GuildMember, category: TicketCategory | null): boolean {
|
||||
if (member.permissions.has(PermissionFlagsBits.ManageChannels)) {
|
||||
return true;
|
||||
}
|
||||
if (!category) {
|
||||
return false;
|
||||
}
|
||||
return category.supportRoleIds.some((roleId) => member.roles.cache.has(roleId));
|
||||
}
|
||||
|
||||
export async function assertTicketStaff(
|
||||
member: GuildMember,
|
||||
ticket: Ticket & { category: TicketCategory | null }
|
||||
): Promise<void> {
|
||||
if (!isTicketStaff(member, ticket.category)) {
|
||||
throw new TicketError('not_staff');
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPanelEmbed(
|
||||
locale: 'de' | 'en',
|
||||
title: string,
|
||||
description?: string | null
|
||||
): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description ?? t(locale, 'ticket.panel.defaultDescription'))
|
||||
.setColor(0x6366f1);
|
||||
}
|
||||
|
||||
export function buildPanelComponents(categories: TicketCategory[]): ActionRowBuilder<
|
||||
ButtonBuilder | StringSelectMenuBuilder
|
||||
>[] {
|
||||
if (categories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (categories.length <= 5) {
|
||||
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||
for (const category of categories) {
|
||||
const button = new ButtonBuilder()
|
||||
.setCustomId(openButtonId(category.id))
|
||||
.setLabel(category.name.slice(0, 80))
|
||||
.setStyle(ButtonStyle.Primary);
|
||||
if (category.emoji) {
|
||||
button.setEmoji(category.emoji);
|
||||
}
|
||||
row.addComponents(button);
|
||||
}
|
||||
return [row];
|
||||
}
|
||||
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId(TICKET_SELECT_ID)
|
||||
.setPlaceholder('Select a category')
|
||||
.addOptions(
|
||||
categories.slice(0, 25).map((category) => ({
|
||||
label: category.name.slice(0, 100),
|
||||
value: category.id,
|
||||
description: category.description?.slice(0, 100) ?? undefined,
|
||||
emoji: category.emoji ? { name: category.emoji } : undefined
|
||||
}))
|
||||
);
|
||||
|
||||
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
|
||||
}
|
||||
|
||||
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
|
||||
const fields = parseFormFields(category.formFields);
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(modalId(category.id))
|
||||
.setTitle(category.name.slice(0, 45));
|
||||
|
||||
for (const field of fields) {
|
||||
const input = new TextInputBuilder()
|
||||
.setCustomId(field.id)
|
||||
.setLabel(field.label.slice(0, 45))
|
||||
.setStyle(field.style === 'PARAGRAPH' ? TextInputStyle.Paragraph : TextInputStyle.Short)
|
||||
.setRequired(field.required);
|
||||
if (field.placeholder) {
|
||||
input.setPlaceholder(field.placeholder.slice(0, 100));
|
||||
}
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('subject')
|
||||
.setLabel(t(locale, 'ticket.modal.subject'))
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true)
|
||||
.setMaxLength(100)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
async function permissionOverwrites(
|
||||
guild: Guild,
|
||||
openerId: string,
|
||||
supportRoleIds: string[]
|
||||
) {
|
||||
const me = await guild.members.fetchMe();
|
||||
const overwrites = [
|
||||
{
|
||||
id: guild.roles.everyone.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: openerId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory,
|
||||
PermissionFlagsBits.AttachFiles,
|
||||
PermissionFlagsBits.EmbedLinks
|
||||
]
|
||||
},
|
||||
{
|
||||
id: me.id,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory,
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.ManageThreads,
|
||||
PermissionFlagsBits.AttachFiles,
|
||||
PermissionFlagsBits.EmbedLinks
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
for (const roleId of supportRoleIds) {
|
||||
overwrites.push({
|
||||
id: roleId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory,
|
||||
PermissionFlagsBits.AttachFiles,
|
||||
PermissionFlagsBits.EmbedLinks,
|
||||
PermissionFlagsBits.ManageChannels
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return overwrites;
|
||||
}
|
||||
|
||||
async function fetchTicketMessages(
|
||||
context: BotContext,
|
||||
ticket: Ticket
|
||||
): Promise<Array<{ author: string; content: string; timestamp: string }>> {
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (!channelId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = await context.client.channels.fetch(channelId);
|
||||
if (!channel?.isTextBased()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const collected: Message[] = [];
|
||||
let lastId: string | undefined;
|
||||
while (collected.length < 500) {
|
||||
const batch = await channel.messages.fetch({ limit: 100, before: lastId });
|
||||
if (batch.size === 0) {
|
||||
break;
|
||||
}
|
||||
collected.push(...batch.values());
|
||||
lastId = batch.last()?.id;
|
||||
}
|
||||
|
||||
return collected
|
||||
.reverse()
|
||||
.map((message) => ({
|
||||
author: message.author.tag,
|
||||
content: message.content || `[${message.attachments.size} attachment(s)]`,
|
||||
timestamp: message.createdAt.toISOString()
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildRatingComponents(ticketId: string, locale: 'de' | 'en'): ActionRowBuilder<ButtonBuilder> {
|
||||
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||
for (let rating = 1; rating <= 5; rating += 1) {
|
||||
row.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(rateButtonId(ticketId, rating))
|
||||
.setLabel(String(rating))
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function createTicketChannel(
|
||||
context: BotContext,
|
||||
params: {
|
||||
guild: Guild;
|
||||
opener: GuildMember;
|
||||
category: TicketCategory;
|
||||
config: TicketConfig;
|
||||
parentChannelId?: string;
|
||||
subject?: string;
|
||||
formAnswers?: Record<string, string>;
|
||||
},
|
||||
locale: 'de' | 'en'
|
||||
): Promise<Ticket> {
|
||||
const existing = await findOpenTicketForUser(
|
||||
context.prisma,
|
||||
params.guild.id,
|
||||
params.opener.id
|
||||
);
|
||||
if (existing) {
|
||||
throw new TicketError('already_open');
|
||||
}
|
||||
|
||||
const mode = TicketModeSchema.parse(params.config.mode);
|
||||
const supportRoleIds = params.category.supportRoleIds;
|
||||
const channelName = `ticket-${params.opener.user.username}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.slice(0, 90);
|
||||
|
||||
let channelId: string | null = null;
|
||||
let threadId: string | null = null;
|
||||
|
||||
if (mode === 'THREAD') {
|
||||
const parentId = params.parentChannelId ?? params.config.categoryId;
|
||||
if (!parentId) {
|
||||
throw new TicketError('no_parent_channel');
|
||||
}
|
||||
const parent = await params.guild.channels.fetch(parentId);
|
||||
if (!parent?.isTextBased() || parent.isDMBased()) {
|
||||
throw new TicketError('invalid_parent_channel');
|
||||
}
|
||||
|
||||
const thread = await (parent as TextChannel).threads.create({
|
||||
name: channelName,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket opened by ${params.opener.user.tag}`
|
||||
});
|
||||
await thread.members.add(params.opener.id);
|
||||
for (const roleId of supportRoleIds) {
|
||||
const role = params.guild.roles.cache.get(roleId);
|
||||
if (role) {
|
||||
for (const [, member] of role.members) {
|
||||
await thread.members.add(member.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
threadId = thread.id;
|
||||
} else {
|
||||
const overwrites = await permissionOverwrites(
|
||||
params.guild,
|
||||
params.opener.id,
|
||||
supportRoleIds
|
||||
);
|
||||
const channel = await params.guild.channels.create({
|
||||
name: channelName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: params.config.categoryId ?? undefined,
|
||||
permissionOverwrites: overwrites,
|
||||
reason: `Ticket opened by ${params.opener.user.tag}`
|
||||
});
|
||||
channelId = channel.id;
|
||||
}
|
||||
|
||||
const ticket = await context.prisma.ticket.create({
|
||||
data: {
|
||||
guildId: params.guild.id,
|
||||
categoryId: params.category.id,
|
||||
openerId: params.opener.id,
|
||||
channelId,
|
||||
threadId,
|
||||
subject: params.subject ?? params.category.name,
|
||||
formAnswers: params.formAnswers ?? undefined,
|
||||
status: 'OPEN',
|
||||
lastActivityAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
const targetId = channelId ?? threadId!;
|
||||
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
|
||||
const introLines = [
|
||||
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
|
||||
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
|
||||
].filter(Boolean);
|
||||
|
||||
if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
|
||||
const answerLines = Object.entries(params.formAnswers).map(
|
||||
([key, value]) => `**${key}:** ${value}`
|
||||
);
|
||||
introLines.push(answerLines.join('\n'));
|
||||
}
|
||||
|
||||
await target.send({ content: introLines.join('\n') });
|
||||
return ticket;
|
||||
}
|
||||
|
||||
export async function claimTicket(
|
||||
context: BotContext,
|
||||
ticket: Ticket & { category: TicketCategory | null },
|
||||
member: GuildMember,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<Ticket> {
|
||||
await assertTicketStaff(member, ticket);
|
||||
if (ticket.status === 'CLOSED') {
|
||||
throw new TicketError('closed');
|
||||
}
|
||||
if (ticket.claimedById === member.id) {
|
||||
throw new TicketError('already_claimed');
|
||||
}
|
||||
|
||||
const updated = await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
claimedById: member.id,
|
||||
status: 'CLAIMED',
|
||||
lastActivityAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (channelId) {
|
||||
const channel = await fetchSendableChannel(context, channelId);
|
||||
if (channel) {
|
||||
await channel.send(tf(locale, 'ticket.claimed', { user: `<@${member.id}>` }));
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function addUserToTicket(
|
||||
context: BotContext,
|
||||
ticket: Ticket & { category: TicketCategory | null },
|
||||
member: GuildMember,
|
||||
target: GuildMember,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<void> {
|
||||
await assertTicketStaff(member, ticket);
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (!channelId) {
|
||||
throw new TicketError('no_channel');
|
||||
}
|
||||
|
||||
const channel = await context.client.channels.fetch(channelId);
|
||||
if (channel?.isThread()) {
|
||||
await channel.members.add(target.id);
|
||||
} else if (channel?.type === ChannelType.GuildText) {
|
||||
await channel.permissionOverwrites.edit(target.id, {
|
||||
ViewChannel: true,
|
||||
SendMessages: true,
|
||||
ReadMessageHistory: true,
|
||||
AttachFiles: true
|
||||
});
|
||||
}
|
||||
|
||||
await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { lastActivityAt: new Date() }
|
||||
});
|
||||
|
||||
if (channel?.isTextBased()) {
|
||||
const sendable = channel as SendableChannels;
|
||||
await sendable.send(tf(locale, 'ticket.userAdded', { user: `<@${target.id}>` }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUserFromTicket(
|
||||
context: BotContext,
|
||||
ticket: Ticket & { category: TicketCategory | null },
|
||||
member: GuildMember,
|
||||
target: GuildMember,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<void> {
|
||||
await assertTicketStaff(member, ticket);
|
||||
if (target.id === ticket.openerId) {
|
||||
throw new TicketError('cannot_remove_opener');
|
||||
}
|
||||
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (!channelId) {
|
||||
throw new TicketError('no_channel');
|
||||
}
|
||||
|
||||
const channel = await context.client.channels.fetch(channelId);
|
||||
if (channel?.isThread()) {
|
||||
await channel.members.remove(target.id);
|
||||
} else if (channel?.type === ChannelType.GuildText) {
|
||||
await channel.permissionOverwrites.delete(target.id);
|
||||
}
|
||||
|
||||
if (channel?.isTextBased()) {
|
||||
await (channel as SendableChannels).send(
|
||||
tf(locale, 'ticket.userRemoved', { user: `<@${target.id}>` })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameTicket(
|
||||
context: BotContext,
|
||||
ticket: Ticket & { category: TicketCategory | null },
|
||||
member: GuildMember,
|
||||
name: string,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<void> {
|
||||
await assertTicketStaff(member, ticket);
|
||||
const sanitized = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.slice(0, 90);
|
||||
if (!sanitized) {
|
||||
throw new TicketError('invalid_name');
|
||||
}
|
||||
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (!channelId) {
|
||||
throw new TicketError('no_channel');
|
||||
}
|
||||
|
||||
const channel = await context.client.channels.fetch(channelId);
|
||||
if (channel && 'setName' in channel) {
|
||||
await channel.setName(sanitized);
|
||||
}
|
||||
|
||||
await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { subject: name, lastActivityAt: new Date() }
|
||||
});
|
||||
|
||||
if (channel?.isTextBased()) {
|
||||
await (channel as SendableChannels).send(tf(locale, 'ticket.renamed', { name }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function setTicketPriority(
|
||||
context: BotContext,
|
||||
ticket: Ticket & { category: TicketCategory | null },
|
||||
member: GuildMember,
|
||||
priority: string,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<Ticket> {
|
||||
await assertTicketStaff(member, ticket);
|
||||
const parsed = TicketPrioritySchema.parse(priority);
|
||||
|
||||
const updated = await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { priority: parsed, lastActivityAt: new Date() }
|
||||
});
|
||||
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (channelId) {
|
||||
const channel = await fetchSendableChannel(context, channelId);
|
||||
if (channel) {
|
||||
await channel.send(
|
||||
tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function closeTicket(
|
||||
context: BotContext,
|
||||
ticket: Ticket & { category: TicketCategory | null },
|
||||
closedById: string,
|
||||
locale: 'de' | 'en',
|
||||
reason?: string
|
||||
): Promise<void> {
|
||||
if (ticket.status === 'CLOSED') {
|
||||
throw new TicketError('closed');
|
||||
}
|
||||
|
||||
const config = await getTicketConfig(context.prisma, ticket.guildId);
|
||||
const guild = await context.client.guilds.fetch(ticket.guildId);
|
||||
const opener = await context.client.users.fetch(ticket.openerId).catch(() => null);
|
||||
const messages = await fetchTicketMessages(context, ticket);
|
||||
const transcriptHtml = buildTicketTranscriptHtml({
|
||||
guildName: guild.name,
|
||||
ticketId: ticket.id,
|
||||
openerTag: opener?.tag ?? ticket.openerId,
|
||||
messages
|
||||
});
|
||||
|
||||
await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: 'CLOSED',
|
||||
closedAt: new Date(),
|
||||
transcriptHtml,
|
||||
lastActivityAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (channelId) {
|
||||
const channel = await fetchSendableChannel(context, channelId);
|
||||
if (channel) {
|
||||
const closeMessage = reason
|
||||
? tf(locale, 'ticket.closedWithReason', {
|
||||
user: `<@${closedById}>`,
|
||||
reason
|
||||
})
|
||||
: tf(locale, 'ticket.closed', { user: `<@${closedById}>` });
|
||||
await channel.send(closeMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.logChannelId) {
|
||||
try {
|
||||
const logChannel = (await context.client.channels.fetch(
|
||||
config.logChannelId
|
||||
)) as TextChannel;
|
||||
await logChannel.send({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setTitle(t(locale, 'ticket.log.closed'))
|
||||
.setColor(0x6366f1)
|
||||
.addFields(
|
||||
{ name: 'ID', value: ticket.id, inline: true },
|
||||
{ name: t(locale, 'ticket.log.opener'), value: `<@${ticket.openerId}>`, inline: true },
|
||||
{
|
||||
name: t(locale, 'ticket.log.closedBy'),
|
||||
value: `<@${closedById}>`,
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, ticketId: ticket.id }, 'Failed to post ticket close log');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.transcriptDm && opener) {
|
||||
try {
|
||||
await opener.send({
|
||||
content: t(locale, 'ticket.transcriptDm'),
|
||||
files: [{ attachment: Buffer.from(transcriptHtml, 'utf8'), name: `ticket-${ticket.id}.html` }]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, ticketId: ticket.id }, 'Failed to DM ticket transcript');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.ratingEnabled && opener) {
|
||||
try {
|
||||
await opener.send({
|
||||
content: t(locale, 'ticket.ratePrompt'),
|
||||
components: [buildRatingComponents(ticket.id, locale)]
|
||||
});
|
||||
} catch {
|
||||
// User may have DMs disabled
|
||||
}
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
try {
|
||||
const channel = await context.client.channels.fetch(channelId);
|
||||
if (channel?.isThread()) {
|
||||
await channel.setArchived(true);
|
||||
} else if (channel && 'delete' in channel) {
|
||||
await channel.delete(`Ticket ${ticket.id} closed`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function rateTicket(
|
||||
context: BotContext,
|
||||
ticketId: string,
|
||||
userId: string,
|
||||
rating: number,
|
||||
locale: 'de' | 'en'
|
||||
): Promise<void> {
|
||||
const ticket = await context.prisma.ticket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket || ticket.openerId !== userId) {
|
||||
throw new TicketError('not_found');
|
||||
}
|
||||
if (ticket.rating !== null) {
|
||||
throw new TicketError('already_rated');
|
||||
}
|
||||
|
||||
await context.prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: { rating }
|
||||
});
|
||||
}
|
||||
|
||||
export async function touchTicketActivity(
|
||||
context: BotContext,
|
||||
channelId: string
|
||||
): Promise<void> {
|
||||
const ticket = await context.prisma.ticket.findFirst({
|
||||
where: {
|
||||
OR: [{ channelId }, { threadId: channelId }],
|
||||
status: { in: ['OPEN', 'CLAIMED'] }
|
||||
}
|
||||
});
|
||||
if (!ticket) {
|
||||
return;
|
||||
}
|
||||
await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { lastActivityAt: new Date() }
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeInactiveTickets(context: BotContext): Promise<number> {
|
||||
const configs = await context.prisma.ticketConfig.findMany({
|
||||
where: { enabled: true }
|
||||
});
|
||||
|
||||
let closed = 0;
|
||||
for (const config of configs) {
|
||||
const cutoff = new Date(Date.now() - config.inactivityHours * 3_600_000);
|
||||
const stale = await context.prisma.ticket.findMany({
|
||||
where: {
|
||||
guildId: config.guildId,
|
||||
status: { in: ['OPEN', 'CLAIMED'] },
|
||||
lastActivityAt: { lt: cutoff }
|
||||
},
|
||||
include: { category: true }
|
||||
});
|
||||
|
||||
const locale = await getGuildLocale(context.prisma, config.guildId);
|
||||
for (const ticket of stale) {
|
||||
try {
|
||||
await closeTicket(context, ticket, context.client.user!.id, locale, t(locale, 'ticket.autoCloseReason'));
|
||||
closed += 1;
|
||||
} catch (error) {
|
||||
logger.warn({ error, ticketId: ticket.id }, 'Auto-close ticket failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closed;
|
||||
}
|
||||
|
||||
export const checkInactiveTickets = closeInactiveTickets;
|
||||
|
||||
export async function upsertCategoryByName(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
name: string,
|
||||
description?: string | null
|
||||
): Promise<TicketCategory> {
|
||||
await ensureGuild(context.prisma, guildId);
|
||||
return context.prisma.ticketCategory.upsert({
|
||||
where: { guildId_name: { guildId, name } },
|
||||
update: { description: description ?? undefined },
|
||||
create: { guildId, name, description: description ?? null }
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCategoryWithRole(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
name: string,
|
||||
supportRoleId: string
|
||||
): Promise<TicketCategory> {
|
||||
await ensureGuild(context.prisma, guildId);
|
||||
const existing = await context.prisma.ticketCategory.findUnique({
|
||||
where: { guildId_name: { guildId, name } }
|
||||
});
|
||||
if (existing) {
|
||||
const roleIds = existing.supportRoleIds.includes(supportRoleId)
|
||||
? existing.supportRoleIds
|
||||
: [...existing.supportRoleIds, supportRoleId];
|
||||
return context.prisma.ticketCategory.update({
|
||||
where: { id: existing.id },
|
||||
data: { supportRoleIds: roleIds }
|
||||
});
|
||||
}
|
||||
return context.prisma.ticketCategory.create({
|
||||
data: {
|
||||
guildId,
|
||||
name,
|
||||
supportRoleIds: [supportRoleId]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getGuildCategories(
|
||||
context: BotContext,
|
||||
guildId: string
|
||||
): Promise<TicketCategory[]> {
|
||||
return context.prisma.ticketCategory.findMany({
|
||||
where: { guildId },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function openTicketForCategory(
|
||||
context: BotContext,
|
||||
guild: Guild,
|
||||
member: GuildMember,
|
||||
categoryId: string,
|
||||
parentChannelId?: string,
|
||||
formAnswers?: Record<string, string>,
|
||||
subject?: string
|
||||
): Promise<Ticket> {
|
||||
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||
const config = await getTicketConfig(context.prisma, guild.id);
|
||||
if (!config.enabled) {
|
||||
throw new TicketError('disabled');
|
||||
}
|
||||
|
||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!category || category.guildId !== guild.id) {
|
||||
throw new TicketError('category_not_found');
|
||||
}
|
||||
|
||||
return createTicketChannel(
|
||||
context,
|
||||
{
|
||||
guild,
|
||||
opener: member,
|
||||
category,
|
||||
config,
|
||||
parentChannelId,
|
||||
subject,
|
||||
formAnswers
|
||||
},
|
||||
locale
|
||||
);
|
||||
}
|
||||
@@ -11,3 +11,9 @@ export const verificationQueueName = 'verification';
|
||||
export const verificationQueue = new Queue(verificationQueueName, { connection: redis });
|
||||
export const reminderQueueName = 'reminders';
|
||||
export const reminderQueue = new Queue(reminderQueueName, { connection: redis });
|
||||
export const giveawayQueueName = 'giveaways';
|
||||
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
|
||||
export const ticketQueueName = 'tickets';
|
||||
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
|
||||
export const birthdayQueueName = 'birthdays';
|
||||
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
|
||||
|
||||
Reference in New Issue
Block a user