diff --git a/apps/bot/prisma/migrations/20260724120000_bot_about/migration.sql b/apps/bot/prisma/migrations/20260724120000_bot_about/migration.sql new file mode 100644 index 0000000..703669f --- /dev/null +++ b/apps/bot/prisma/migrations/20260724120000_bot_about/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "BotAboutConfig" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "messageType" TEXT NOT NULL DEFAULT 'EMBED', + "content" TEXT, + "embed" JSONB, + "components" JSONB, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BotAboutConfig_pkey" PRIMARY KEY ("id") +); diff --git a/apps/bot/prisma/migrations/20260725160000_ticket_panel/migration.sql b/apps/bot/prisma/migrations/20260725160000_ticket_panel/migration.sql new file mode 100644 index 0000000..ea1a8ca --- /dev/null +++ b/apps/bot/prisma/migrations/20260725160000_ticket_panel/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "TicketConfig" ADD COLUMN "panelChannelId" TEXT; +ALTER TABLE "TicketConfig" ADD COLUMN "panelMessageId" TEXT; diff --git a/apps/bot/prisma/migrations/20260725170000_tag_cooldown/migration.sql b/apps/bot/prisma/migrations/20260725170000_tag_cooldown/migration.sql new file mode 100644 index 0000000..a311b4e --- /dev/null +++ b/apps/bot/prisma/migrations/20260725170000_tag_cooldown/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Tag" ADD COLUMN "cooldownSeconds" INTEGER NOT NULL DEFAULT 15; diff --git a/apps/bot/prisma/migrations/20260725180000_warning_number/migration.sql b/apps/bot/prisma/migrations/20260725180000_warning_number/migration.sql new file mode 100644 index 0000000..413be75 --- /dev/null +++ b/apps/bot/prisma/migrations/20260725180000_warning_number/migration.sql @@ -0,0 +1,16 @@ +-- AlterTable +ALTER TABLE "Warning" ADD COLUMN "warningNumber" INTEGER; + +-- Backfill per-guild sequential numbers (oldest first) +WITH numbered AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY "guildId" ORDER BY "createdAt" ASC, id ASC) AS rn + FROM "Warning" +) +UPDATE "Warning" AS w +SET "warningNumber" = numbered.rn +FROM numbered +WHERE w.id = numbered.id; + +ALTER TABLE "Warning" ALTER COLUMN "warningNumber" SET NOT NULL; + +CREATE UNIQUE INDEX "Warning_guildId_warningNumber_key" ON "Warning"("guildId", "warningNumber"); diff --git a/apps/bot/prisma/migrations/20260725190000_leave_announce_ban/migration.sql b/apps/bot/prisma/migrations/20260725190000_leave_announce_ban/migration.sql new file mode 100644 index 0000000..2c336c0 --- /dev/null +++ b/apps/bot/prisma/migrations/20260725190000_leave_announce_ban/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "WelcomeConfig" ADD COLUMN "leaveAnnounceBan" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 1e15fe8..6c46d2c 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -129,17 +129,19 @@ model Case { } model Warning { - id String @id @default(cuid()) - guildId String - userId String - moderatorId String - reason String? - caseId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull) + id String @id @default(cuid()) + warningNumber Int + guildId String + userId String + moderatorId String + reason String? + caseId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull) + @@unique([guildId, warningNumber]) @@index([guildId, userId]) } @@ -252,6 +254,8 @@ model WelcomeConfig { leaveContent String? leaveEmbed Json? leaveComponents Json? + /// When true, leave channel also posts a notice if the member was banned. + leaveAnnounceBan Boolean @default(false) welcomeDmEnabled Boolean @default(false) welcomeDmContent String? userAutoroleIds String[] @default([]) @@ -513,6 +517,8 @@ model TicketConfig { mode String @default("CHANNEL") categoryId String? logChannelId String? + panelChannelId String? + panelMessageId String? transcriptDm Boolean @default(false) inactivityHours Int @default(72) ratingEnabled Boolean @default(true) @@ -582,21 +588,22 @@ model SelfRolePanel { } model Tag { - id String @id @default(cuid()) - guildId String - name String - content String? - embed Json? - components Json? - responseType String @default("TEXT") - triggerWord String? - allowedRoleIds String[] @default([]) + id String @id @default(cuid()) + guildId String + name String + content String? + embed Json? + components Json? + responseType String @default("TEXT") + triggerWord String? + cooldownSeconds Int @default(15) + 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) + 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]) @@ -914,6 +921,18 @@ model BotPresenceConfig { updatedAt DateTime @updatedAt } +/// Global `/about` message (Owner panel). Singleton row id = "singleton". +model BotAboutConfig { + id String @id @default("singleton") + enabled Boolean @default(true) + /// TEXT | EMBED | COMPONENTS_V2 + messageType String @default("EMBED") + content String? + embed Json? + components Json? + updatedAt DateTime @updatedAt +} + model ChangelogEntry { id String @id @default(cuid()) version String diff --git a/apps/bot/src/command-gates.ts b/apps/bot/src/command-gates.ts index 4d9a010..678481a 100644 --- a/apps/bot/src/command-gates.ts +++ b/apps/bot/src/command-gates.ts @@ -84,9 +84,31 @@ export async function evaluateCommandGate( if (existing > 0) { return { ok: false, reason: 'cooldown', retryAfterSeconds: existing }; } - await redis.set(key, '1', 'EX', override.cooldownSeconds); } } return { ok: true }; } + +/** Sets the per-command cooldown after a successful execute. */ +export async function applyCommandCooldown( + interaction: ChatInputCommandInteraction, + context: BotContext +): Promise { + if (!interaction.guildId) { + return; + } + const override = await context.prisma.commandOverride.findUnique({ + where: { + guildId_commandName: { + guildId: interaction.guildId, + commandName: interaction.commandName + } + } + }); + if (!override?.cooldownSeconds || override.cooldownSeconds <= 0) { + return; + } + const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`; + await redis.set(key, '1', 'EX', override.cooldownSeconds); +} diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index e74e256..8789e91 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -8,7 +8,7 @@ import { t, tf, type Locale } from '@nexumi/shared'; import { env } from './env.js'; import { logger } from './logger.js'; import { getGuildLocale } from './i18n.js'; -import { evaluateCommandGate, type CommandGateReason } from './command-gates.js'; +import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js'; import { isGuildBlacklisted, isModuleEnabled, @@ -214,11 +214,17 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con moduleId ); if (!enabled) { - await interaction.reply({ - content: t(locale, 'generic.moduleDisabled'), - ephemeral: true - }); - return; + // Allow first-time setup while the module is still disabled (chicken-egg). + const sub = interaction.options.getSubcommand(false); + const isBootstrapSetup = + interaction.commandName === 'starboard' && sub === 'setup'; + if (!isBootstrapSetup) { + await interaction.reply({ + content: t(locale, 'generic.moduleDisabled'), + ephemeral: true + }); + return; + } } } @@ -232,6 +238,7 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con } await command.execute(interaction, context); + await applyCommandCooldown(interaction, context).catch(() => undefined); } catch (error) { ok = false; captureException(error, { @@ -270,6 +277,16 @@ export async function routeContextMenu( return; } + const maintenance = await isMaintenanceMode(context); + const ownerIds = env.OWNER_USER_IDS ?? []; + if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) { + await interaction.reply({ + content: maintenance.message?.trim() || t(locale, 'generic.maintenance'), + ephemeral: true + }); + return; + } + const command = contextMenuMap.get(interaction.commandName); if (!command) { await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 25e7e1b..dae601a 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -15,6 +15,7 @@ import { ensureRecurringJobs, startWorkers } from './jobs.js'; import type { BotContext } from './types.js'; import { startHealthServer } from './health.js'; import { getGuildLocale } from './i18n.js'; +import { ephemeral } from './interaction-reply.js'; import { t } from '@nexumi/shared'; import { handleAutoModMessage } from './modules/automod/actions.js'; import { registerLoggingEvents } from './modules/logging/events.js'; @@ -116,6 +117,21 @@ if (!isShard) { logger.error({ error }, 'Failed to ensure recurring jobs'); captureException(error, { phase: 'ensureRecurringJobs' }); } + + try { + await runOncePerCluster( + 'nexumi:lock:recover-overdue-giveaways', + 60, + async () => { + const { recoverOverdueGiveaways } = await import('./modules/giveaways/index.js'); + await recoverOverdueGiveaways(context); + }, + 'Overdue giveaway recovery' + ); + } catch (error) { + logger.error({ error }, 'Failed to recover overdue giveaways'); + captureException(error, { phase: 'recoverOverdueGiveaways' }); + } } try { @@ -207,14 +223,18 @@ if (!isShard) { interactionType: interaction.type, guildId: interaction.guildId }); - const locale = await getGuildLocale(context.prisma, interaction.guildId); - const message = t(locale, 'generic.error'); - if (interaction.isRepliable()) { - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ content: message, ephemeral: true }); - } else { - await interaction.reply({ content: message, ephemeral: true }); + try { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const message = t(locale, 'generic.error'); + if (interaction.isRepliable()) { + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: message, ...ephemeral() }); + } else { + await interaction.reply({ content: message, ...ephemeral() }); + } } + } catch (replyError) { + logger.warn({ replyError }, 'Failed to send interaction error reply'); } } }); diff --git a/apps/bot/src/interaction-registry.ts b/apps/bot/src/interaction-registry.ts index 9f8558f..a05fb40 100644 --- a/apps/bot/src/interaction-registry.ts +++ b/apps/bot/src/interaction-registry.ts @@ -7,6 +7,12 @@ import type { StringSelectMenuInteraction, UserSelectMenuInteraction } from 'discord.js'; +import { decodeComponentCustomId, t, type DashboardModuleId } from '@nexumi/shared'; +import { env } from './env.js'; +import { getGuildLocale } from './i18n.js'; +import { ephemeral } from './interaction-reply.js'; +import { isModuleEnabled, moduleForComponent } from './module-gates.js'; +import { isMaintenanceMode } from './presence.js'; import type { BotContext } from './types.js'; import { handleModerationConfirmation, @@ -139,11 +145,67 @@ const handlers: InteractionHandler[] = [ } ]; +function moduleForComponentsV2(customId: string): DashboardModuleId | null { + const decoded = decodeComponentCustomId(customId); + if (!decoded) { + return null; + } + switch (decoded.source) { + case 'w': + return 'welcome'; + case 't': + return 'tags'; + case 's': + return 'scheduler'; + case 'm': + return 'messages'; + default: + return 'messages'; + } +} + export async function routeComponentInteraction( interaction: AnyComponentInteraction, context: BotContext ): Promise { const customId = interaction.customId; + const locale = await getGuildLocale(context.prisma, interaction.guildId); + + const maintenance = await isMaintenanceMode(context); + const ownerIds = env.OWNER_USER_IDS ?? []; + if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: maintenance.message?.trim() || t(locale, 'generic.maintenance'), + ...ephemeral() + }); + } + return true; + } + + let moduleId = moduleForComponent(customId); + if (!moduleId && isComponentsV2Interaction(customId)) { + moduleId = moduleForComponentsV2(customId); + } + + if (moduleId && interaction.guildId) { + const enabled = await isModuleEnabled( + context.prisma, + context.redis, + interaction.guildId, + moduleId + ); + if (!enabled) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: t(locale, 'generic.moduleDisabled'), + ...ephemeral() + }); + } + return true; + } + } + for (const handler of handlers) { if (handler.match(customId)) { await handler.handle(interaction, context); diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index aeb429c..8f3525a 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -9,12 +9,27 @@ import type { BotContext } from './types.js'; import { env } from './env.js'; import { refreshPhishingDomains } from './modules/automod/filters.js'; import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js'; -import { completeVerification } from './modules/verification/handlers.js'; +import { + completeVerification, + runVerificationPanelSyncJob +} 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 { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js'; -import { closeInactiveTickets } from './modules/tickets/index.js'; +import { + expireSelfRole, + runSelfRolePanelCreateJob, + runSelfRolePanelUpdateJob, + runSelfRolePanelDeleteJob +} from './modules/selfroles/index.js'; +import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared'; +import { + endGiveaway, + runGiveawayCreateJob, + runGiveawayPauseJob, + runGiveawayDeleteJob, + GiveawayError +} from './modules/giveaways/index.js'; +import { closeInactiveTickets, runTicketDashboardActionJob, runTicketPanelSyncJob } from './modules/tickets/index.js'; import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { pollAllFeeds } from './modules/feeds/index.js'; @@ -42,6 +57,7 @@ import { retentionQueue, retentionQueueName, scheduleQueueName, + selfrolesQueueName, suggestionsQueueName, ticketQueue, ticketQueueName, @@ -71,6 +87,10 @@ type VerificationCompleteJob = { captchaProvider?: string | null; }; +type VerificationPanelSyncJob = { + guildId: string; +}; + type ReminderSendJob = { reminderId: string; }; @@ -87,6 +107,8 @@ type SelfRoleExpireJob = { type GiveawayEndJob = { giveawayId: string; + /** When true, end even if the giveaway is paused (dashboard/slash paths). */ + force?: boolean; }; type GiveawayCreateJob = { @@ -101,6 +123,15 @@ type GiveawayCreateJob = { requiredMemberDays?: number | null; }; +type GiveawayPauseJob = { + giveawayId: string; + paused: boolean; +}; + +type GiveawayDeleteJob = { + giveawayId: string; +}; + type BirthdayRoleExpireJob = { guildId: string; @@ -115,6 +146,36 @@ type SuggestionStatusUpdateJob = { reason: string; }; +type SelfRolePanelCreateJob = { + guildId: string; + channelId: string; + title: string; + description?: string | null; + mode: SelfRoleMode; + behavior: SelfRoleBehavior; + roles: SelfRoleEntry[]; + temporaryDurationMs?: number; + expiresAt?: string | null; +}; + +type SelfRolePanelUpdateJob = { + panelId: string; + guildId: string; + channelId?: string; + title?: string; + description?: string | null; + mode?: SelfRoleMode; + behavior?: SelfRoleBehavior; + roles?: SelfRoleEntry[]; + temporaryDurationMs?: number; + expiresAt?: string | null; +}; + +type SelfRolePanelDeleteJob = { + panelId: string; + guildId: string; +}; + export function startWorkers(context: BotContext): Worker[] { const moderationWorker = new Worker( moderationQueueName, @@ -243,16 +304,20 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'AutoMod job failed'); }); - const verificationWorker = new Worker( + const verificationWorker = new Worker( verificationQueueName, async (job) => { + if (job.name === 'verificationPanelSync') { + return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob); + } if (job.name !== 'verificationComplete') { return; } - await completeVerification(context, job.data.guildId, job.data.userId, { - ipHash: job.data.ipHash, - userAgentHash: job.data.userAgentHash, - captchaProvider: job.data.captchaProvider + const data = job.data as VerificationCompleteJob; + await completeVerification(context, data.guildId, data.userId, { + ipHash: data.ipHash, + userAgentHash: data.userAgentHash, + captchaProvider: data.captchaProvider }); }, { connection: redis } @@ -283,6 +348,22 @@ export function startWorkers(context: BotContext): Worker[] { const ticketWorker = new Worker( ticketQueueName, async (job) => { + if (job.name === 'ticketPanelSync') { + return runTicketPanelSyncJob(context, job.data as { guildId: string }); + } + if (job.name === 'ticketDashboardAction') { + return runTicketDashboardActionJob( + context, + job.data as { + ticketId: string; + guildId: string; + actorUserId: string; + action: 'close' | 'claim' | 'priority'; + reason?: string; + priority?: string; + } + ); + } if (job.name === 'selfRoleExpire') { const data = job.data as SelfRoleExpireJob; await expireSelfRole(context, data.guildId, data.userId, data.roleId); @@ -299,12 +380,47 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); }); - const giveawayWorker = new Worker( + const selfrolesWorker = new Worker< + SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob + >( + selfrolesQueueName, + async (job) => { + if (job.name === 'selfRolePanelCreate') { + return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob); + } + if (job.name === 'selfRolePanelUpdate') { + return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob); + } + if (job.name === 'selfRolePanelDelete') { + const data = job.data as SelfRolePanelDeleteJob; + await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId); + } + }, + { connection: redis } + ); + + selfrolesWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed'); + }); + + const giveawayWorker = new Worker< + GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob + >( giveawayQueueName, async (job) => { if (job.name === 'giveawayEnd') { try { - await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId); + const data = job.data as GiveawayEndJob; + if (!data.force) { + const current = await context.prisma.giveaway.findUnique({ + where: { id: data.giveawayId } + }); + // Timer/recover must not end a paused giveaway. + if (current?.paused) { + return; + } + } + await endGiveaway(context, data.giveawayId); } catch (error) { if ( error instanceof GiveawayError && @@ -319,6 +435,13 @@ export function startWorkers(context: BotContext): Worker[] { if (job.name === 'giveawayCreate') { return runGiveawayCreateJob(context, job.data as GiveawayCreateJob); } + if (job.name === 'giveawayPause') { + const data = job.data as GiveawayPauseJob; + return runGiveawayPauseJob(context, data.giveawayId, data.paused); + } + if (job.name === 'giveawayDelete') { + await runGiveawayDeleteJob(context, (job.data as GiveawayDeleteJob).giveawayId); + } }, { connection: redis } ); @@ -453,6 +576,7 @@ export function startWorkers(context: BotContext): Worker[] { verificationWorker, reminderWorker, ticketWorker, + selfrolesWorker, giveawayWorker, birthdayWorker, statsWorker, diff --git a/apps/bot/src/lib/embed-payload.ts b/apps/bot/src/lib/embed-payload.ts index 4160743..f5ac5c7 100644 --- a/apps/bot/src/lib/embed-payload.ts +++ b/apps/bot/src/lib/embed-payload.ts @@ -2,6 +2,7 @@ import { EmbedBuilder } from 'discord.js'; import { embedHasContent, mapEmbedTextFields, + type EmbedTextField, type WelcomeEmbed } from '@nexumi/shared'; @@ -18,8 +19,12 @@ function isHttpUrl(value: string | undefined): value is string { } export interface ApplyEmbedOptions { - /** Called for every text/URL field before applying to the builder. */ - renderText: (value: string) => string; + /** + * Called for every text/URL field before applying to the builder. + * `field` lets callers render `{user}` as a mention in the description + * but as a plain display name in title/author/footer. + */ + renderText: (value: string, field: EmbedTextField) => string; /** * Used when `thumbnailUrl` is unset (Welcome default: member avatar). * Pass `null` to force no thumbnail. diff --git a/apps/bot/src/lib/safe-remove-job.ts b/apps/bot/src/lib/safe-remove-job.ts new file mode 100644 index 0000000..2a65889 --- /dev/null +++ b/apps/bot/src/lib/safe-remove-job.ts @@ -0,0 +1,35 @@ +import type { Job } from 'bullmq'; +import { logger } from '../logger.js'; + +/** + * Removes a BullMQ job when present. Locked (active) jobs cannot be removed — + * those errors are swallowed so callers can still schedule a replacement jobId + * or rely on removeOnComplete after the worker finishes. + * Jobs that belong to a Job Scheduler must be removed via removeJobScheduler; + * direct remove is ignored here. + */ +export async function safeRemoveBullJob( + job: Job | null | undefined, + context?: { label?: string; jobId?: string } +): Promise { + if (!job) { + return; + } + try { + await job.remove(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes('locked') || + message.includes('could not be removed') || + message.includes('belongs to a job scheduler') + ) { + logger.warn( + { error, jobId: context?.jobId ?? job.id, label: context?.label }, + 'BullMQ job could not be removed directly; skipping' + ); + return; + } + throw error; + } +} diff --git a/apps/bot/src/module-gates.test.ts b/apps/bot/src/module-gates.test.ts index d68729f..8d1ea0d 100644 --- a/apps/bot/src/module-gates.test.ts +++ b/apps/bot/src/module-gates.test.ts @@ -20,6 +20,7 @@ describe('moduleForCommand', () => { it('leaves core commands unmapped', () => { expect(moduleForCommand('help')).toBeNull(); expect(moduleForCommand('info')).toBeNull(); + expect(moduleForCommand('about')).toBeNull(); expect(moduleForCommand('gdpr')).toBeNull(); }); }); diff --git a/apps/bot/src/module-gates.ts b/apps/bot/src/module-gates.ts index 672d65e..45b979e 100644 --- a/apps/bot/src/module-gates.ts +++ b/apps/bot/src/module-gates.ts @@ -92,6 +92,41 @@ export function moduleForCommand(commandName: string): DashboardModuleId | null return COMMAND_MODULE_MAP[commandName] ?? null; } +/** Component customId → dashboard module (null = no module gate). */ +export function moduleForComponent(customId: string): DashboardModuleId | null { + if (customId.startsWith('mod:confirm:') || customId.startsWith('mod:cancel:')) { + return 'moderation'; + } + if (customId.startsWith('verify:')) { + return 'verification'; + } + if (customId.startsWith('eco:bj:')) { + return 'economy'; + } + if (customId.startsWith('fun:')) { + return 'fun'; + } + if (customId.startsWith('gw:')) { + return 'giveaways'; + } + if (customId.startsWith('ticket:') || customId === 'ticket:select') { + return 'tickets'; + } + if (customId.startsWith('sr:')) { + return 'selfroles'; + } + if (customId.startsWith('sug:')) { + return 'suggestions'; + } + if (customId.startsWith('tv:')) { + return 'tempvoice'; + } + if (customId.startsWith('gbackup:')) { + return 'guildbackup'; + } + return null; +} + export async function isFeatureFlagEnabled( prisma: PrismaClient, key: string, diff --git a/apps/bot/src/modules/automod/actions.ts b/apps/bot/src/modules/automod/actions.ts index df2188d..0d533d2 100644 --- a/apps/bot/src/modules/automod/actions.ts +++ b/apps/bot/src/modules/automod/actions.ts @@ -1,6 +1,7 @@ import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js'; import type { BotContext } from '../../types.js'; -import { createCase } from '../moderation/service.js'; +import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js'; +import { getGuildLocale } from '../../i18n.js'; import { getAutoModConfig, getEnabledAutoModRules, @@ -41,15 +42,8 @@ async function applyAutoModAction( const baseReason = `AutoMod (${ruleType}): ${reason}`; if (action === 'WARN') { - await context.prisma.warning.create({ - data: { - guildId: guild.id, - userId: message.author.id, - moderatorId, - reason: baseReason - } - }); - await createCase(context, { + const locale = await getGuildLocale(context.prisma, guild.id); + const modCase = await createCase(context, { guildId: guild.id, targetUserId: message.author.id, moderatorId, @@ -59,6 +53,16 @@ async function applyAutoModAction( source: 'automod', metadata: { ruleType, automod: true } }); + await createWarning(context, { + guildId: guild.id, + userId: message.author.id, + moderatorId, + reason: baseReason, + caseId: modCase.id, + guildName: guild.name, + locale, + notifyUser: message.author + }); return; } @@ -81,6 +85,14 @@ async function applyAutoModAction( if (action === 'KICK') { if (me.permissions.has(PermissionFlagsBits.KickMembers)) { + const locale = await getGuildLocale(context.prisma, guild.id); + await notifyModerationTargetDm({ + user: message.author, + guildName: guild.name, + reason: baseReason, + locale, + action: 'kick' + }); await message.member.kick(baseReason); await createCase(context, { guildId: guild.id, @@ -98,6 +110,14 @@ async function applyAutoModAction( if (action === 'BAN') { if (me.permissions.has(PermissionFlagsBits.BanMembers)) { + const locale = await getGuildLocale(context.prisma, guild.id); + await notifyModerationTargetDm({ + user: message.author, + guildName: guild.name, + reason: baseReason, + locale, + action: 'ban' + }); await guild.members.ban(message.author.id, { reason: baseReason }); await createCase(context, { guildId: guild.id, diff --git a/apps/bot/src/modules/automod/command-definitions.ts b/apps/bot/src/modules/automod/command-definitions.ts index 7b5a77c..86c93bb 100644 --- a/apps/bot/src/modules/automod/command-definitions.ts +++ b/apps/bot/src/modules/automod/command-definitions.ts @@ -1,8 +1,11 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { applyCommandDescription } from '@nexumi/shared'; export const automodStatusCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('automod').addSubcommand((s) => + new SlashCommandBuilder() + .setName('automod') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((s) => applyCommandDescription(s.setName('status'), 'automod.status.description') ), 'automod.description' diff --git a/apps/bot/src/modules/automod/filters.ts b/apps/bot/src/modules/automod/filters.ts index 5b68fa3..d34a48a 100644 --- a/apps/bot/src/modules/automod/filters.ts +++ b/apps/bot/src/modules/automod/filters.ts @@ -1,6 +1,7 @@ import type { Redis } from 'ioredis'; import { capsRatio, + contentMatchesBlockedWord, countEmojis, extractUrls, hasZalgo, @@ -124,15 +125,14 @@ function checkExternalLink(content: string, threshold: AutoModThreshold): Filter } function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null { - const lower = content.toLowerCase(); for (const word of wordList.words) { - if (word.length > 0 && lower.includes(word.toLowerCase())) { + if (contentMatchesBlockedWord(content, word)) { return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' }; } } for (const pattern of wordList.regexPatterns) { try { - const regex = new RegExp(pattern, 'i'); + const regex = new RegExp(pattern, 'iu'); if (regex.test(content)) { return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' }; } diff --git a/apps/bot/src/modules/automod/service.ts b/apps/bot/src/modules/automod/service.ts index 1600281..9658ad4 100644 --- a/apps/bot/src/modules/automod/service.ts +++ b/apps/bot/src/modules/automod/service.ts @@ -1,6 +1,8 @@ -import type { PrismaClient } from '@prisma/client'; +import type { Prisma, PrismaClient } from '@prisma/client'; import { DEFAULT_AUTOMOD_RULES, + defaultAutoModWordList, + shouldSeedDefaultBlockedWords, type AutoModExceptions, type AutoModThreshold, type AutoModWordList @@ -16,6 +18,26 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) { return config; } +async function seedDefaultWordFilterIfNeeded(prisma: PrismaClient, guildId: string) { + const rule = await prisma.autoModRule.findUnique({ + where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } }, + select: { id: true, wordList: true } + }); + if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) { + return; + } + const existing = parseWordList(rule.wordList); + await prisma.autoModRule.update({ + where: { id: rule.id }, + data: { + wordList: { + ...defaultAutoModWordList(), + regexPatterns: existing.regexPatterns + } as Prisma.InputJsonValue + } + }); +} + export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) { await ensureGuild(prisma, guildId); const existing = await prisma.autoModRule.findMany({ @@ -24,19 +46,19 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s }); const have = new Set(existing.map((row) => row.ruleType)); const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType)); - if (missing.length === 0) { - return; + if (missing.length > 0) { + await prisma.autoModRule.createMany({ + data: missing.map((rule) => ({ + guildId, + ruleType: rule.ruleType, + action: rule.action, + threshold: rule.threshold ?? undefined, + wordList: rule.wordList ?? undefined, + enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType) + })) + }); } - await prisma.autoModRule.createMany({ - data: missing.map((rule) => ({ - guildId, - ruleType: rule.ruleType, - action: rule.action, - threshold: rule.threshold ?? undefined, - wordList: rule.wordList ?? undefined, - enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType) - })) - }); + await seedDefaultWordFilterIfNeeded(prisma, guildId); } export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) { @@ -71,7 +93,8 @@ export function parseWordList(raw: unknown): AutoModWordList { const obj = raw as Record; return { words: Array.isArray(obj.words) ? obj.words.map(String) : [], - regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [] + regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [], + ...(obj.userCleared === true ? { userCleared: true } : {}) }; } diff --git a/apps/bot/src/modules/components-v2/interactions.ts b/apps/bot/src/modules/components-v2/interactions.ts index 6df750d..21f65ca 100644 --- a/apps/bot/src/modules/components-v2/interactions.ts +++ b/apps/bot/src/modules/components-v2/interactions.ts @@ -67,6 +67,13 @@ async function loadPayload( } return parseMessageComponentsV2(binding.payload); } + case 'a': { + if (ref !== 'singleton') { + return null; + } + const config = await context.prisma.botAboutConfig.findUnique({ where: { id: 'singleton' } }); + return parseMessageComponentsV2(config?.components); + } default: return null; } diff --git a/apps/bot/src/modules/core/about.ts b/apps/bot/src/modules/core/about.ts new file mode 100644 index 0000000..7eaefdc --- /dev/null +++ b/apps/bot/src/modules/core/about.ts @@ -0,0 +1,98 @@ +import { + ABOUT_REDIS_KEY, + BotAboutConfigSchema, + expandBracketChannelMentions, + parseMessageComponentsV2, + t, + type BotAboutConfig, + type Locale, + type WelcomeEmbed +} from '@nexumi/shared'; +import type { InteractionReplyOptions, MessageCreateOptions } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; +import { redis } from '../../redis.js'; +import type { BotContext } from '../../types.js'; + +const NEXUMI_COLOR = 0x6366f1; + +function parseEmbed(raw: unknown): WelcomeEmbed | null { + if (!raw || typeof raw !== 'object') { + return null; + } + return raw as WelcomeEmbed; +} + +export async function readAboutConfig(context: BotContext): Promise { + const cached = await redis.get(ABOUT_REDIS_KEY); + if (cached) { + const parsed = BotAboutConfigSchema.safeParse(JSON.parse(cached)); + if (parsed.success) { + return parsed.data; + } + } + + const row = await context.prisma.botAboutConfig.upsert({ + where: { id: 'singleton' }, + create: { id: 'singleton' }, + update: {} + }); + const config = BotAboutConfigSchema.parse({ + enabled: row.enabled, + messageType: row.messageType, + content: row.content, + embed: parseEmbed(row.embed), + components: parseMessageComponentsV2(row.components) + }); + await redis.set(ABOUT_REDIS_KEY, JSON.stringify(config)); + return config; +} + +function defaultAboutReply(locale: Locale): InteractionReplyOptions { + return { + embeds: [ + new EmbedBuilder() + .setTitle(t(locale, 'core.about.defaultTitle')) + .setDescription(t(locale, 'core.about.defaultBody')) + .setColor(NEXUMI_COLOR) + .setFooter({ text: 'Nexumi' }) + ] + }; +} + +export async function buildAboutReply( + context: BotContext, + locale: Locale +): Promise { + const config = await readAboutConfig(context); + if (!config.enabled) { + return { content: t(locale, 'core.about.disabled'), ephemeral: true }; + } + + const renderText = (value: string) => expandBracketChannelMentions(value); + + if (config.messageType === 'COMPONENTS_V2') { + const payload = buildComponentsV2Payload(config.components, { + source: 'a', + ref: 'singleton', + renderText + }); + if (payload) { + return payload; + } + } + + if (config.messageType === 'EMBED') { + const embed = buildEmbedFromPayload(config.embed, { renderText }); + if (embed) { + return { embeds: [embed] }; + } + } + + if (config.messageType === 'TEXT' && config.content?.trim()) { + return { content: renderText(config.content) }; + } + + return defaultAboutReply(locale); +} diff --git a/apps/bot/src/modules/core/command-definitions.ts b/apps/bot/src/modules/core/command-definitions.ts index 7901d59..b98ad98 100644 --- a/apps/bot/src/modules/core/command-definitions.ts +++ b/apps/bot/src/modules/core/command-definitions.ts @@ -13,6 +13,11 @@ export const infoCommandData = applyCommandDescription( 'core.info.description' ); +export const aboutCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('about'), + 'core.about.description' +); + export const inviteCommandData = applyCommandDescription( new SlashCommandBuilder().setName('invite'), 'core.invite.description' diff --git a/apps/bot/src/modules/core/commands.ts b/apps/bot/src/modules/core/commands.ts index 3bd9e52..20ebe49 100644 --- a/apps/bot/src/modules/core/commands.ts +++ b/apps/bot/src/modules/core/commands.ts @@ -6,10 +6,12 @@ import { buildBotInviteUrl, env } from '../../env.js'; import { helpCommandData, infoCommandData, + aboutCommandData, inviteCommandData, supportCommandData } from './command-definitions.js'; import { HELP_MODULES } from './help-catalog.js'; +import { buildAboutReply } from './about.js'; const NEXUMI_COLOR = 0x6366f1; const PACKAGE_VERSION = '0.1.0'; @@ -118,6 +120,15 @@ const infoCommand: SlashCommand = { } }; +const aboutCommand: SlashCommand = { + data: aboutCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const reply = await buildAboutReply(context, locale); + await interaction.reply(reply as import('discord.js').InteractionReplyOptions); + } +}; + const inviteCommand: SlashCommand = { data: inviteCommandData, async execute(interaction, context) { @@ -153,6 +164,7 @@ const supportCommand: SlashCommand = { export const coreCommands: SlashCommand[] = [ helpCommand, infoCommand, + aboutCommand, inviteCommand, supportCommand ]; diff --git a/apps/bot/src/modules/core/help-catalog.ts b/apps/bot/src/modules/core/help-catalog.ts index 85bf0cb..17e1b0a 100644 --- a/apps/bot/src/modules/core/help-catalog.ts +++ b/apps/bot/src/modules/core/help-catalog.ts @@ -1,6 +1,6 @@ /** Module id → top-level slash command names for `/help`. */ export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [ - { id: 'core', commands: ['help', 'info', 'invite', 'support', 'privacy', 'gdpr'] }, + { id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] }, { id: 'moderation', commands: [ diff --git a/apps/bot/src/modules/fun/commands.ts b/apps/bot/src/modules/fun/commands.ts index 8f6e062..1dc3dd8 100644 --- a/apps/bot/src/modules/fun/commands.ts +++ b/apps/bot/src/modules/fun/commands.ts @@ -327,7 +327,12 @@ async function handleMediaError( ): Promise { if (error instanceof FunMediaError) { const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed'; - await interaction.reply({ content: t(locale, key), ephemeral: true }); + const content = t(locale, key); + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ content }); + return; + } + await interaction.reply({ content, ephemeral: true }); return; } throw error; @@ -342,12 +347,13 @@ const memeCommand: SlashCommand = { try { await assertMediaEnabled(context.redis, guildId); + await interaction.deferReply(); const meme = await fetchMeme(); const embed = new EmbedBuilder() .setTitle(meme.title) .setImage(meme.url) .setColor(0x6366f1); - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); } catch (error) { await handleMediaError(interaction, locale, error); } @@ -363,12 +369,13 @@ const catCommand: SlashCommand = { try { await assertMediaEnabled(context.redis, guildId); + await interaction.deferReply(); const url = await fetchCatImage(); const embed = new EmbedBuilder() .setTitle(t(locale, 'fun.cat.title')) .setImage(url) .setColor(0x6366f1); - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); } catch (error) { await handleMediaError(interaction, locale, error); } @@ -384,12 +391,13 @@ const dogCommand: SlashCommand = { try { await assertMediaEnabled(context.redis, guildId); + await interaction.deferReply(); const url = await fetchDogImage(); const embed = new EmbedBuilder() .setTitle(t(locale, 'fun.dog.title')) .setImage(url) .setColor(0x6366f1); - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); } catch (error) { await handleMediaError(interaction, locale, error); } diff --git a/apps/bot/src/modules/giveaways/command-definitions.ts b/apps/bot/src/modules/giveaways/command-definitions.ts index 5989d34..460d6fa 100644 --- a/apps/bot/src/modules/giveaways/command-definitions.ts +++ b/apps/bot/src/modules/giveaways/command-definitions.ts @@ -1,9 +1,10 @@ import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; -import { SlashCommandBuilder } from 'discord.js'; +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; export const giveawayCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('giveaway') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((s) => applyCommandDescription(s.setName('start'), 'giveaway.start.description') .addStringOption((o) => diff --git a/apps/bot/src/modules/giveaways/commands.ts b/apps/bot/src/modules/giveaways/commands.ts index bd28e59..0e7cb59 100644 --- a/apps/bot/src/modules/giveaways/commands.ts +++ b/apps/bot/src/modules/giveaways/commands.ts @@ -6,6 +6,7 @@ import { requirePermission } from '../../permissions.js'; import { parseDuration } from '../moderation/duration.js'; import { giveawayCommandData } from './command-definitions.js'; import { + cancelGiveawayJob, deleteGiveaway, endGiveaway, GiveawayError, @@ -26,6 +27,17 @@ async function ensureManageGuild( return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale); } +async function replyEphemeral( + interaction: Parameters[0], + content: string +): Promise { + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ content }); + return; + } + await interaction.reply({ content, ephemeral: true }); +} + const giveawayCommand: SlashCommand = { data: giveawayCommandData, async execute(interaction, context) { @@ -71,6 +83,8 @@ const giveawayCommand: SlashCommand = { return; } + await interaction.deferReply({ ephemeral: true }); + const giveaway = await startGiveaway( context, { @@ -87,9 +101,8 @@ const giveawayCommand: SlashCommand = { locale ); - await interaction.reply({ - content: tf(locale, 'giveaway.started', { id: giveaway.id }), - ephemeral: true + await interaction.editReply({ + content: tf(locale, 'giveaway.started', { id: giveaway.id }) }); return; } @@ -100,20 +113,30 @@ const giveawayCommand: SlashCommand = { try { if (sub === 'end') { - const id = interaction.options.getString('id', true); + const id = interaction.options.getString('id', true).trim(); + const giveaway = await context.prisma.giveaway.findUnique({ where: { id } }); + if (!giveaway || giveaway.guildId !== interaction.guildId) { + throw new GiveawayError('not_found'); + } + await interaction.deferReply({ ephemeral: true }); + await cancelGiveawayJob(id); await endGiveaway(context, id); - await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true }); + await interaction.editReply({ content: t(locale, 'giveaway.ended') }); return; } if (sub === 'reroll') { - const id = interaction.options.getString('id', true); + const id = interaction.options.getString('id', true).trim(); + const existing = await context.prisma.giveaway.findUnique({ where: { id } }); + if (!existing || existing.guildId !== interaction.guildId) { + throw new GiveawayError('not_found'); + } + await interaction.deferReply({ ephemeral: true }); const updated = await rerollGiveaway(context, id, locale); - await interaction.reply({ + await interaction.editReply({ content: tf(locale, 'giveaway.rerolled', { winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—' - }), - ephemeral: true + }) }); return; } @@ -136,14 +159,22 @@ const giveawayCommand: SlashCommand = { } if (sub === 'delete') { - const id = interaction.options.getString('id', true); + const id = interaction.options.getString('id', true).trim(); + const existing = await context.prisma.giveaway.findUnique({ where: { id } }); + if (!existing || existing.guildId !== interaction.guildId) { + throw new GiveawayError('not_found'); + } 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 id = interaction.options.getString('id', true).trim(); + const existing = await context.prisma.giveaway.findUnique({ where: { id } }); + if (!existing || existing.guildId !== interaction.guildId) { + throw new GiveawayError('not_found'); + } const updated = await pauseGiveaway(context, id, locale); await interaction.reply({ content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'), @@ -152,10 +183,7 @@ const giveawayCommand: SlashCommand = { } } catch (error) { if (error instanceof GiveawayError) { - await interaction.reply({ - content: t(locale, `giveaway.error.${error.code}`), - ephemeral: true - }); + await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`)); return; } throw error; diff --git a/apps/bot/src/modules/giveaways/index.ts b/apps/bot/src/modules/giveaways/index.ts index 6423d4d..747778c 100644 --- a/apps/bot/src/modules/giveaways/index.ts +++ b/apps/bot/src/modules/giveaways/index.ts @@ -1,3 +1,11 @@ export { giveawayCommands } from './commands.js'; export { isGiveawayButton, handleGiveawayButton } from './buttons.js'; -export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js'; +export { + endGiveaway, + scheduleGiveawayEnd, + runGiveawayCreateJob, + runGiveawayPauseJob, + runGiveawayDeleteJob, + recoverOverdueGiveaways, + GiveawayError +} from './service.js'; diff --git a/apps/bot/src/modules/giveaways/service.ts b/apps/bot/src/modules/giveaways/service.ts index 449bd0a..b60668b 100644 --- a/apps/bot/src/modules/giveaways/service.ts +++ b/apps/bot/src/modules/giveaways/service.ts @@ -22,25 +22,46 @@ export class GiveawayError extends Error { } } +export function giveawayEndJobId(giveawayId: string): string { + return `giveaway-end-${giveawayId}`; +} + +/** + * Removes a scheduled/failed end job if present. Locked (active) jobs cannot be + * removed — callers must not invoke this from inside the giveawayEnd worker. + */ +export async function cancelGiveawayJob(giveawayId: string): Promise { + const job = await giveawayQueue.getJob(giveawayEndJobId(giveawayId)); + if (!job) { + return; + } + try { + await job.remove(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('locked') || message.includes('could not be removed')) { + logger.warn({ giveawayId, error }, 'Giveaway end job is locked; skipping remove'); + return; + } + throw error; + } +} + export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise { + await cancelGiveawayJob(giveawayId); const delay = Math.max(0, endsAt.getTime() - Date.now()); await giveawayQueue.add( 'giveawayEnd', { giveawayId }, { delay, - jobId: `giveaway-end-${giveawayId}`, + jobId: giveawayEndJobId(giveawayId), removeOnComplete: true, removeOnFail: 50 } ); } -export async function cancelGiveawayJob(giveawayId: string): Promise { - 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) { @@ -316,6 +337,15 @@ async function dmWinners( } } +/** + * Draws winners and marks the giveaway ended. Does **not** cancel BullMQ jobs — + * the slash-command / dashboard paths cancel the delayed job first; the + * `giveawayEnd` worker must never remove its own locked job (that threw and + * left giveaways stuck with the join button still visible). + * + * Uses a conditional update so concurrent ends (timer + slash + recover) + * cannot each write different winners. + */ export async function endGiveaway(context: BotContext, giveawayId: string): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); if (!giveaway) { @@ -325,21 +355,31 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom throw new GiveawayError('already_ended'); } - 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 endedAt = new Date(); - const updated = await context.prisma.giveaway.update({ - where: { id: giveawayId }, + const claimed = await context.prisma.giveaway.updateMany({ + where: { id: giveawayId, endedAt: null }, data: { - endedAt: new Date(), + endedAt, winners, paused: false } }); + if (claimed.count === 0) { + const current = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); + if (!current) { + throw new GiveawayError('not_found'); + } + throw new GiveawayError('already_ended'); + } + + const updated = await context.prisma.giveaway.findUniqueOrThrow({ where: { id: giveawayId } }); + await updateGiveawayMessage(context, updated, locale); if (winners.length > 0) { await dmWinners(context, updated, winners, locale); @@ -347,6 +387,41 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom return updated; } +/** + * Ends giveaways whose `endsAt` has passed but `endedAt` is still null + * (e.g. after a failed/locked BullMQ job). Skips paused giveaways. + */ +export async function recoverOverdueGiveaways(context: BotContext): Promise { + const overdue = await context.prisma.giveaway.findMany({ + where: { + endedAt: null, + paused: false, + endsAt: { lte: new Date() } + }, + take: 100, + orderBy: { endsAt: 'asc' } + }); + + let recovered = 0; + for (const giveaway of overdue) { + try { + await cancelGiveawayJob(giveaway.id); + await endGiveaway(context, giveaway.id); + recovered += 1; + } catch (error) { + if (error instanceof GiveawayError && error.code === 'already_ended') { + continue; + } + logger.error({ error, giveawayId: giveaway.id }, 'Failed to recover overdue giveaway'); + } + } + + if (recovered > 0) { + logger.info({ recovered }, 'Recovered overdue giveaways'); + } + return recovered; +} + export async function rerollGiveaway( context: BotContext, giveawayId: string, @@ -378,22 +453,61 @@ export async function rerollGiveaway( export async function pauseGiveaway( context: BotContext, giveawayId: string, - locale: 'de' | 'en' + locale: 'de' | 'en', + forcePaused?: boolean ): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); if (!giveaway || giveaway.endedAt) { throw new GiveawayError('not_found'); } + const nextPaused = forcePaused ?? !giveaway.paused; + if (nextPaused === giveaway.paused) { + await updateGiveawayMessage(context, giveaway, locale); + return giveaway; + } + + if (nextPaused) { + // Stop the end timer while paused; remaining wall-clock is preserved via endsAt. + await cancelGiveawayJob(giveawayId); + } + const updated = await context.prisma.giveaway.update({ where: { id: giveawayId }, - data: { paused: !giveaway.paused } + data: { paused: nextPaused } }); + if (!nextPaused) { + // Resume: re-schedule with delay based on remaining time until endsAt + // (delay 0 if endsAt already passed while paused → ends immediately). + await scheduleGiveawayEnd(updated.id, updated.endsAt); + } + await updateGiveawayMessage(context, updated, locale); return updated; } +/** + * Dashboard entry points — same Discord side-effects as slash commands. + */ +export async function runGiveawayPauseJob( + context: BotContext, + giveawayId: string, + paused: boolean +): Promise<{ id: string; paused: boolean }> { + const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); + if (!giveaway) { + throw new GiveawayError('not_found'); + } + const locale = await getGuildLocale(context.prisma, giveaway.guildId); + const updated = await pauseGiveaway(context, giveawayId, locale, paused); + return { id: updated.id, paused: updated.paused }; +} + +export async function runGiveawayDeleteJob(context: BotContext, giveawayId: string): Promise { + await deleteGiveaway(context, giveawayId); +} + export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); if (!giveaway) { diff --git a/apps/bot/src/modules/guildbackup/command-definitions.ts b/apps/bot/src/modules/guildbackup/command-definitions.ts index 36edc34..0d972fa 100644 --- a/apps/bot/src/modules/guildbackup/command-definitions.ts +++ b/apps/bot/src/modules/guildbackup/command-definitions.ts @@ -1,9 +1,10 @@ import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; -import { SlashCommandBuilder } from 'discord.js'; +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; export const backupCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('backup') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((s) => applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption( (o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name') diff --git a/apps/bot/src/modules/guildbackup/commands.ts b/apps/bot/src/modules/guildbackup/commands.ts index 633e8e6..c3ab746 100644 --- a/apps/bot/src/modules/guildbackup/commands.ts +++ b/apps/bot/src/modules/guildbackup/commands.ts @@ -55,17 +55,17 @@ const backupCommand: SlashCommand = { return; } + await interaction.deferReply({ ephemeral: true }); + try { const backup = await createGuildBackup(context, guild, name, interaction.user.id); - await interaction.reply({ - content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }), - ephemeral: true + await interaction.editReply({ + content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }) }); } catch (error) { if (error instanceof GuildBackupError) { - await interaction.reply({ - content: t(locale, `guildbackup.error.${error.code}`), - ephemeral: true + await interaction.editReply({ + content: t(locale, `guildbackup.error.${error.code}`) }); return; } @@ -75,6 +75,9 @@ const backupCommand: SlashCommand = { } if (sub === 'list') { + if (!(await ensureManageGuild(interaction, locale))) { + return; + } const backups = await listGuildBackups(context, guild.id); if (backups.length === 0) { await interaction.reply({ @@ -100,6 +103,9 @@ const backupCommand: SlashCommand = { } if (sub === 'info') { + if (!(await ensureManageGuild(interaction, locale))) { + return; + } const backupId = interaction.options.getString('id', true); const backup = await getGuildBackup(context, guild.id, backupId); if (!backup) { diff --git a/apps/bot/src/modules/logging/events.ts b/apps/bot/src/modules/logging/events.ts index 448cab6..26630f0 100644 --- a/apps/bot/src/modules/logging/events.ts +++ b/apps/bot/src/modules/logging/events.ts @@ -10,6 +10,9 @@ import type { LogEventType } from '@nexumi/shared'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; +/** Audit-log entries can lag a moment behind the gateway event. */ +const AUDIT_LOG_MAX_AGE_MS = 15_000; + export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) { await ensureGuild(prisma, guildId); let config = await prisma.loggingConfig.findUnique({ where: { guildId } }); @@ -19,6 +22,36 @@ export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: st return config; } +/** + * Discord does not send ban/kick reasons over the gateway. + * Reasons must be read from the audit log (requires View Audit Log). + */ +async function fetchRecentAuditEntry( + guild: Guild, + type: AuditLogEvent, + targetId: string +): Promise<{ reason: string | null; executorId: string | null } | null> { + try { + const auditLogs = await guild.fetchAuditLogs({ type, limit: 5 }); + const entry = auditLogs.entries.find((item) => { + if (item.targetId !== targetId) { + return false; + } + return Date.now() - item.createdTimestamp <= AUDIT_LOG_MAX_AGE_MS; + }); + if (!entry) { + return null; + } + return { + reason: entry.reason ?? null, + executorId: entry.executorId ?? entry.executor?.id ?? null + }; + } catch (error) { + logger.warn({ error, guildId: guild.id, type }, 'Failed to fetch audit log for logging'); + return null; + } +} + export async function getLogChannelId( prisma: BotContext['prisma'], guildId: string, @@ -124,6 +157,45 @@ export async function logModAction( await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444); } +export async function logVerificationFail( + context: BotContext, + input: { + guildId: string; + userId: string; + userTag?: string; + reason: string; + detail?: string; + failAction?: string; + matchedUserId?: string | null; + } +): Promise { + const guild = await context.client.guilds.fetch(input.guildId).catch(() => null); + if (!guild) { + return; + } + const lines = [ + `**User:** ${input.userTag ? `${input.userTag} ` : ''}(<@${input.userId}>) (\`${input.userId}\`)`, + `**Reason:** ${input.reason}` + ]; + if (input.detail) { + lines.push(`**Detail:** ${input.detail}`); + } + if (input.matchedUserId) { + lines.push(`**Matched user:** <@${input.matchedUserId}> (\`${input.matchedUserId}\`)`); + } + if (input.failAction) { + lines.push(`**Fail action:** ${input.failAction}`); + } + await sendLogEmbed( + context, + guild, + 'VERIFICATION_FAIL', + 'Verification Failed', + lines.join('\n'), + 0xf97316 + ); +} + export async function handleChannelDeleteForAntiNuke( context: BotContext, guild: Guild, @@ -253,6 +325,29 @@ export async function registerLoggingEvents(context: BotContext): Promise if (memberIgnored(config, member as GuildMember, member.user.bot)) { return; } + + const kickEntry = await fetchRecentAuditEntry( + member.guild, + AuditLogEvent.MemberKick, + member.id + ); + + if (kickEntry) { + await sendLogEmbed( + context, + member.guild, + 'MEMBER_LEAVE', + 'Member Kicked', + [ + `**User:** ${member.user.tag} (<@${member.id}>)`, + `**Moderator:** ${kickEntry.executorId ? `<@${kickEntry.executorId}>` : '—'}`, + `**Reason:** ${kickEntry.reason ?? '—'}` + ].join('\n'), + 0xf59e0b + ); + return; + } + await sendLogEmbed( context, member.guild, @@ -265,12 +360,19 @@ export async function registerLoggingEvents(context: BotContext): Promise client.on('guildBanAdd', async (ban) => { const guild = ban.guild; await handleBanForAntiNuke(context, guild); + + const audit = await fetchRecentAuditEntry(guild, AuditLogEvent.MemberBanAdd, ban.user.id); + const reason = audit?.reason ?? ban.reason ?? '—'; + const executorLine = audit?.executorId + ? `\n**Moderator:** <@${audit.executorId}>` + : ''; + await sendLogEmbed( context, guild, 'MEMBER_BAN', 'Member Banned', - `**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}` + `**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${reason}` ); }); diff --git a/apps/bot/src/modules/moderation/actions.ts b/apps/bot/src/modules/moderation/actions.ts index 1210990..6b4fe13 100644 --- a/apps/bot/src/modules/moderation/actions.ts +++ b/apps/bot/src/modules/moderation/actions.ts @@ -2,7 +2,7 @@ import { TextChannel, type ButtonInteraction } from 'discord.js'; import { type Locale, t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { assertBannableForConfirm } from '../../hierarchy.js'; -import { createCase, scheduleTempBanExpire } from './service.js'; +import { createCase, notifyModerationTargetDm, scheduleTempBanExpire } from './service.js'; import { tryParseDuration } from './duration.js'; type BanPayload = { @@ -53,6 +53,20 @@ export async function executeBan( } } + await interaction.deferUpdate(); + + const targetUser = await context.client.users.fetch(payload.userId).catch(() => null); + if (targetUser) { + await notifyModerationTargetDm({ + user: targetUser, + guildName: guild.name, + reason: payload.reason, + locale, + action: 'ban', + duration: payload.duration + }); + } + await guild.members.ban(payload.userId, { reason: payload.reason }); const modCase = await createCase(context, { guildId: guild.id, @@ -73,8 +87,11 @@ export async function executeBan( await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason); } - await interaction.update({ - content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }), + await interaction.editReply({ + content: tf(locale, 'moderation.success.caseWithReason', { + caseNumber: modCase.caseNumber, + reason: payload.reason + }), components: [] }); } @@ -91,6 +108,8 @@ export async function executePurge( return; } + await interaction.deferUpdate(); + const channel = await guild.channels.fetch(payload.channelId); let deletedCount = 0; const regex = payload.regex ? new RegExp(payload.regex, 'i') : null; @@ -134,7 +153,7 @@ export async function executePurge( } }); - await interaction.update({ + await interaction.editReply({ content: tf(locale, 'moderation.purge.done', { deletedCount, caseNumber: modCase.caseNumber diff --git a/apps/bot/src/modules/moderation/command-definitions.ts b/apps/bot/src/modules/moderation/command-definitions.ts index 1122075..6b5efd0 100644 --- a/apps/bot/src/modules/moderation/command-definitions.ts +++ b/apps/bot/src/modules/moderation/command-definitions.ts @@ -96,9 +96,12 @@ export const warnCommandData = applyCommandDescription( ) ) .addSubcommand((s) => - applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addStringOption( + applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addIntegerOption( (o) => - applyOptionDescription(o.setName('warning_id').setRequired(true), 'moderation.warn.options.warning_id') + applyOptionDescription( + o.setName('id').setRequired(true).setMinValue(1), + 'moderation.warn.options.warning_id' + ) ) ) .addSubcommand((s) => diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index c42cc21..3f0061b 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -8,7 +8,7 @@ import { t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { requirePermission } from '../../permissions.js'; import { assertModerableMember } from '../../hierarchy.js'; -import { applyWarnEscalation, createCase } from './service.js'; +import { applyWarnEscalation, createCase, createWarning, notifyModerationTargetDm } from './service.js'; import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js'; import { getGuildLocale } from '../../i18n.js'; import { promptDestructiveConfirmation } from './confirmations.js'; @@ -48,16 +48,24 @@ async function ensureMod( await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); return false; } - return requirePermission(interaction, permission, locale); + return requirePermission(interaction, permission, locale, { botPermissions: permission }); } async function replySuccessCase( interaction: ChatInputCommandInteraction, locale: 'de' | 'en', - caseNumber: number + caseNumber: number, + reason?: string ): Promise { + const content = reason + ? tf(locale, 'moderation.success.caseWithReason', { caseNumber, reason }) + : tf(locale, 'moderation.success.case', { caseNumber }); + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ content }); + return; + } await interaction.reply({ - content: tf(locale, 'moderation.success.case', { caseNumber }), + content, ephemeral: true }); } @@ -112,7 +120,7 @@ const unbanCommand: SlashCommand = { action: 'UNBAN', reason }); - await replySuccessCase(interaction, locale, modCase.caseNumber); + await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; @@ -125,6 +133,13 @@ const kickCommand: SlashCommand = { const reason = reasonFrom(interaction, locale); const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick'); if (!hierarchy.ok || !hierarchy.member) return; + await notifyModerationTargetDm({ + user, + guildName: interaction.guild!.name, + reason, + locale, + action: 'kick' + }); await hierarchy.member.kick(reason); const modCase = await createCase(context, { guildId: interaction.guildId!, @@ -134,7 +149,7 @@ const kickCommand: SlashCommand = { action: 'KICK', reason }); - await replySuccessCase(interaction, locale, modCase.caseNumber); + await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; @@ -167,7 +182,7 @@ const timeoutCommand: SlashCommand = { reason, metadata: { durationMs: duration } }); - await replySuccessCase(interaction, locale, modCase.caseNumber); + await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; @@ -189,7 +204,7 @@ const untimeoutCommand: SlashCommand = { action: 'UN_TIMEOUT', reason }); - await replySuccessCase(interaction, locale, modCase.caseNumber); + await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; @@ -299,19 +314,15 @@ const warnCommand: SlashCommand = { action: 'WARN_ADD', reason }); - await context.prisma.user.upsert({ - where: { id: user.id }, - update: {}, - create: { id: user.id } - }); - const warning = await context.prisma.warning.create({ - data: { - guildId: interaction.guildId!, - userId: user.id, - moderatorId: interaction.user.id, - reason, - caseId: modCase.id - } + const { warning } = await createWarning(context, { + guildId: interaction.guildId!, + userId: user.id, + moderatorId: interaction.user.id, + reason, + caseId: modCase.id, + guildName: interaction.guild!.name, + locale, + notifyUser: user }); const escalationResult = await applyWarnEscalation( interaction, @@ -322,8 +333,8 @@ const warnCommand: SlashCommand = { ); await interaction.reply({ content: escalationResult - ? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}` - : tf(locale, 'moderation.warning.created', { warningId: warning.id }), + ? `${tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber })}\n${escalationResult}` + : tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber }), ephemeral: true }); return; @@ -341,8 +352,8 @@ const warnCommand: SlashCommand = { ? t(locale, 'moderation.warning.none') : warnings .map( - (w: { id: string; reason: string | null }) => - `- ${w.id}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}` + (w: { warningNumber: number; reason: string | null }) => + `- #${w.warningNumber}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}` ) .join('\n'); await interaction.reply({ content, ephemeral: true }); @@ -350,9 +361,9 @@ const warnCommand: SlashCommand = { } if (sub === 'remove') { - const warningId = interaction.options.getString('warning_id', true); + const warningNumber = interaction.options.getInteger('id', true); const warning = await context.prisma.warning.findFirst({ - where: { id: warningId, guildId: interaction.guildId! } + where: { warningNumber, guildId: interaction.guildId! } }); if (!warning) { await interaction.reply({ @@ -368,7 +379,7 @@ const warnCommand: SlashCommand = { moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'WARN_REMOVE', - reason: `Warning ${warningId} removed` + reason: `Warning #${warningNumber} removed` }); await replySuccessCase(interaction, locale, modCase.caseNumber); return; @@ -460,6 +471,7 @@ const lockCommand: SlashCommand = { const guild = interaction.guild!; if (serverWide) { + await interaction.deferReply({ ephemeral: true }); for (const channel of guild.channels.cache.values()) { if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { continue; @@ -495,6 +507,7 @@ const unlockCommand: SlashCommand = { const guild = interaction.guild!; if (serverWide) { + await interaction.deferReply({ ephemeral: true }); for (const channel of guild.channels.cache.values()) { if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { continue; diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts index d7c28ac..d27aaa2 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -1,8 +1,14 @@ +import { + PermissionFlagsBits, + type ChatInputCommandInteraction, + type User +} from 'discord.js'; +import type { Prisma, Warning } from '@prisma/client'; +import { buildCaseMetadata, type CaseAuditSource, t, tf, type Locale } from '@nexumi/shared'; +import { logger } from '../../logger.js'; +import { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; import { moderationQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; -import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; -import type { Prisma } from '@prisma/client'; -import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared'; import { MAX_TIMEOUT_MS } from './duration.js'; type CreateCaseInput = { @@ -16,6 +22,152 @@ type CreateCaseInput = { metadata?: Record; }; +type CreateWarningInput = { + guildId: string; + userId: string; + moderatorId: string; + reason?: string; + caseId?: string; + guildName: string; + locale: Locale; + /** Discord user to DM; when omitted the user is fetched by id. */ + notifyUser?: User; +}; + +export type CreatedWarning = { + warning: Warning; + warningCount: number; +}; + +async function notifyWarnedUser(params: { + user: User; + guildName: string; + reason: string; + warningCount: number; + warningNumber: number; + locale: Locale; +}): Promise { + try { + await params.user.send( + tf(params.locale, 'moderation.warning.dm', { + guild: params.guildName, + reason: params.reason, + count: params.warningCount, + warningNumber: params.warningNumber + }) + ); + } catch (error) { + logger.warn( + { error, userId: params.user.id, warningNumber: params.warningNumber }, + 'Failed to DM warned user' + ); + } +} + +export async function notifyModerationTargetDm(params: { + user: User; + guildName: string; + reason: string; + locale: Locale; + action: 'ban' | 'kick'; + duration?: string | null; +}): Promise { + const reason = params.reason || t(params.locale, 'moderation.reason.none'); + const key = + params.action === 'kick' + ? 'moderation.kick.dm' + : params.duration + ? 'moderation.ban.dm.temporary' + : 'moderation.ban.dm'; + try { + await params.user.send( + tf(params.locale, key, { + guild: params.guildName, + reason, + ...(params.duration ? { duration: params.duration } : {}) + }) + ); + } catch (error) { + logger.warn( + { error, userId: params.user.id, action: params.action }, + 'Failed to DM moderation target' + ); + } +} + +export async function createWarning( + context: BotContext, + input: CreateWarningInput +): Promise { + await context.prisma.user.upsert({ + where: { id: input.userId }, + update: {}, + create: { id: input.userId } + }); + + let warning: Warning | null = null; + for (let attempt = 0; attempt < 5; attempt++) { + try { + warning = await context.prisma.$transaction( + async (tx) => { + const last = await tx.warning.findFirst({ + where: { guildId: input.guildId }, + orderBy: { warningNumber: 'desc' }, + select: { warningNumber: true } + }); + return tx.warning.create({ + data: { + guildId: input.guildId, + warningNumber: (last?.warningNumber ?? 0) + 1, + userId: input.userId, + moderatorId: input.moderatorId, + reason: input.reason, + caseId: input.caseId + } + }); + }, + { isolationLevel: 'Serializable' } + ); + break; + } catch (error) { + const code = + typeof error === 'object' && error && 'code' in error + ? String((error as { code: unknown }).code) + : ''; + if (code !== 'P2002' && attempt === 4) { + throw error; + } + if (attempt === 4) { + throw error; + } + } + } + + if (!warning) { + throw new Error('Failed to create warning'); + } + + const warningCount = await context.prisma.warning.count({ + where: { guildId: input.guildId, userId: input.userId } + }); + + const reason = input.reason ?? t(input.locale, 'moderation.warning.defaultReason'); + const notifyUser = + input.notifyUser ?? (await context.client.users.fetch(input.userId).catch(() => null)); + if (notifyUser) { + await notifyWarnedUser({ + user: notifyUser, + guildName: input.guildName, + reason, + warningCount, + warningNumber: warning.warningNumber, + locale: input.locale + }); + } + + return { warning, warningCount }; +} + export async function createCase(context: BotContext, input: CreateCaseInput) { await context.prisma.guild.upsert({ where: { id: input.guildId }, @@ -96,9 +248,7 @@ export async function scheduleTempBanExpire( ) { const jobId = tempBanJobId(guildId, userId); const existing = await moderationQueue.getJob(jobId); - if (existing) { - await existing.remove(); - } + await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId }); await moderationQueue.add( 'tempBanExpire', @@ -179,6 +329,13 @@ export async function applyWarnEscalation( String(warningCount) ); } + await notifyModerationTargetDm({ + user: member.user, + guildName: interaction.guild.name, + reason, + locale, + action: 'kick' + }); await member.kick(reason); await createCase(context, { guildId, @@ -199,6 +356,13 @@ export async function applyWarnEscalation( String(warningCount) ); } + await notifyModerationTargetDm({ + user: member.user, + guildName: interaction.guild.name, + reason, + locale, + action: 'ban' + }); await interaction.guild.members.ban(userId, { reason }); await createCase(context, { guildId, diff --git a/apps/bot/src/modules/scheduler/command-definitions.ts b/apps/bot/src/modules/scheduler/command-definitions.ts index 814d93a..ae67bbc 100644 --- a/apps/bot/src/modules/scheduler/command-definitions.ts +++ b/apps/bot/src/modules/scheduler/command-definitions.ts @@ -1,9 +1,10 @@ import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; -import { SlashCommandBuilder } from 'discord.js'; +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; export const scheduleCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('schedule') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((sub) => applyCommandDescription(sub.setName('create'), 'schedule.create.description') .addChannelOption((o) => @@ -51,6 +52,7 @@ export const scheduleCommandData = applyCommandDescription( export const announceCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('announce') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addChannelOption((o) => applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel') ) diff --git a/apps/bot/src/modules/scheduler/service.ts b/apps/bot/src/modules/scheduler/service.ts index e282a83..bebf892 100644 --- a/apps/bot/src/modules/scheduler/service.ts +++ b/apps/bot/src/modules/scheduler/service.ts @@ -4,11 +4,20 @@ import { type MessageCreateOptions, type TextChannel } from 'discord.js'; -import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared'; +import { + DEFAULT_GUILD_TIMEZONE, + WelcomeEmbedSchema, + expandBracketChannelMentions, + parseMessageComponentsV2, + resolveScheduleRunAt, + t, + tf +} from '@nexumi/shared'; import type { ScheduledMessage } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; import { logger } from '../../logger.js'; import { scheduleQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; @@ -36,7 +45,7 @@ export type ScheduleCreateInput = AnnouncementInput & { cron?: string | null; }; -function parseScheduleWhen(input: string): Date { +function parseScheduleWhen(input: string, timeZone: string): Date { const trimmed = input.trim(); if (/^\d+[smhd]$/i.test(trimmed)) { const ms = parseDuration(trimmed); @@ -46,11 +55,79 @@ function parseScheduleWhen(input: string): Date { return new Date(Date.now() + ms); } - const parsed = Date.parse(trimmed); - if (Number.isNaN(parsed) || parsed <= Date.now()) { + try { + const parsed = resolveScheduleRunAt(trimmed, timeZone); + if (parsed.getTime() <= Date.now()) { + throw new SchedulerError('invalid_when'); + } + return parsed; + } catch { throw new SchedulerError('invalid_when'); } - return new Date(parsed); +} + +async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise { + const settings = await prisma.guildSettings.findUnique({ + where: { guildId }, + select: { timezone: true } + }); + return settings?.timezone || DEFAULT_GUILD_TIMEZONE; +} + +export function scheduleJobId(scheduleId: string): string { + return `schedule-${scheduleId}`; +} + +/** BullMQ delayed instance ids look like `repeat:{schedulerId}:{millis}`. */ +const REPEAT_INSTANCE_ID_RE = /^repeat:(.+):(\d+)$/; + +function collectSchedulerIds(scheduleId: string, jobId: string | null | undefined): string[] { + const ids = new Set(); + ids.add(scheduleJobId(scheduleId)); + if (!jobId) { + return [...ids]; + } + + const repeatMatch = jobId.match(REPEAT_INSTANCE_ID_RE); + if (repeatMatch?.[1]) { + ids.add(repeatMatch[1]); + } else if (!jobId.startsWith('repeat:')) { + ids.add(jobId); + } + return [...ids]; +} + +async function removeScheduleJobSchedulers( + queue: typeof scheduleQueue, + scheduleId: string, + jobId: string | null | undefined +): Promise { + for (const schedulerId of collectSchedulerIds(scheduleId, jobId)) { + try { + await queue.removeJobScheduler(schedulerId); + } catch (error) { + logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler'); + } + } + + // Legacy / hash-keyed schedulers: match by payload scheduleId. + try { + const schedulers = await queue.getJobSchedulers(); + for (const scheduler of schedulers) { + const data = scheduler.template?.data as { scheduleId?: string } | undefined; + if (data?.scheduleId !== scheduleId) { + continue; + } + const id = scheduler.id ?? scheduler.key; + try { + await queue.removeJobScheduler(id); + } catch (error) { + logger.warn({ schedulerId: id, error }, 'Failed to remove matched schedule job scheduler'); + } + } + } catch (error) { + logger.warn({ scheduleId, error }, 'Failed to scan job schedulers for schedule'); + } } function isValidCron(pattern: string): boolean { @@ -157,38 +234,85 @@ async function assertSendableChannel( async function enqueueScheduleJob( schedule: ScheduledMessage, cron: string | null, - runAt: Date | null + runAt: Date | null, + timeZone: string ): Promise { - const jobOptions: { - jobId: string; - removeOnComplete: boolean; - removeOnFail: number; - delay?: number; - repeat?: { pattern: string }; - } = { - jobId: `schedule-${schedule.id}`, - removeOnComplete: true, - removeOnFail: 50 - }; + const jobId = scheduleJobId(schedule.id); if (cron) { - jobOptions.repeat = { pattern: cron }; - } else if (runAt) { - jobOptions.delay = Math.max(0, runAt.getTime() - Date.now()); + // BullMQ 5 job schedulers honor guild timezone for cron patterns. + await scheduleQueue.upsertJobScheduler( + jobId, + { pattern: cron, tz: timeZone }, + { + name: 'scheduleSend', + data: { scheduleId: schedule.id }, + opts: { + removeOnComplete: true, + removeOnFail: 50 + } + } + ); + return jobId; } - const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions); + if (!runAt) { + throw new SchedulerError('missing_schedule_time'); + } + + const job = await scheduleQueue.add( + 'scheduleSend', + { scheduleId: schedule.id }, + { + jobId, + removeOnComplete: true, + removeOnFail: 50, + delay: Math.max(0, runAt.getTime() - Date.now()) + } + ); return String(job.id); } -export async function removeScheduleJob(jobId: string | null): Promise { - if (!jobId) { - return; +/** + * Removes a one-shot delayed job and/or cron job scheduler. Locked (active) + * jobs cannot be removed — do not call this from inside the scheduleSend + * worker for the job that is currently running. + */ +export async function removeScheduleJob( + jobId: string | null, + options?: { scheduleId?: string; cron?: string | null } +): Promise { + if (options?.scheduleId) { + await removeScheduleJobSchedulers(scheduleQueue, options.scheduleId, jobId); + } else if (options?.cron && jobId) { + // Best-effort for callers without scheduleId: extract scheduler from repeat instance id. + for (const schedulerId of collectSchedulerIds('unknown', jobId)) { + if (schedulerId === 'schedule-unknown') { + continue; + } + try { + await scheduleQueue.removeJobScheduler(schedulerId); + } catch (error) { + logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler'); + } + } } - const job = await scheduleQueue.getJob(jobId); - if (job) { - await job.remove(); + // One-shot delayed jobs only — never job.remove() on repeat:* scheduler children. + const oneShotIds = new Set(); + if (jobId && !jobId.startsWith('repeat:')) { + oneShotIds.add(jobId); + } + if (options?.scheduleId) { + oneShotIds.add(scheduleJobId(options.scheduleId)); + } + + for (const id of oneShotIds) { + const job = await scheduleQueue.getJob(id); + if (job?.id && String(job.id).startsWith('repeat:')) { + continue; + } + await safeRemoveBullJob(job, { label: 'schedule', jobId: id }); } } @@ -220,6 +344,7 @@ export async function createScheduledMessage( let runAt: Date | null = null; let cronPattern: string | null = null; + const timeZone = await getGuildTimezone(context.prisma, input.guildId); if (cron) { if (!isValidCron(cron)) { @@ -227,7 +352,7 @@ export async function createScheduledMessage( } cronPattern = cron; } else if (whenInput) { - runAt = parseScheduleWhen(whenInput); + runAt = parseScheduleWhen(whenInput, timeZone); } await assertSendableChannel(context, input.channelId); @@ -247,7 +372,7 @@ export async function createScheduledMessage( } }); - const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt); + const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone); return context.prisma.scheduledMessage.update({ where: { id: schedule.id }, data: { jobId } @@ -307,7 +432,7 @@ export async function deleteScheduledMessage( return false; } - await removeScheduleJob(match.jobId); + await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron }); await context.prisma.scheduledMessage.delete({ where: { id: match.id } }); return true; } @@ -350,8 +475,10 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri throw error; } + // One-shot: delete the DB row only. Do not remove the active BullMQ job + // from inside its own worker (locked remove throws → retry → duplicate send). + // removeOnComplete on enqueue cleans the job up after the processor returns. if (!schedule.cron) { - await removeScheduleJob(schedule.jobId); await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } }); } } diff --git a/apps/bot/src/modules/selfroles/command-definitions.ts b/apps/bot/src/modules/selfroles/command-definitions.ts index e40ffa8..4700432 100644 --- a/apps/bot/src/modules/selfroles/command-definitions.ts +++ b/apps/bot/src/modules/selfroles/command-definitions.ts @@ -3,11 +3,12 @@ import { applyOptionDescription, localizedChoice } from '@nexumi/shared'; -import { ChannelType, SlashCommandBuilder } from 'discord.js'; +import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; export const selfrolesCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('selfroles') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommandGroup((group) => applyCommandDescription(group.setName('panel'), 'selfroles.panel.description') .addSubcommand((sub) => diff --git a/apps/bot/src/modules/selfroles/index.ts b/apps/bot/src/modules/selfroles/index.ts index e40783c..4e0ff87 100644 --- a/apps/bot/src/modules/selfroles/index.ts +++ b/apps/bot/src/modules/selfroles/index.ts @@ -4,5 +4,9 @@ export { registerSelfRoleEvents } from './reactions.js'; export { expireSelfRole, scheduleSelfRoleExpiry, - cancelSelfRoleExpiry + cancelSelfRoleExpiry, + runSelfRolePanelCreateJob, + runSelfRolePanelUpdateJob, + runSelfRolePanelDeleteJob, + SelfRoleError } from './service.js'; diff --git a/apps/bot/src/modules/selfroles/service.ts b/apps/bot/src/modules/selfroles/service.ts index a2621fc..b786b1e 100644 --- a/apps/bot/src/modules/selfroles/service.ts +++ b/apps/bot/src/modules/selfroles/service.ts @@ -21,7 +21,9 @@ import { } from '@nexumi/shared'; import type { SelfRolePanel } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; +import { getGuildLocale } from '../../i18n.js'; import { logger } from '../../logger.js'; +import { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; import { ticketQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; @@ -266,13 +268,17 @@ export async function scheduleSelfRoleExpiry( roleId: string, expiresAt: Date ): Promise { + const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`; + const existing = await ticketQueue.getJob(jobId); + await safeRemoveBullJob(existing, { label: 'selfRoleExpire', jobId }); + const delay = Math.max(0, expiresAt.getTime() - Date.now()); await ticketQueue.add( 'selfRoleExpire', { guildId, userId, roleId }, { delay, - jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`, + jobId, removeOnComplete: true, removeOnFail: 50 } @@ -284,8 +290,9 @@ export async function cancelSelfRoleExpiry( userId: string, roleId: string ): Promise { - const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`); - await job?.remove(); + const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`; + const job = await ticketQueue.getJob(jobId); + await safeRemoveBullJob(job, { label: 'selfRoleExpire', jobId }); } export async function expireSelfRole( @@ -416,6 +423,69 @@ export async function findPanelByMessageId( }); } +async function postOrEditPanelMessage( + context: BotContext, + panel: SelfRolePanel, + locale: 'de' | 'en', + previous?: { channelId: string; messageId: string | null } +): Promise { + const mode = SelfRoleModeSchema.parse(panel.mode); + const { entries } = parseRolesJson(panel.roles); + const channelChanged = + previous !== undefined && previous.channelId !== panel.channelId; + + if (panel.messageId && !channelChanged) { + 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') { + for (const reaction of message.reactions.cache.values()) { + await reaction.remove().catch(() => undefined); + } + await applyReactionEmojis(message, entries); + } + + return panel; + } catch (error) { + logger.warn({ error, panelId: panel.id }, 'Failed to edit self-role panel message; recreating'); + } + } + + if (previous?.messageId && channelChanged) { + try { + const oldChannel = (await context.client.channels.fetch(previous.channelId)) as TextChannel; + const oldMessage = await oldChannel.messages.fetch(previous.messageId); + await oldMessage.delete(); + } catch { + // Previous message may already be gone + } + } + + const channel = await context.client.channels.fetch(panel.channelId); + if (!channel || !channel.isTextBased() || channel.isDMBased()) { + throw new SelfRoleError('invalid_channel'); + } + + const message = await channel.send({ + embeds: [buildPanelEmbed(panel, locale)], + components: buildPanelComponents(panel) + }); + + if (mode === 'REACTIONS') { + await applyReactionEmojis(message, entries); + } + + return context.prisma.selfRolePanel.update({ + where: { id: panel.id }, + data: { messageId: message.id } + }); +} + export async function createSelfRolePanel( context: BotContext, params: { @@ -427,6 +497,7 @@ export async function createSelfRolePanel( behavior: SelfRoleBehavior; roles: SelfRoleEntry[]; temporaryDurationMs?: number; + expiresAt?: Date | null; }, locale: 'de' | 'en' ): Promise { @@ -451,24 +522,12 @@ export async function createSelfRolePanel( description: params.description ?? null, mode: params.mode, behavior: params.behavior, - roles: serializeRolesJson(params.roles, params.temporaryDurationMs) + roles: serializeRolesJson(params.roles, params.temporaryDurationMs), + expiresAt: params.expiresAt ?? null } }); - 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 } - }); + return postOrEditPanelMessage(context, panel, locale); } export async function updateSelfRolePanel( @@ -476,12 +535,14 @@ export async function updateSelfRolePanel( panelId: string, guildId: string, updates: { + channelId?: string; title?: string; description?: string | null; mode?: SelfRoleMode; behavior?: SelfRoleBehavior; roles?: SelfRoleEntry[]; temporaryDurationMs?: number; + expiresAt?: Date | null; }, locale: 'de' | 'en' ): Promise { @@ -507,39 +568,100 @@ export async function updateSelfRolePanel( throw new SelfRoleError('reaction_emoji_required'); } + const previous = { channelId: existing.channelId, messageId: existing.messageId }; const panel = await context.prisma.selfRolePanel.update({ where: { id: panelId }, data: { + channelId: updates.channelId ?? existing.channelId, title: updates.title ?? existing.title, description: updates.description !== undefined ? updates.description : existing.description, mode, behavior, - roles: serializeRolesJson(roles, temporaryDurationMs) + roles: serializeRolesJson(roles, temporaryDurationMs), + ...(updates.expiresAt !== undefined ? { expiresAt: updates.expiresAt } : {}) } }); - 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) - }); + return postOrEditPanelMessage(context, panel, locale, previous); +} - 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'); - } +/** + * BullMQ entry points for dashboard create/update/delete. The WebUI has no + * discord.js Client, so panel posting is delegated here (same pattern as + * giveaways / dashboard messages). + */ +export async function runSelfRolePanelCreateJob( + context: BotContext, + params: { + guildId: string; + channelId: string; + title: string; + description?: string | null; + mode: SelfRoleMode; + behavior: SelfRoleBehavior; + roles: SelfRoleEntry[]; + temporaryDurationMs?: number; + expiresAt?: string | null; } +): Promise<{ id: string }> { + const locale = await getGuildLocale(context.prisma, params.guildId); + const panel = await createSelfRolePanel( + context, + { + ...params, + expiresAt: params.expiresAt ? new Date(params.expiresAt) : null + }, + locale + ); + return { id: panel.id }; +} - return panel; +export async function runSelfRolePanelUpdateJob( + context: BotContext, + params: { + panelId: string; + guildId: string; + channelId?: string; + title?: string; + description?: string | null; + mode?: SelfRoleMode; + behavior?: SelfRoleBehavior; + roles?: SelfRoleEntry[]; + temporaryDurationMs?: number; + expiresAt?: string | null; + } +): Promise<{ id: string }> { + const locale = await getGuildLocale(context.prisma, params.guildId); + const panel = await updateSelfRolePanel( + context, + params.panelId, + params.guildId, + { + channelId: params.channelId, + title: params.title, + description: params.description, + mode: params.mode, + behavior: params.behavior, + roles: params.roles, + temporaryDurationMs: params.temporaryDurationMs, + expiresAt: + params.expiresAt === undefined + ? undefined + : params.expiresAt + ? new Date(params.expiresAt) + : null + }, + locale + ); + return { id: panel.id }; +} + +export async function runSelfRolePanelDeleteJob( + context: BotContext, + panelId: string, + guildId: string +): Promise { + await deleteSelfRolePanel(context, panelId, guildId); } export async function deleteSelfRolePanel( diff --git a/apps/bot/src/modules/starboard/command-definitions.ts b/apps/bot/src/modules/starboard/command-definitions.ts index 65a3fa1..a70800e 100644 --- a/apps/bot/src/modules/starboard/command-definitions.ts +++ b/apps/bot/src/modules/starboard/command-definitions.ts @@ -1,15 +1,19 @@ -import { ChannelType } from 'discord.js'; +import { ChannelType, PermissionFlagsBits } from 'discord.js'; import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; import { SlashCommandBuilder } from 'discord.js'; export const starboardCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('starboard') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((s) => applyCommandDescription(s.setName('setup'), 'starboard.setup.description') .addChannelOption((o) => applyOptionDescription( - o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true), + o + .setName('channel') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) + .setRequired(true), 'starboard.setup.options.channel' ) ) @@ -28,6 +32,9 @@ export const starboardCommandData = applyCommandDescription( 'starboard.setup.options.allow_self_star' ) ) + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('status'), 'starboard.status.description') ), 'starboard.description' ); diff --git a/apps/bot/src/modules/starboard/commands.ts b/apps/bot/src/modules/starboard/commands.ts index 5818c74..e68bf60 100644 --- a/apps/bot/src/modules/starboard/commands.ts +++ b/apps/bot/src/modules/starboard/commands.ts @@ -1,17 +1,18 @@ import { PermissionFlagsBits } from 'discord.js'; -import { t } from '@nexumi/shared'; +import { t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { requirePermission } from '../../permissions.js'; +import { ephemeral } from '../../interaction-reply.js'; import { starboardCommandData } from './command-definitions.js'; -import { getStarboardConfig, updateStarboardConfig } from './service.js'; +import { botCanPostStarboard, 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 }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); return; } if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { @@ -22,11 +23,21 @@ const starboardCommand: SlashCommand = { const sub = interaction.options.getSubcommand(); if (sub === 'setup') { - const channel = interaction.options.getChannel('channel', true); + const channelOption = 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; + const channel = await interaction.guild!.channels.fetch(channelOption.id).catch(() => null); + const me = interaction.guild!.members.me; + if (!channel || !me || !botCanPostStarboard(channel, me.id)) { + await interaction.reply({ + content: t(locale, 'starboard.invalidChannel'), + ...ephemeral() + }); + return; + } + await updateStarboardConfig(context.prisma, guildId, { enabled: true, channelId: channel.id, @@ -35,13 +46,30 @@ const starboardCommand: SlashCommand = { allowSelfStar }); - await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'starboard.setupDone'), ...ephemeral() }); return; } - const config = await getStarboardConfig(context.prisma, guildId); - if (!config.enabled || !config.channelId) { - await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true }); + if (sub === 'status') { + const config = await getStarboardConfig(context.prisma, guildId); + if (!config.enabled || !config.channelId) { + await interaction.reply({ + content: t(locale, 'starboard.notConfigured'), + ...ephemeral() + }); + return; + } + await interaction.reply({ + content: tf(locale, 'starboard.statusLine', { + channel: `<#${config.channelId}>`, + emoji: config.emoji, + threshold: String(config.threshold), + selfStar: config.allowSelfStar + ? t(locale, 'starboard.selfStarOn') + : t(locale, 'starboard.selfStarOff') + }), + ...ephemeral() + }); } } }; diff --git a/apps/bot/src/modules/starboard/events.ts b/apps/bot/src/modules/starboard/events.ts index e3ad7e1..2b98ad5 100644 --- a/apps/bot/src/modules/starboard/events.ts +++ b/apps/bot/src/modules/starboard/events.ts @@ -1,6 +1,11 @@ +import type { Message, PartialMessage } from 'discord.js'; import type { BotContext } from '../../types.js'; import { logger } from '../../logger.js'; -import { processStarboardReaction } from './service.js'; +import { + processStarboardReaction, + processStarboardSourceDelete, + processStarboardSourceUpdate +} from './service.js'; export function registerStarboardEvents(context: BotContext): void { const handleReaction = async ( @@ -26,4 +31,48 @@ export function registerStarboardEvents(context: BotContext): void { } await handleReaction(_reaction); }); + + context.client.on('messageReactionRemoveAll', async (message) => { + try { + if (message.partial) { + await message.fetch().catch(() => null); + } + if (!message.guild || !('author' in message) || !message.author) { + return; + } + await processStarboardSourceUpdate(context, message as Message | PartialMessage); + } catch (error) { + logger.warn({ error }, 'Starboard remove-all handler failed'); + } + }); + + context.client.on('messageReactionRemoveEmoji', async (reaction) => { + await handleReaction(reaction); + }); + + context.client.on('messageDelete', async (message) => { + try { + await processStarboardSourceDelete(context, message); + } catch (error) { + logger.warn({ error }, 'Starboard source-delete handler failed'); + } + }); + + context.client.on('messageDeleteBulk', async (messages) => { + for (const message of messages.values()) { + try { + await processStarboardSourceDelete(context, message); + } catch (error) { + logger.warn({ error }, 'Starboard bulk-delete handler failed'); + } + } + }); + + context.client.on('messageUpdate', async (_oldMessage, newMessage) => { + try { + await processStarboardSourceUpdate(context, newMessage); + } catch (error) { + logger.warn({ error }, 'Starboard source-update handler failed'); + } + }); } diff --git a/apps/bot/src/modules/starboard/service.test.ts b/apps/bot/src/modules/starboard/service.test.ts new file mode 100644 index 0000000..28ef539 --- /dev/null +++ b/apps/bot/src/modules/starboard/service.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { emojiMatches, shouldIgnoreMessage } from './service.js'; + +function reactionEmoji(partial: { id?: string | null; name?: string | null }) { + return { + id: partial.id ?? null, + name: partial.name ?? null, + identifier: partial.id ? `${partial.name}:${partial.id}` : (partial.name ?? ''), + toString() { + if (partial.id) { + return `<:${partial.name}:${partial.id}>`; + } + return partial.name ?? ''; + } + } as unknown as import('discord.js').Emoji; +} + +describe('starboard emojiMatches', () => { + it('matches unicode emoji', () => { + expect(emojiMatches('⭐', reactionEmoji({ name: '⭐' }))).toBe(true); + }); + + it('matches custom emoji forms', () => { + const emoji = reactionEmoji({ id: '123', name: 'star' }); + expect(emojiMatches('<:star:123>', emoji)).toBe(true); + expect(emojiMatches('star:123', emoji)).toBe(true); + }); +}); + +describe('starboard shouldIgnoreMessage', () => { + const baseConfig = { + id: 'cfg', + guildId: 'g1', + enabled: true, + channelId: 'star-channel', + emoji: '⭐', + threshold: 3, + allowSelfStar: false, + ignoredChannelIds: ['ignored'], + createdAt: new Date(), + updatedAt: new Date() + }; + + it('ignores bot authors', () => { + const message = { + author: { bot: true }, + guild: { id: 'g1' }, + channel: { + id: 'c1', + isTextBased: () => true, + isDMBased: () => false, + nsfw: false + } + } as unknown as import('discord.js').Message; + expect(shouldIgnoreMessage(message, baseConfig)).toBe(true); + }); + + it('ignores the starboard channel itself', () => { + const message = { + author: { bot: false, id: 'u1' }, + guild: { id: 'g1' }, + channel: { + id: 'star-channel', + isTextBased: () => true, + isDMBased: () => false, + nsfw: false + } + } as unknown as import('discord.js').Message; + expect(shouldIgnoreMessage(message, baseConfig)).toBe(true); + }); + + it('ignores listed channels', () => { + const message = { + author: { bot: false, id: 'u1' }, + guild: { id: 'g1' }, + channel: { + id: 'ignored', + isTextBased: () => true, + isDMBased: () => false, + nsfw: false + } + } as unknown as import('discord.js').Message; + expect(shouldIgnoreMessage(message, baseConfig)).toBe(true); + }); +}); diff --git a/apps/bot/src/modules/starboard/service.ts b/apps/bot/src/modules/starboard/service.ts index 051f9f3..4c56254 100644 --- a/apps/bot/src/modules/starboard/service.ts +++ b/apps/bot/src/modules/starboard/service.ts @@ -1,14 +1,17 @@ import { + ChannelType, EmbedBuilder, + PermissionFlagsBits, type Emoji, + type GuildBasedChannel, type Message, + type PartialMessage, 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 { t, type Locale } from '@nexumi/shared'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; import type { BotContext } from '../../types.js'; @@ -73,6 +76,27 @@ export function shouldIgnoreMessage(message: Message, config: StarboardConfig): return false; } +export function botCanPostStarboard(channel: GuildBasedChannel, meId: string): channel is TextChannel { + if (!channel.isTextBased() || channel.isDMBased()) { + return false; + } + if ( + channel.type !== ChannelType.GuildText && + channel.type !== ChannelType.GuildAnnouncement + ) { + return false; + } + const permissions = channel.permissionsFor(meId); + if (!permissions) { + return false; + } + return ( + permissions.has(PermissionFlagsBits.ViewChannel) && + permissions.has(PermissionFlagsBits.SendMessages) && + permissions.has(PermissionFlagsBits.EmbedLinks) + ); +} + export async function countMatchingStars( message: Message, config: StarboardConfig @@ -109,6 +133,10 @@ function resolveImageUrl(message: Message): string | undefined { return embedImage ?? undefined; } +function authorDisplayName(message: Message): string { + return message.member?.displayName || message.author.displayName || message.author.username; +} + export function buildStarboardEmbed( message: Message, starCount: number, @@ -119,7 +147,7 @@ export function buildStarboardEmbed( const embed = new EmbedBuilder() .setColor(0x6366f1) .setAuthor({ - name: message.author.tag, + name: authorDisplayName(message), iconURL: message.author.displayAvatarURL() }) .setDescription(content.slice(0, 4096)) @@ -143,6 +171,17 @@ export function buildStarboardEmbed( return embed; } +async function fetchStarboardTextChannel( + context: BotContext, + channelId: string +): Promise { + const channel = await context.client.channels.fetch(channelId).catch(() => null); + if (!channel || !channel.isTextBased() || channel.isDMBased()) { + return null; + } + return channel as TextChannel; +} + async function removeStarboardEntry(context: BotContext, entryId: string): Promise { const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } }); if (!entry) { @@ -152,9 +191,13 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi 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); + const channel = await fetchStarboardTextChannel(context, config.channelId); + if (channel) { + 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'); @@ -163,6 +206,82 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined); } +export async function removeStarboardEntryBySource( + context: BotContext, + guildId: string, + sourceMessageId: string +): Promise { + const entry = await context.prisma.starboardEntry.findUnique({ + where: { + guildId_sourceMessageId: { guildId, sourceMessageId } + } + }); + if (entry) { + await removeStarboardEntry(context, entry.id); + } +} + +async function createOrRecreateStarboardPost( + context: BotContext, + message: Message, + config: StarboardConfig, + locale: Locale, + starCount: number, + starboardChannel: TextChannel, + existingId?: string +): Promise { + const embed = buildStarboardEmbed(message, starCount, config, locale); + const starboardMessage = await starboardChannel.send({ embeds: [embed] }); + + if (existingId) { + await context.prisma.starboardEntry.update({ + where: { id: existingId }, + data: { + starboardMessageId: starboardMessage.id, + starCount, + sourceChannelId: message.channel.id + } + }); + return; + } + + try { + await context.prisma.starboardEntry.create({ + data: { + guildId: message.guild!.id, + sourceChannelId: message.channel.id, + sourceMessageId: message.id, + starboardMessageId: starboardMessage.id, + starCount + } + }); + } catch (error) { + // Race: another reaction created the row first — keep the newer post, drop ours. + logger.warn({ error, messageId: message.id }, 'Starboard entry create raced; cleaning up'); + await starboardMessage.delete().catch(() => undefined); + const winner = await context.prisma.starboardEntry.findUnique({ + where: { + guildId_sourceMessageId: { + guildId: message.guild!.id, + sourceMessageId: message.id + } + } + }); + if (winner) { + await context.prisma.starboardEntry.update({ + where: { id: winner.id }, + data: { starCount } + }); + const existingMsg = await starboardChannel.messages + .fetch(winner.starboardMessageId) + .catch(() => null); + if (existingMsg) { + await existingMsg.edit({ embeds: [embed] }).catch(() => undefined); + } + } + } +} + export async function processStarboardMessage(context: BotContext, message: Message): Promise { if (!message.guild) { return; @@ -194,8 +313,13 @@ export async function processStarboardMessage(context: BotContext, message: Mess return; } + const starboardChannel = await fetchStarboardTextChannel(context, config.channelId); + if (!starboardChannel) { + logger.warn({ guildId: message.guild.id, channelId: config.channelId }, 'Starboard channel missing'); + return; + } + const embed = buildStarboardEmbed(message, starCount, config, locale); - const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel; if (existing) { try { @@ -206,21 +330,28 @@ export async function processStarboardMessage(context: BotContext, message: Mess data: { starCount } }); } catch (error) { - logger.warn({ error, messageId: message.id }, 'Failed to update starboard message'); + logger.warn({ error, messageId: message.id }, 'Starboard message missing; recreating'); + await createOrRecreateStarboardPost( + context, + message, + config, + locale, + starCount, + starboardChannel, + existing.id + ); } 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 - } - }); + await createOrRecreateStarboardPost( + context, + message, + config, + locale, + starCount, + starboardChannel + ); } export async function processStarboardReaction( @@ -249,3 +380,41 @@ export async function processStarboardReaction( await processStarboardMessage(context, message as Message); } + +export async function processStarboardSourceDelete( + context: BotContext, + message: Message | PartialMessage +): Promise { + if (!message.guildId || !message.id) { + return; + } + await removeStarboardEntryBySource(context, message.guildId, message.id); +} + +export async function processStarboardSourceUpdate( + context: BotContext, + message: Message | PartialMessage +): Promise { + if (!message.guildId || !message.id) { + return; + } + + const entry = await context.prisma.starboardEntry.findUnique({ + where: { + guildId_sourceMessageId: { + guildId: message.guildId, + sourceMessageId: message.id + } + } + }); + if (!entry) { + return; + } + + const full = message.partial ? await message.fetch().catch(() => null) : message; + if (!full || !full.guild) { + return; + } + + await processStarboardMessage(context, full as Message); +} diff --git a/apps/bot/src/modules/stats/commands.ts b/apps/bot/src/modules/stats/commands.ts index f3dfa51..dcd1829 100644 --- a/apps/bot/src/modules/stats/commands.ts +++ b/apps/bot/src/modules/stats/commands.ts @@ -7,8 +7,8 @@ import { invitesCommandData, statsCommandData } from './command-definitions.js'; import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js'; import { getStatsConfig, + refreshGuildStatsChannels, StatsSetupSchema, - updateStatsChannels, upsertStatsConfig } from './service.js'; @@ -47,7 +47,7 @@ const statsCommand: SlashCommand = { } await upsertStatsConfig(context.prisma, guildId, parsed.data); - await updateStatsChannels(context); + await refreshGuildStatsChannels(context, guildId); await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true }); return; @@ -64,7 +64,7 @@ const statsCommand: SlashCommand = { return; } - await updateStatsChannels(context); + await refreshGuildStatsChannels(context, guildId); await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true }); } } diff --git a/apps/bot/src/modules/stats/index.ts b/apps/bot/src/modules/stats/index.ts index edb2b4b..e7dcaa1 100644 --- a/apps/bot/src/modules/stats/index.ts +++ b/apps/bot/src/modules/stats/index.ts @@ -4,6 +4,9 @@ export { ensureStatsRecurringJobs, startStatsWorker, updateStatsChannels, + refreshGuildStatsChannels, + runStatsGuildRefreshJob, STATS_REFRESH_CRON, - STATS_REFRESH_JOB_NAME + STATS_REFRESH_JOB_NAME, + STATS_GUILD_REFRESH_JOB_NAME } from './service.js'; diff --git a/apps/bot/src/modules/stats/service.ts b/apps/bot/src/modules/stats/service.ts index bad7e13..52d39f9 100644 --- a/apps/bot/src/modules/stats/service.ts +++ b/apps/bot/src/modules/stats/service.ts @@ -21,6 +21,8 @@ export type StatsSetupInput = z.infer; export const STATS_REFRESH_CRON = '*/10 * * * *'; export const STATS_REFRESH_JOB_NAME = 'statsRefresh'; +/** One-shot refresh for a single guild (dashboard save / slash refresh). */ +export const STATS_GUILD_REFRESH_JOB_NAME = 'statsRefreshGuild'; export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) { await ensureGuild(prisma, guildId); @@ -97,13 +99,17 @@ async function renameStatsChannel( }); } -async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise { +export async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise { const config = await context.prisma.statsConfig.findUnique({ where: { guildId } }); if (!config?.enabled) { return; } - const guild = await context.client.guilds.fetch(guildId).catch(() => null); + // withCounts populates approximatePresenceCount / approximateMemberCount. + // GuildPresences is not privileged for us — presence cache would stay empty. + const guild = await context.client.guilds + .fetch({ guild: guildId, withCounts: true }) + .catch(() => null); if (!guild) { return; } @@ -114,9 +120,7 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string): return; } - await guild.members.fetch().catch(() => undefined); - - const memberCount = guild.memberCount; + const memberCount = guild.approximateMemberCount ?? guild.memberCount; const onlineCount = estimateOnlineCount(guild); const boostCount = guild.premiumSubscriptionCount ?? 0; @@ -127,6 +131,13 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string): ]); } +export async function runStatsGuildRefreshJob( + context: BotContext, + data: { guildId: string } +): Promise { + await refreshGuildStatsChannels(context, data.guildId); +} + export async function updateStatsChannels(context: BotContext): Promise { const configs = await context.prisma.statsConfig.findMany({ where: { enabled: true } @@ -159,6 +170,15 @@ export function startStatsWorker(context: BotContext): Worker { async (job) => { if (job.name === STATS_REFRESH_JOB_NAME) { await updateStatsChannels(context); + return; + } + if (job.name === STATS_GUILD_REFRESH_JOB_NAME) { + const guildId = typeof job.data?.guildId === 'string' ? job.data.guildId : null; + if (!guildId) { + logger.warn({ jobId: job.id }, 'statsRefreshGuild missing guildId'); + return; + } + await runStatsGuildRefreshJob(context, { guildId }); } }, { connection: redis } diff --git a/apps/bot/src/modules/tags/command-definitions.ts b/apps/bot/src/modules/tags/command-definitions.ts index 468d208..a24e1f7 100644 --- a/apps/bot/src/modules/tags/command-definitions.ts +++ b/apps/bot/src/modules/tags/command-definitions.ts @@ -1,9 +1,10 @@ import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared'; -import { SlashCommandBuilder } from 'discord.js'; +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; export const tagCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('tag') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((sub) => applyCommandDescription(sub.setName('create'), 'tag.create.description') .addStringOption((o) => diff --git a/apps/bot/src/modules/tags/commands.ts b/apps/bot/src/modules/tags/commands.ts index 2ee8c39..cced794 100644 --- a/apps/bot/src/modules/tags/commands.ts +++ b/apps/bot/src/modules/tags/commands.ts @@ -172,6 +172,9 @@ const tagCommand: SlashCommand = { tag.triggerWord ? tf(locale, 'tag.info.trigger', { word: tag.triggerWord }) : t(locale, 'tag.info.noTrigger'), + tag.cooldownSeconds > 0 + ? tf(locale, 'tag.info.cooldown', { seconds: tag.cooldownSeconds }) + : t(locale, 'tag.info.noCooldown'), tag.allowedRoleIds.length > 0 ? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') }) : t(locale, 'tag.info.allRoles'), @@ -189,8 +192,12 @@ const tagCommand: SlashCommand = { } } catch (error) { if (error instanceof TagError) { + const content = + error.code === 'cooldown' && error.retryAfterSeconds !== undefined + ? tf(locale, 'tag.error.cooldown', { seconds: error.retryAfterSeconds }) + : t(locale, `tag.error.${error.code}`); await interaction.reply({ - content: t(locale, `tag.error.${error.code}`), + content, ephemeral: true }); return; diff --git a/apps/bot/src/modules/tags/events.ts b/apps/bot/src/modules/tags/events.ts index 2a9ed4d..d494355 100644 --- a/apps/bot/src/modules/tags/events.ts +++ b/apps/bot/src/modules/tags/events.ts @@ -4,8 +4,10 @@ import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { logger } from '../../logger.js'; import { + applyTagCooldown, buildTagReply, findTriggeredTag, + getTagCooldownRemaining, memberCanUseTag, TagError } from './service.js'; @@ -31,6 +33,18 @@ export function registerTagEvents(client: Client, context: BotContext): void { return; } + // Channel-scoped cooldown: silent skip to avoid spam replies. + if (tag.cooldownSeconds > 0) { + const remaining = await getTagCooldownRemaining( + message.guild.id, + tag.id, + message.channel.id + ); + if (remaining > 0) { + return; + } + } + await context.prisma.tag.update({ where: { id: tag.id }, data: { useCount: { increment: 1 } } @@ -38,6 +52,7 @@ export function registerTagEvents(client: Client, context: BotContext): void { const payload = buildTagReply(tag, member, message.guild, ''); await message.reply(payload); + await applyTagCooldown(message.guild.id, tag.id, message.channel.id, tag.cooldownSeconds); } catch (error) { if (error instanceof TagError) { const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null); diff --git a/apps/bot/src/modules/tags/service.ts b/apps/bot/src/modules/tags/service.ts index 9734cb2..f3f4cec 100644 --- a/apps/bot/src/modules/tags/service.ts +++ b/apps/bot/src/modules/tags/service.ts @@ -14,15 +14,44 @@ import type { Tag } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import { redis } from '../../redis.js'; import type { BotContext } from '../../types.js'; import { assertPremiumLimit, PremiumLimitError } from '../../premium.js'; +import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js'; + +export { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js'; export class TagError extends Error { - constructor(public readonly code: string) { + constructor( + public readonly code: string, + public readonly retryAfterSeconds?: number + ) { super(code); } } +/** Returns remaining cooldown seconds, or 0 if ready. */ +export async function getTagCooldownRemaining( + guildId: string, + tagId: string, + scopeId: string +): Promise { + const ttl = await redis.ttl(tagCooldownKey(guildId, tagId, scopeId)); + return ttl > 0 ? ttl : 0; +} + +export async function applyTagCooldown( + guildId: string, + tagId: string, + scopeId: string, + cooldownSeconds: number +): Promise { + if (cooldownSeconds <= 0) { + return; + } + await redis.set(tagCooldownKey(guildId, tagId, scopeId), '1', 'EX', cooldownSeconds); +} + export function normalizeTagName(name: string): string { return name.trim().toLowerCase(); } @@ -147,6 +176,7 @@ export async function createTag( embed?: WelcomeEmbed | null; components?: MessageComponentsV2 | null; triggerWord?: string | null; + cooldownSeconds?: number; allowedRoleIds?: string[]; allowedChannelIds?: string[]; createdById: string; @@ -190,6 +220,7 @@ export async function createTag( embed: params.embed ?? undefined, components: params.components ?? undefined, triggerWord: params.triggerWord?.trim() || null, + cooldownSeconds: params.cooldownSeconds ?? 15, allowedRoleIds: params.allowedRoleIds ?? [], allowedChannelIds: params.allowedChannelIds ?? [], createdById: params.createdById @@ -208,6 +239,7 @@ export async function updateTag( embed?: WelcomeEmbed | null; components?: MessageComponentsV2 | null; triggerWord?: string | null; + cooldownSeconds?: number; allowedRoleIds?: string[]; allowedChannelIds?: string[]; } @@ -255,6 +287,8 @@ export async function updateTag( components: components ?? undefined, triggerWord: updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord, + cooldownSeconds: + updates.cooldownSeconds !== undefined ? updates.cooldownSeconds : existing.cooldownSeconds, allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds, allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds } @@ -294,18 +328,22 @@ export async function executeTag( throw new TagError('not_allowed'); } + const scopeId = member?.id ?? channelId; + if (tag.cooldownSeconds > 0) { + const remaining = await getTagCooldownRemaining(guildId, tag.id, scopeId); + if (remaining > 0) { + throw new TagError('cooldown', remaining); + } + } + await context.prisma.tag.update({ where: { id: tag.id }, data: { useCount: { increment: 1 } } }); - return buildTagReply(tag, member, guild, args); -} + await applyTagCooldown(guildId, tag.id, scopeId, tag.cooldownSeconds); -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); + return buildTagReply(tag, member, guild, args); } export async function findTriggeredTag( diff --git a/apps/bot/src/modules/tags/tag-helpers.test.ts b/apps/bot/src/modules/tags/tag-helpers.test.ts new file mode 100644 index 0000000..055a9c1 --- /dev/null +++ b/apps/bot/src/modules/tags/tag-helpers.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js'; + +describe('tagCooldownKey', () => { + it('builds a stable redis key', () => { + expect(tagCooldownKey('g1', 't1', 'c1')).toBe('tag:cooldown:g1:t1:c1'); + }); +}); + +describe('matchesTriggerWord', () => { + it('matches whole words case-insensitively', () => { + expect(matchesTriggerWord('Hello world', 'hello')).toBe(true); + expect(matchesTriggerWord('say helloworld', 'hello')).toBe(false); + }); +}); diff --git a/apps/bot/src/modules/tags/tag-helpers.ts b/apps/bot/src/modules/tags/tag-helpers.ts new file mode 100644 index 0000000..cf49f22 --- /dev/null +++ b/apps/bot/src/modules/tags/tag-helpers.ts @@ -0,0 +1,10 @@ +/** Redis key for tag cooldowns (guild + tag + user or channel scope). */ +export function tagCooldownKey(guildId: string, tagId: string, scopeId: string): string { + return `tag:cooldown:${guildId}:${tagId}:${scopeId}`; +} + +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); +} diff --git a/apps/bot/src/modules/tickets/command-definitions.ts b/apps/bot/src/modules/tickets/command-definitions.ts index 14b3b87..2653ae0 100644 --- a/apps/bot/src/modules/tickets/command-definitions.ts +++ b/apps/bot/src/modules/tickets/command-definitions.ts @@ -3,13 +3,14 @@ import { applyOptionDescription, localizedChoice } from '@nexumi/shared'; -import { SlashCommandBuilder } from 'discord.js'; +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; // Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings. // SPEC `/ticket panel create` is represented as subcommand `panel_create`. export const ticketCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('ticket') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((s) => applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description') .addStringOption((o) => diff --git a/apps/bot/src/modules/tickets/commands.ts b/apps/bot/src/modules/tickets/commands.ts index 2b87054..2c0edfc 100644 --- a/apps/bot/src/modules/tickets/commands.ts +++ b/apps/bot/src/modules/tickets/commands.ts @@ -3,12 +3,11 @@ import { t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { requirePermission } from '../../permissions.js'; +import { ephemeral } from '../../interaction-reply.js'; import { ticketCommandData } from './command-definitions.js'; import { addUserToTicket, assertTicketStaff, - buildPanelComponents, - buildPanelEmbed, claimTicket, closeTicket, createCategoryWithRole, @@ -17,7 +16,9 @@ import { removeUserFromTicket, renameTicket, setTicketPriority, + syncTicketPanel, TicketError, + TicketPanelError, upsertCategoryByName } from './service.js'; @@ -26,7 +27,7 @@ async function ensureManageGuild( locale: 'de' | 'en' ): Promise { if (!interaction.inGuild()) { - await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); return false; } return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale); @@ -37,7 +38,7 @@ const ticketCommand: SlashCommand = { 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 }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); return; } @@ -54,7 +55,10 @@ const ticketCommand: SlashCommand = { const categories = await getGuildCategories(context, interaction.guildId!); if (categories.length === 0) { - await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true }); + await interaction.reply({ + content: t(locale, 'ticket.error.no_categories'), + ...ephemeral() + }); return; } @@ -62,15 +66,31 @@ const ticketCommand: SlashCommand = { if (!channel?.isTextBased() || channel.isDMBased()) { await interaction.reply({ content: t(locale, 'ticket.error.invalid_channel'), - ephemeral: true + ...ephemeral() }); 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 }); + try { + await syncTicketPanel(context, interaction.guildId!, { + channelId: channel.id, + title: name, + description + }); + await interaction.reply({ content: t(locale, 'ticket.panel.created'), ...ephemeral() }); + } catch (error) { + if (error instanceof TicketPanelError) { + const key = + error.code === 'no_categories' + ? 'ticket.error.no_categories' + : error.code === 'not_configured' + ? 'ticket.error.invalid_channel' + : 'ticket.error.missing_permission'; + await interaction.reply({ content: t(locale, key), ...ephemeral() }); + return; + } + throw error; + } return; } @@ -89,18 +109,28 @@ const ticketCommand: SlashCommand = { ); await interaction.reply({ content: tf(locale, 'ticket.category.created', { name: category.name }), - ephemeral: true + ...ephemeral() }); return; } const ticket = await findTicketByChannel(context.prisma, interaction.channelId!); if (!ticket) { - await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true }); + await interaction.reply({ + content: t(locale, 'ticket.error.not_in_ticket'), + ...ephemeral() + }); return; } - const member = await interaction.guild!.members.fetch(interaction.user.id); + const member = await interaction.guild!.members.fetch(interaction.user.id).catch(() => null); + if (!member) { + await interaction.reply({ + content: t(locale, 'ticket.error.member_not_found'), + ...ephemeral() + }); + return; + } try { if (sub === 'close') { @@ -115,40 +145,57 @@ const ticketCommand: SlashCommand = { const isOpener = ticket.openerId === interaction.user.id; if (!isStaff && !isOpener) { - await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true }); + await interaction.reply({ + content: t(locale, 'ticket.error.not_staff'), + ...ephemeral() + }); 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 }); + await interaction.reply({ content: t(locale, 'ticket.close.success'), ...ephemeral() }); return; } if (sub === 'claim') { await claimTicket(context, ticket, member, locale); - await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() }); return; } if (sub === 'add') { const user = interaction.options.getUser('user', true); - const target = await interaction.guild!.members.fetch(user.id); + const target = await interaction.guild!.members.fetch(user.id).catch(() => null); + if (!target) { + await interaction.reply({ + content: t(locale, 'ticket.error.member_not_found'), + ...ephemeral() + }); + return; + } await addUserToTicket(context, ticket, member, target, locale); await interaction.reply({ content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }), - ephemeral: true + ...ephemeral() }); return; } if (sub === 'remove') { const user = interaction.options.getUser('user', true); - const target = await interaction.guild!.members.fetch(user.id); + const target = await interaction.guild!.members.fetch(user.id).catch(() => null); + if (!target) { + await interaction.reply({ + content: t(locale, 'ticket.error.member_not_found'), + ...ephemeral() + }); + return; + } await removeUserFromTicket(context, ticket, member, target, locale); await interaction.reply({ content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }), - ephemeral: true + ...ephemeral() }); return; } @@ -158,7 +205,7 @@ const ticketCommand: SlashCommand = { await renameTicket(context, ticket, member, name, locale); await interaction.reply({ content: tf(locale, 'ticket.rename.success', { name }), - ephemeral: true + ...ephemeral() }); return; } @@ -170,14 +217,14 @@ const ticketCommand: SlashCommand = { content: tf(locale, 'ticket.priority.success', { priority: t(locale, `ticket.priority.${level}`) }), - ephemeral: true + ...ephemeral() }); } } catch (error) { if (error instanceof TicketError) { await interaction.reply({ content: t(locale, `ticket.error.${error.code}`), - ephemeral: true + ...ephemeral() }); return; } diff --git a/apps/bot/src/modules/tickets/index.ts b/apps/bot/src/modules/tickets/index.ts index 3464c30..a69d555 100644 --- a/apps/bot/src/modules/tickets/index.ts +++ b/apps/bot/src/modules/tickets/index.ts @@ -1,4 +1,11 @@ export { ticketsCommands } from './commands.js'; export { isTicketInteraction, handleTicketInteraction } from './interactions.js'; -export { closeInactiveTickets, checkInactiveTickets } from './service.js'; +export { + closeInactiveTickets, + checkInactiveTickets, + runTicketPanelSyncJob, + runTicketDashboardActionJob, + TicketPanelError +} from './service.js'; export { registerTicketEvents } from './events.js'; + diff --git a/apps/bot/src/modules/tickets/interactions.ts b/apps/bot/src/modules/tickets/interactions.ts index e8c8fea..98c5eb0 100644 --- a/apps/bot/src/modules/tickets/interactions.ts +++ b/apps/bot/src/modules/tickets/interactions.ts @@ -1,17 +1,32 @@ import type { ButtonInteraction, ModalSubmitInteraction, - StringSelectMenuInteraction + StringSelectMenuInteraction, + UserSelectMenuInteraction } from 'discord.js'; import { t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; +import { ephemeral } from '../../interaction-reply.js'; import { + addUserToTicket, + assertTicketStaff, buildOpenModal, + claimTicket, + closeTicket, + findTicketByChannel, + isTicketControlInteraction, openTicketForCategory, parseFormFields, parseRateButton, rateTicket, + removeUserFromTicket, + setTicketPriority, + TICKET_CTRL_ADD, + TICKET_CTRL_CLAIM, + TICKET_CTRL_CLOSE, + TICKET_CTRL_PRIORITY, + TICKET_CTRL_REMOVE, TICKET_MODAL_PREFIX, TICKET_OPEN_PREFIX, TICKET_RATE_PREFIX, @@ -24,37 +39,45 @@ export function isTicketInteraction(customId: string): boolean { customId.startsWith(TICKET_OPEN_PREFIX) || customId === TICKET_SELECT_ID || customId.startsWith(TICKET_MODAL_PREFIX) || - customId.startsWith(TICKET_RATE_PREFIX) + customId.startsWith(TICKET_RATE_PREFIX) || + isTicketControlInteraction(customId) ); } async function replyTicketError( - interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, + interaction: + | ButtonInteraction + | StringSelectMenuInteraction + | ModalSubmitInteraction + | UserSelectMenuInteraction, locale: 'de' | 'en', code: string ): Promise { const content = t(locale, `ticket.error.${code}`); if (interaction.replied || interaction.deferred) { - await interaction.followUp({ content, ephemeral: true }); + await interaction.followUp({ content, ...ephemeral() }); } else { - await interaction.reply({ content, ephemeral: true }); + await interaction.reply({ content, ...ephemeral() }); } } -export async function handleOpenTicketRequest( +async function handleOpenTicketRequest( interaction: ButtonInteraction | StringSelectMenuInteraction, context: BotContext, categoryId: string ): Promise { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!interaction.inGuild() || !interaction.guild) { - await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); 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 }); + await interaction.reply({ + content: t(locale, 'ticket.error.category_not_found'), + ...ephemeral() + }); return; } @@ -65,7 +88,14 @@ export async function handleOpenTicketRequest( } try { - const member = await interaction.guild.members.fetch(interaction.user.id); + const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null); + if (!member) { + await interaction.reply({ + content: t(locale, 'ticket.error.member_not_found'), + ...ephemeral() + }); + return; + } const ticket = await openTicketForCategory( context, interaction.guild, @@ -76,7 +106,7 @@ export async function handleOpenTicketRequest( const channelRef = ticket.channelId ?? ticket.threadId; await interaction.reply({ content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }), - ephemeral: true + ...ephemeral() }); } catch (error) { if (error instanceof TicketError) { @@ -87,8 +117,131 @@ export async function handleOpenTicketRequest( } } +async function handleTicketControl( + interaction: ButtonInteraction | UserSelectMenuInteraction | StringSelectMenuInteraction, + context: BotContext +): Promise { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); + return; + } + + const ticket = await findTicketByChannel(context.prisma, interaction.channelId); + if (!ticket) { + await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ...ephemeral() }); + return; + } + + const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null); + if (!member) { + await interaction.reply({ + content: t(locale, 'ticket.error.member_not_found'), + ...ephemeral() + }); + return; + } + + try { + if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLOSE) { + const isOpener = ticket.openerId === interaction.user.id; + let isStaff = false; + try { + await assertTicketStaff(member, ticket); + isStaff = true; + } catch { + isStaff = false; + } + if (!isStaff && !isOpener) { + await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ...ephemeral() }); + return; + } + + await interaction.deferReply(ephemeral()); + await closeTicket(context, ticket, interaction.user.id, locale); + await interaction.editReply({ content: t(locale, 'ticket.close.success') }); + return; + } + + if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLAIM) { + await claimTicket(context, ticket, member, locale); + await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() }); + return; + } + + if (interaction.isStringSelectMenu() && interaction.customId === TICKET_CTRL_PRIORITY) { + const level = interaction.values[0]; + if (!level) { + await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() }); + return; + } + await setTicketPriority(context, ticket, member, level, locale); + await interaction.reply({ + content: tf(locale, 'ticket.priority.success', { + priority: t(locale, `ticket.priority.${level}`) + }), + ...ephemeral() + }); + return; + } + + if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_ADD) { + const userId = interaction.values[0]; + if (!userId) { + await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() }); + return; + } + const target = await interaction.guild.members.fetch(userId).catch(() => null); + if (!target) { + await interaction.reply({ + content: t(locale, 'ticket.error.member_not_found'), + ...ephemeral() + }); + return; + } + await addUserToTicket(context, ticket, member, target, locale); + await interaction.reply({ + content: tf(locale, 'ticket.add.success', { user: `<@${userId}>` }), + ...ephemeral() + }); + return; + } + + if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_REMOVE) { + const userId = interaction.values[0]; + if (!userId) { + await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() }); + return; + } + const target = await interaction.guild.members.fetch(userId).catch(() => null); + if (!target) { + await interaction.reply({ + content: t(locale, 'ticket.error.member_not_found'), + ...ephemeral() + }); + return; + } + await removeUserFromTicket(context, ticket, member, target, locale); + await interaction.reply({ + content: tf(locale, 'ticket.remove.success', { user: `<@${userId}>` }), + ...ephemeral() + }); + } + } catch (error) { + if (error instanceof TicketError) { + await replyTicketError(interaction, locale, error.code); + return; + } + throw error; + } +} + export async function handleTicketInteraction( - interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, + interaction: + | ButtonInteraction + | StringSelectMenuInteraction + | ModalSubmitInteraction + | UserSelectMenuInteraction, context: BotContext ): Promise { const locale = await getGuildLocale(context.prisma, interaction.guildId); @@ -96,13 +249,27 @@ export async function handleTicketInteraction( 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 }); + await interaction.reply({ + content: t(locale, 'ticket.error.category_not_found'), + ...ephemeral() + }); return; } await handleOpenTicketRequest(interaction, context, categoryId); return; } + if ( + interaction.isButton() || + interaction.isUserSelectMenu() || + interaction.isStringSelectMenu() + ) { + if (isTicketControlInteraction(interaction.customId)) { + await handleTicketControl(interaction, context); + return; + } + } + if (interaction.isButton()) { if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) { const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length); @@ -122,7 +289,7 @@ export async function handleTicketInteraction( if (error instanceof TicketError) { await interaction.reply({ content: t(locale, `ticket.error.${error.code}`), - ephemeral: true + ...ephemeral() }); return; } @@ -134,14 +301,17 @@ export async function handleTicketInteraction( if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) { if (!interaction.inGuild() || !interaction.guild) { - await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); 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 }); + await interaction.reply({ + content: t(locale, 'ticket.error.category_not_found'), + ...ephemeral() + }); return; } @@ -156,7 +326,14 @@ export async function handleTicketInteraction( } try { - const member = await interaction.guild.members.fetch(interaction.user.id); + const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null); + if (!member) { + await interaction.reply({ + content: t(locale, 'ticket.error.member_not_found'), + ...ephemeral() + }); + return; + } const ticket = await openTicketForCategory( context, interaction.guild, @@ -169,7 +346,7 @@ export async function handleTicketInteraction( const channelRef = ticket.channelId ?? ticket.threadId; await interaction.reply({ content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }), - ephemeral: true + ...ephemeral() }); } catch (error) { if (error instanceof TicketError) { diff --git a/apps/bot/src/modules/tickets/service.ts b/apps/bot/src/modules/tickets/service.ts index 8adef4c..99ee497 100644 --- a/apps/bot/src/modules/tickets/service.ts +++ b/apps/bot/src/modules/tickets/service.ts @@ -10,9 +10,14 @@ import { StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, + UserSelectMenuBuilder, + type APIMessageComponentEmoji, type Guild, + type GuildBasedChannel, type GuildMember, + type GuildTextBasedChannel, type Message, + type Role, type SendableChannels, type TextChannel, type ThreadChannel @@ -34,6 +39,14 @@ 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 TICKET_CTRL_CLAIM = 'ticket:ctrl:claim'; +export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close'; +export const TICKET_CTRL_ADD = 'ticket:ctrl:add'; +export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove'; +export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority'; + +/** Cap private-thread staff adds to stay within Discord rate limits. */ +const MAX_THREAD_SUPPORT_MEMBERS = 50; export const TicketFormFieldSchema = z.object({ id: z.string(), @@ -51,6 +64,189 @@ export class TicketError extends Error { } } +export class TicketPanelError extends Error { + constructor(public readonly code: 'not_configured' | 'missing_permission' | 'no_categories') { + super(code); + this.name = 'TicketPanelError'; + } +} + +function isUnknownChannelError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error as { code: unknown }).code === 10003 + ); +} + +function botCanPostPanel( + channel: GuildBasedChannel, + meId: string +): channel is GuildTextBasedChannel { + if (!channel.isTextBased() || channel.isDMBased()) { + return false; + } + const permissions = channel.permissionsFor(meId); + if (!permissions) { + return false; + } + return ( + permissions.has(PermissionFlagsBits.ViewChannel) && + permissions.has(PermissionFlagsBits.SendMessages) && + permissions.has(PermissionFlagsBits.EmbedLinks) + ); +} + +function parseButtonEmoji(emoji?: string | null): 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 }; +} + +/** + * `Role#members` only includes cached guild members. Fetch the member list when + * the cache looks empty so support staff can be added to private threads. + */ +async function resolveSupportMembers(guild: Guild, roleIds: string[]): Promise { + if (roleIds.length === 0) { + return []; + } + + const roles: Role[] = []; + for (const roleId of roleIds) { + const role = + guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null)); + if (role) { + roles.push(role); + } + } + + if (roles.length === 0) { + return []; + } + + const cachedCount = roles.reduce((sum, role) => sum + role.members.size, 0); + if (cachedCount === 0) { + await guild.members.fetch().catch((error) => { + logger.warn({ error, guildId: guild.id }, 'Failed to fetch members for ticket support roles'); + }); + } + + const seen = new Set(); + const members: GuildMember[] = []; + for (const role of roles) { + for (const member of role.members.values()) { + if (member.user.bot || seen.has(member.id)) { + continue; + } + seen.add(member.id); + members.push(member); + if (members.length >= MAX_THREAD_SUPPORT_MEMBERS) { + return members; + } + } + } + return members; +} + +export function isTicketControlInteraction(customId: string): boolean { + return ( + customId === TICKET_CTRL_CLAIM || + customId === TICKET_CTRL_CLOSE || + customId === TICKET_CTRL_ADD || + customId === TICKET_CTRL_REMOVE || + customId === TICKET_CTRL_PRIORITY + ); +} + +export function buildTicketControlPanel(locale: 'de' | 'en'): { + embeds: EmbedBuilder[]; + components: ActionRowBuilder< + ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder + >[]; +} { + const embed = new EmbedBuilder() + .setTitle(t(locale, 'ticket.control.title')) + .setDescription(t(locale, 'ticket.control.description')) + .setColor(0x6366f1); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(TICKET_CTRL_CLAIM) + .setLabel(t(locale, 'ticket.control.claim')) + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(TICKET_CTRL_CLOSE) + .setLabel(t(locale, 'ticket.control.close')) + .setStyle(ButtonStyle.Danger) + ); + + const priorityRow = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(TICKET_CTRL_PRIORITY) + .setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder')) + .addOptions( + { + label: t(locale, 'ticket.priority.LOW'), + value: 'LOW', + description: t(locale, 'ticket.control.priorityLowHint') + }, + { + label: t(locale, 'ticket.priority.NORMAL'), + value: 'NORMAL', + description: t(locale, 'ticket.control.priorityNormalHint') + }, + { + label: t(locale, 'ticket.priority.HIGH'), + value: 'HIGH', + description: t(locale, 'ticket.control.priorityHighHint') + }, + { + label: t(locale, 'ticket.priority.URGENT'), + value: 'URGENT', + description: t(locale, 'ticket.control.priorityUrgentHint') + } + ) + ); + + const addRow = new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder() + .setCustomId(TICKET_CTRL_ADD) + .setPlaceholder(t(locale, 'ticket.control.addPlaceholder')) + .setMinValues(1) + .setMaxValues(1) + ); + + const removeRow = new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder() + .setCustomId(TICKET_CTRL_REMOVE) + .setPlaceholder(t(locale, 'ticket.control.removePlaceholder')) + .setMinValues(1) + .setMaxValues(1) + ); + + return { + embeds: [embed], + components: [buttons, priorityRow, addRow, removeRow] + }; +} + async function fetchSendableChannel( context: BotContext, channelId: string @@ -185,8 +381,9 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui .setCustomId(openButtonId(category.id)) .setLabel(category.name.slice(0, 80)) .setStyle(ButtonStyle.Primary); - if (category.emoji) { - button.setEmoji(category.emoji); + const emoji = parseButtonEmoji(category.emoji); + if (emoji) { + button.setEmoji(emoji); } row.addComponents(button); } @@ -197,17 +394,208 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui .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 - })) + categories.slice(0, 25).map((category) => { + const emoji = parseButtonEmoji(category.emoji); + return { + label: category.name.slice(0, 100), + value: category.id, + description: category.description?.slice(0, 100) ?? undefined, + emoji + }; + }) ); return [new ActionRowBuilder().addComponents(select)]; } +/** + * Posts or updates the ticket panel. Used by `/ticket panel_create` and by the + * dashboard via BullMQ (`ticketPanelSync`). + */ +export async function syncTicketPanel( + context: BotContext, + guildId: string, + options: { + channelId?: string | null; + title?: string; + description?: string | null; + } = {} +): Promise<{ panelChannelId: string; panelMessageId: string }> { + const locale = await getGuildLocale(context.prisma, guildId); + const config = await getTicketConfig(context.prisma, guildId); + const categories = await getGuildCategories(context, guildId); + + if (categories.length === 0) { + throw new TicketPanelError('no_categories'); + } + + const targetChannelId = options.channelId || config.panelChannelId; + if (!targetChannelId) { + throw new TicketPanelError('not_configured'); + } + + const guild = await context.client.guilds.fetch(guildId); + const me = guild.members.me; + if (!me) { + throw new TicketPanelError('missing_permission'); + } + + const channel = await guild.channels.fetch(targetChannelId).catch(() => null); + if (!channel || !botCanPostPanel(channel, me.id)) { + throw new TicketPanelError('missing_permission'); + } + + const title = options.title ?? t(locale, 'ticket.panel.defaultTitle'); + const description = + options.description !== undefined + ? options.description + : t(locale, 'ticket.panel.defaultDescription'); + const payload = { + embeds: [buildPanelEmbed(locale, title, description)], + components: buildPanelComponents(categories) + }; + + const sameChannel = + Boolean(config.panelMessageId) && config.panelChannelId === channel.id; + + if (sameChannel && config.panelMessageId) { + try { + const existing = await channel.messages.fetch(config.panelMessageId); + await existing.edit(payload); + if (!config.panelChannelId) { + await context.prisma.ticketConfig.update({ + where: { guildId }, + data: { panelChannelId: channel.id } + }); + } + return { panelChannelId: channel.id, panelMessageId: existing.id }; + } catch (error) { + logger.warn( + { error, guildId, messageId: config.panelMessageId }, + 'Failed to edit ticket panel; recreating' + ); + } + } + + if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) { + try { + const oldChannel = await guild.channels.fetch(config.panelChannelId); + if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) { + const oldMessage = await oldChannel.messages.fetch(config.panelMessageId); + await oldMessage.delete(); + } + } catch { + // Previous panel may already be gone + } + } + + const message = await channel.send(payload); + await context.prisma.ticketConfig.update({ + where: { guildId }, + data: { + panelChannelId: channel.id, + panelMessageId: message.id + } + }); + + return { panelChannelId: channel.id, panelMessageId: message.id }; +} + +export async function runTicketPanelSyncJob( + context: BotContext, + data: { guildId: string } +): Promise<{ panelChannelId: string; panelMessageId: string }> { + return syncTicketPanel(context, data.guildId); +} + +/** + * Dashboard ticket actions (close / claim / priority). Staff checks are done by + * the WebUI (`requireGuildAccess`); the bot applies Discord side effects. + */ +export async function runTicketDashboardActionJob( + context: BotContext, + data: { + ticketId: string; + guildId: string; + actorUserId: string; + action: 'close' | 'claim' | 'priority'; + reason?: string; + priority?: string; + } +): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> { + const ticket = await context.prisma.ticket.findFirst({ + where: { id: data.ticketId, guildId: data.guildId }, + include: { category: true } + }); + if (!ticket) { + throw new TicketError('not_found'); + } + if (ticket.status === 'CLOSED') { + throw new TicketError('closed'); + } + + const locale = await getGuildLocale(context.prisma, data.guildId); + + if (data.action === 'close') { + await closeTicket(context, ticket, data.actorUserId, locale, data.reason); + const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } }); + return { + id: closed.id, + status: closed.status, + priority: closed.priority, + claimedById: closed.claimedById + }; + } + + if (data.action === 'claim') { + if (ticket.claimedById === data.actorUserId) { + throw new TicketError('already_claimed'); + } + const updated = await context.prisma.ticket.update({ + where: { id: ticket.id }, + data: { + claimedById: data.actorUserId, + 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: `<@${data.actorUserId}>` })); + } + } + return { + id: updated.id, + status: updated.status, + priority: updated.priority, + claimedById: updated.claimedById + }; + } + + const parsed = TicketPrioritySchema.parse(data.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 { + id: updated.id, + status: updated.status, + priority: updated.priority, + claimedById: updated.claimedById + }; +} + export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder { const fields = parseFormFields(category.formFields); const modal = new ModalBuilder() @@ -328,7 +716,11 @@ async function fetchTicketMessages( timestamp: message.createdAt.toISOString() })); } catch (error) { - logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages'); + if (isUnknownChannelError(error)) { + logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while fetching messages'); + } else { + logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages'); + } return []; } } @@ -395,13 +787,24 @@ export async function createTicketChannel( 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); - } + + const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds); + for (const member of supportMembers) { + if (member.id === params.opener.id) { + continue; } + await thread.members.add(member.id).catch((error) => { + logger.warn( + { error, threadId: thread.id, userId: member.id }, + 'Failed to add support member to ticket thread' + ); + }); + } + if (supportRoleIds.length > 0 && supportMembers.length === 0) { + logger.warn( + { guildId: params.guild.id, supportRoleIds }, + 'No cached members found for ticket support roles – staff may need Server Members intent coverage' + ); } threadId = thread.id; } else { @@ -436,10 +839,13 @@ export async function createTicketChannel( const targetId = channelId ?? threadId!; const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel; + + const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' '); const introLines = [ + roleMentions || null, tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }), params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null - ].filter(Boolean); + ].filter(Boolean) as string[]; if (params.formAnswers && Object.keys(params.formAnswers).length > 0) { const answerLines = Object.entries(params.formAnswers).map( @@ -448,7 +854,17 @@ export async function createTicketChannel( introLines.push(answerLines.join('\n')); } - await target.send({ content: introLines.join('\n') }); + await target.send({ + content: introLines.join('\n'), + allowedMentions: { + roles: supportRoleIds, + users: [params.opener.id] + } + }); + + const control = buildTicketControlPanel(locale); + await target.send(control); + return ticket; } @@ -720,7 +1136,11 @@ export async function closeTicket( await channel.delete(`Ticket ${ticket.id} closed`); } } catch (error) { - logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel'); + if (isUnknownChannelError(error)) { + logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while deleting'); + } else { + logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel'); + } } } } diff --git a/apps/bot/src/modules/utility/commands.ts b/apps/bot/src/modules/utility/commands.ts index c5facb5..befcdeb 100644 --- a/apps/bot/src/modules/utility/commands.ts +++ b/apps/bot/src/modules/utility/commands.ts @@ -257,7 +257,9 @@ const emojiCommand: SlashCommand = { async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureGuildCommand(interaction, locale))) return; - if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) { + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, { + botPermissions: PermissionFlagsBits.ManageGuildExpressions + }))) { return; } @@ -326,7 +328,9 @@ const stickerCommand: SlashCommand = { async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureGuildCommand(interaction, locale))) return; - if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) { + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, { + botPermissions: PermissionFlagsBits.ManageGuildExpressions + }))) { return; } @@ -419,7 +423,9 @@ const embedCommand: SlashCommand = { async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureGuildCommand(interaction, locale))) return; - if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) { + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale, { + botPermissions: PermissionFlagsBits.ManageMessages + }))) { return; } diff --git a/apps/bot/src/modules/utility/context-menus.ts b/apps/bot/src/modules/utility/context-menus.ts index 928c3b6..c618c2c 100644 --- a/apps/bot/src/modules/utility/context-menus.ts +++ b/apps/bot/src/modules/utility/context-menus.ts @@ -32,17 +32,18 @@ const translateContextMenu: ContextMenuCommand = { return; } + await interaction.deferReply({ ephemeral: true }); + const targetLang = locale === 'de' ? 'de' : 'en'; const translated = await translateText(content, targetLang); if (!translated) { - await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true }); + await interaction.editReply({ content: t(locale, 'utility.translate.failed') }); return; } - await interaction.reply({ - content: t(locale, 'utility.translate.result').replace('{text}', translated), - ephemeral: true + await interaction.editReply({ + content: t(locale, 'utility.translate.result').replace('{text}', translated) }); } }; diff --git a/apps/bot/src/modules/utility/reminders.ts b/apps/bot/src/modules/utility/reminders.ts index 9d095a9..8086005 100644 --- a/apps/bot/src/modules/utility/reminders.ts +++ b/apps/bot/src/modules/utility/reminders.ts @@ -2,6 +2,7 @@ import { type TextChannel } from 'discord.js'; import { t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { ensureGuild } from '../../guild.js'; +import { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; import { reminderQueue } from '../../queues.js'; import { parseWhen, UtilityError } from './service.js'; @@ -107,9 +108,7 @@ export async function deleteReminder( if (match.jobId) { const job = await reminderQueue.getJob(match.jobId); - if (job) { - await job.remove(); - } + await safeRemoveBullJob(job, { label: 'reminder', jobId: match.jobId }); } await context.prisma.reminder.delete({ where: { id: match.id } }); diff --git a/apps/bot/src/modules/verification/commands.ts b/apps/bot/src/modules/verification/commands.ts index 4de4338..764c2e2 100644 --- a/apps/bot/src/modules/verification/commands.ts +++ b/apps/bot/src/modules/verification/commands.ts @@ -1,8 +1,4 @@ -import { - PermissionFlagsBits, - type GuildBasedChannel, - type GuildTextBasedChannel -} from 'discord.js'; +import { PermissionFlagsBits } from 'discord.js'; import { t } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; @@ -10,25 +6,7 @@ import { requirePermission } from '../../permissions.js'; import { ephemeral } from '../../interaction-reply.js'; import { verifyCommandData } from './command-definitions.js'; import { getVerificationConfig, updateVerificationConfig } from './service.js'; -import { buildVerificationPanel } from './handlers.js'; - -function botCanPostPanel( - channel: GuildBasedChannel, - meId: string -): channel is GuildTextBasedChannel { - if (!channel.isTextBased() || channel.isDMBased()) { - return false; - } - const permissions = channel.permissionsFor(meId); - if (!permissions) { - return false; - } - return ( - permissions.has(PermissionFlagsBits.ViewChannel) && - permissions.has(PermissionFlagsBits.SendMessages) && - permissions.has(PermissionFlagsBits.EmbedLinks) - ); -} +import { syncVerificationPanel, VerificationPanelError } from './handlers.js'; const verifyCommand: SlashCommand = { data: verifyCommandData, @@ -91,31 +69,21 @@ const verifyCommand: SlashCommand = { } const panelChannelOption = interaction.options.getChannel('channel'); - const panelChannel = panelChannelOption - ? await interaction.guild!.channels.fetch(panelChannelOption.id) - : config.channelId - ? await interaction.guild!.channels.fetch(config.channelId) - : null; - const me = interaction.guild!.members.me; - if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) { - await interaction.reply({ - content: t(locale, 'verification.panelMissingPermission'), - ...ephemeral() - }); - return; - } - - const panel = buildVerificationPanel(config, locale); - const message = await panelChannel.send(panel); - await context.prisma.verificationConfig.update({ - where: { guildId }, - data: { - panelChannelId: panelChannel.id, - panelMessageId: message.id + try { + await syncVerificationPanel(context, guildId, panelChannelOption?.id ?? null); + await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() }); + } catch (error) { + if (error instanceof VerificationPanelError) { + const key = + error.code === 'not_configured' + ? 'verification.notConfigured' + : 'verification.panelMissingPermission'; + await interaction.reply({ content: t(locale, key), ...ephemeral() }); + return; } - }); - await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() }); + throw error; + } } }; diff --git a/apps/bot/src/modules/verification/handlers.ts b/apps/bot/src/modules/verification/handlers.ts index e1c8061..74b3da3 100644 --- a/apps/bot/src/modules/verification/handlers.ts +++ b/apps/bot/src/modules/verification/handlers.ts @@ -5,7 +5,9 @@ import { EmbedBuilder, PermissionFlagsBits, type ButtonInteraction, - type GuildMember + type GuildBasedChannel, + type GuildMember, + type GuildTextBasedChannel } from 'discord.js'; import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; @@ -22,6 +24,32 @@ import { detectAltAccount } from './alt-detection.js'; import { env } from '../../env.js'; import { logger } from '../../logger.js'; import { ephemeral } from '../../interaction-reply.js'; +import { notifyModerationTargetDm } from '../moderation/service.js'; + +export class VerificationPanelError extends Error { + constructor(public readonly code: 'not_configured' | 'missing_permission') { + super(code); + this.name = 'VerificationPanelError'; + } +} + +function botCanPostPanel( + channel: GuildBasedChannel, + meId: string +): channel is GuildTextBasedChannel { + if (!channel.isTextBased() || channel.isDMBased()) { + return false; + } + const permissions = channel.permissionsFor(meId); + if (!permissions) { + return false; + } + return ( + permissions.has(PermissionFlagsBits.ViewChannel) && + permissions.has(PermissionFlagsBits.SendMessages) && + permissions.has(PermissionFlagsBits.EmbedLinks) + ); +} export type CompleteVerificationOptions = { ipHash?: string | null; @@ -30,20 +58,61 @@ export type CompleteVerificationOptions = { }; async function applyFailAction( + context: BotContext, member: GuildMember, failAction: string, reason: string ): Promise { const me = member.guild.members.me; + if (failAction !== 'KICK' && failAction !== 'BAN') { + return; + } + const locale = await getGuildLocale(context.prisma, member.guild.id); if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) { + await notifyModerationTargetDm({ + user: member.user, + guildName: member.guild.name, + reason, + locale, + action: 'kick' + }); await member.kick(reason); return; } if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) { + await notifyModerationTargetDm({ + user: member.user, + guildName: member.guild.name, + reason, + locale, + action: 'ban' + }); await member.guild.members.ban(member.id, { reason }); } } +async function logVerificationFailure( + context: BotContext, + member: GuildMember, + input: { + reason: string; + detail?: string; + failAction?: string; + matchedUserId?: string | null; + } +): Promise { + const { logVerificationFail } = await import('../logging/events.js'); + await logVerificationFail(context, { + guildId: member.guild.id, + userId: member.id, + userTag: member.user.tag, + reason: input.reason, + detail: input.detail, + failAction: input.failAction, + matchedUserId: input.matchedUserId + }); +} + export async function completeVerification( context: BotContext, guildId: string, @@ -63,11 +132,13 @@ export async function completeVerification( const ageDays = accountAgeDays(member.user.createdAt); if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) { - await applyFailAction( - member, - config.failAction, - `Verification failed: account too young (${ageDays}d)` - ); + const failReason = `Verification failed: account too young (${ageDays}d)`; + await logVerificationFailure(context, member, { + reason: 'account_too_young', + detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`, + failAction: config.failAction + }); + await applyFailAction(context, member, config.failAction, failReason); return { ok: false, reason: 'account_too_young' }; } @@ -143,13 +214,23 @@ export async function completeVerification( }); if (config.altBlockOnMatch) { - await applyFailAction( - member, - config.failAction, - `Verification failed: possible alt account (${match.reason})` - ); + const failReason = `Verification failed: possible alt account (${match.reason})`; + await logVerificationFailure(context, member, { + reason: 'alt_detected', + detail: `Signal: ${match.reason}`, + failAction: config.failAction, + matchedUserId + }); + await applyFailAction(context, member, config.failAction, failReason); return { ok: false, reason: 'alt_detected' }; } + + await logVerificationFailure(context, member, { + reason: 'alt_detected_flagged', + detail: `Signal: ${match.reason} (flagged only, not blocked)`, + failAction: 'NONE', + matchedUserId + }); } } @@ -228,6 +309,86 @@ export function buildVerificationPanel( return { embeds: [embed], components: [row] }; } +/** + * Posts or updates the verification panel in the configured channel. + * Used by `/verify panel` and by the dashboard via BullMQ (`verificationPanelSync`). + */ +export async function syncVerificationPanel( + context: BotContext, + guildId: string, + preferredChannelId?: string | null +): Promise<{ panelChannelId: string; panelMessageId: string }> { + const locale = await getGuildLocale(context.prisma, guildId); + const config = await getVerificationConfig(context.prisma, guildId); + + if (!config.enabled || !config.verifiedRoleId) { + throw new VerificationPanelError('not_configured'); + } + + const targetChannelId = preferredChannelId || config.channelId; + if (!targetChannelId) { + throw new VerificationPanelError('not_configured'); + } + + const guild = await context.client.guilds.fetch(guildId); + const me = guild.members.me; + if (!me) { + throw new VerificationPanelError('missing_permission'); + } + + const channel = await guild.channels.fetch(targetChannelId).catch(() => null); + if (!channel || !botCanPostPanel(channel, me.id)) { + throw new VerificationPanelError('missing_permission'); + } + + const panel = buildVerificationPanel(config, locale); + const sameChannel = + Boolean(config.panelMessageId) && config.panelChannelId === channel.id; + + if (sameChannel && config.panelMessageId) { + try { + const existing = await channel.messages.fetch(config.panelMessageId); + await existing.edit(panel); + return { panelChannelId: channel.id, panelMessageId: existing.id }; + } catch (error) { + logger.warn( + { error, guildId, messageId: config.panelMessageId }, + 'Failed to edit verification panel; recreating' + ); + } + } + + if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) { + try { + const oldChannel = await guild.channels.fetch(config.panelChannelId); + if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) { + const oldMessage = await oldChannel.messages.fetch(config.panelMessageId); + await oldMessage.delete(); + } + } catch { + // Previous panel may already be gone + } + } + + const message = await channel.send(panel); + await context.prisma.verificationConfig.update({ + where: { guildId }, + data: { + panelChannelId: channel.id, + panelMessageId: message.id + } + }); + + return { panelChannelId: channel.id, panelMessageId: message.id }; +} + +export async function runVerificationPanelSyncJob( + context: BotContext, + data: { guildId: string } +): Promise<{ panelChannelId: string; panelMessageId: string }> { + return syncVerificationPanel(context, data.guildId); +} + export async function handleVerificationButton( interaction: ButtonInteraction, context: BotContext diff --git a/apps/bot/src/modules/welcome/events.ts b/apps/bot/src/modules/welcome/events.ts index f344ffe..7ccdc9c 100644 --- a/apps/bot/src/modules/welcome/events.ts +++ b/apps/bot/src/modules/welcome/events.ts @@ -1,23 +1,75 @@ -import { PermissionFlagsBits, type GuildMember } from 'discord.js'; +import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js'; +import { t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js'; import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js'; import { logger } from '../../logger.js'; +async function resolveRole(member: GuildMember, roleId: string): Promise { + const cached = member.guild.roles.cache.get(roleId); + if (cached) { + return cached; + } + return member.guild.roles.fetch(roleId).catch(() => null); +} + async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise { - const me = member.guild.members.me; - if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + const uniqueIds = [...new Set(roleIds.filter(Boolean))]; + if (uniqueIds.length === 0) { return; } - const assignable = roleIds.filter((roleId) => { - const role = member.guild.roles.cache.get(roleId); - return role && role.position < me.roles.highest.position; - }); + + const me = + member.guild.members.me ?? (await member.guild.members.fetchMe().catch(() => null)); + if (!me) { + logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: bot member unavailable'); + return; + } + if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) { + logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: missing ManageRoles permission'); + return; + } + + const assignable: string[] = []; + for (const roleId of uniqueIds) { + if (roleId === member.guild.id) { + continue; + } + if (member.roles.cache.has(roleId)) { + continue; + } + + const role = await resolveRole(member, roleId); + if (!role) { + logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: role not found'); + continue; + } + if (role.managed) { + logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: managed role'); + continue; + } + if (!role.editable) { + logger.warn( + { + guildId: member.guild.id, + roleId, + rolePosition: role.position, + botHighest: me.roles.highest.position + }, + 'Autorole skipped: role above bot or not editable' + ); + continue; + } + assignable.push(roleId); + } + if (assignable.length === 0) { return; } - await member.roles.add(assignable).catch((error) => { - logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles'); + + await member.roles.add(assignable, 'Nexumi welcome autorole').catch((error) => { + logger.warn({ error, memberId: member.id, assignable }, 'Failed to assign autoroles'); }); } @@ -56,6 +108,33 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember return; } await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild); + + if (!config.leaveAnnounceBan || member.user.bot) { + return; + } + + const ban = await member.guild.bans.fetch(member.id).catch(() => null); + if (!ban) { + return; + } + + const locale = await getGuildLocale(context.prisma, member.guild.id); + const reason = ban.reason ?? t(locale, 'moderation.reason.none'); + if ('send' in channel) { + await channel + .send( + tf(locale, 'welcome.leave.bannedNotice', { + user: member.user.tag, + reason + }) + ) + .catch((error) => { + logger.warn( + { error, guildId: member.guild.id, userId: member.id }, + 'Failed to send leave ban notice' + ); + }); + } } export function registerWelcomeEvents(context: BotContext): void { diff --git a/apps/bot/src/modules/welcome/renderer.ts b/apps/bot/src/modules/welcome/renderer.ts index 339d880..2b2eb8c 100644 --- a/apps/bot/src/modules/welcome/renderer.ts +++ b/apps/bot/src/modules/welcome/renderer.ts @@ -10,7 +10,7 @@ import { createCanvas, loadImage } from '@napi-rs/canvas'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import type { LeavePayload, WelcomePayload } from './service.js'; -import { renderWelcomeText } from './service.js'; +import { renderWelcomeEmbedText, renderWelcomeText } from './service.js'; export async function buildWelcomeMessage( payload: WelcomePayload, @@ -31,7 +31,7 @@ export async function buildWelcomeMessage( if (payload.type === 'EMBED') { const embed = buildEmbedFromPayload(payload.embed, { - renderText: (value) => renderWelcomeText(value, member, guild), + renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field), defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) }); return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) }; @@ -77,14 +77,15 @@ async function renderWelcomeCard( const title = payload.imageTitle ?? 'Welcome!'; const subtitle = payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`; + const plain = { mentionUser: false as const }; ctx.fillStyle = '#f9fafb'; ctx.font = 'bold 36px sans-serif'; - ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130); + ctx.fillText(renderWelcomeText(title, member, guild, plain).slice(0, 40), 220, 130); ctx.fillStyle = '#d1d5db'; ctx.font = '24px sans-serif'; - ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180); + ctx.fillText(renderWelcomeText(subtitle, member, guild, plain).slice(0, 60), 220, 180); return canvas.toBuffer('image/png'); } @@ -119,7 +120,7 @@ export async function buildLeaveMessage( if (payload.type === 'EMBED') { const embed = buildEmbedFromPayload(payload.embed, { - renderText: (value) => renderWelcomeText(value, member, guild), + renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field), defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) }); if (embed) { diff --git a/apps/bot/src/modules/welcome/service.ts b/apps/bot/src/modules/welcome/service.ts index 4d5c7c0..472a4b0 100644 --- a/apps/bot/src/modules/welcome/service.ts +++ b/apps/bot/src/modules/welcome/service.ts @@ -13,9 +13,23 @@ export async function getWelcomeConfig(prisma: PrismaClient, guildId: string) { return config; } -export function buildPlaceholderVars(member: GuildMember, guild: Guild) { +export type WelcomePlaceholderOptions = { + /** + * When false, `{user}` becomes the member display name instead of `<@id>`. + * Required for embed title/author/footer and canvas text — Discord does not + * resolve mentions in those surfaces. + */ + mentionUser?: boolean; +}; + +export function buildPlaceholderVars( + member: GuildMember, + guild: Guild, + options: WelcomePlaceholderOptions = {} +) { + const mentionUser = options.mentionUser !== false; return { - user: `<@${member.id}>`, + user: mentionUser ? `<@${member.id}>` : member.displayName, 'user.name': member.displayName, 'user.tag': member.user.tag, 'user.id': member.id, @@ -26,8 +40,27 @@ export function buildPlaceholderVars(member: GuildMember, guild: Guild) { }; } -export function renderWelcomeText(template: string, member: GuildMember, guild: Guild): string { - return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild)); +export function renderWelcomeText( + template: string, + member: GuildMember, + guild: Guild, + options: WelcomePlaceholderOptions = {} +): string { + return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild, options)); +} + +/** Fields where Discord will not turn `<@id>` into a clickable mention. */ +const EMBED_PLAIN_USER_FIELDS = new Set(['title', 'authorName', 'footerText']); + +export function renderWelcomeEmbedText( + template: string, + member: GuildMember, + guild: Guild, + field: string +): string { + return renderWelcomeText(template, member, guild, { + mentionUser: !EMBED_PLAIN_USER_FIELDS.has(field) + }); } export function parseWelcomeEmbed(raw: unknown): WelcomeEmbed | null { diff --git a/apps/bot/src/permissions.ts b/apps/bot/src/permissions.ts index 1773960..f5fccc5 100644 --- a/apps/bot/src/permissions.ts +++ b/apps/bot/src/permissions.ts @@ -1,14 +1,49 @@ -import { type ChatInputCommandInteraction } from 'discord.js'; +import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import { t } from '@nexumi/shared'; import type { Locale } from '@nexumi/shared'; +const PERMISSION_LABELS: Partial> = { + [PermissionFlagsBits.Administrator.toString()]: 'Administrator', + [PermissionFlagsBits.ManageGuild.toString()]: 'Manage Server', + [PermissionFlagsBits.ManageChannels.toString()]: 'Manage Channels', + [PermissionFlagsBits.ManageRoles.toString()]: 'Manage Roles', + [PermissionFlagsBits.ManageMessages.toString()]: 'Manage Messages', + [PermissionFlagsBits.ManageNicknames.toString()]: 'Manage Nicknames', + [PermissionFlagsBits.ManageGuildExpressions.toString()]: 'Manage Expressions', + [PermissionFlagsBits.KickMembers.toString()]: 'Kick Members', + [PermissionFlagsBits.BanMembers.toString()]: 'Ban Members', + [PermissionFlagsBits.ModerateMembers.toString()]: 'Timeout Members', + [PermissionFlagsBits.ViewChannel.toString()]: 'View Channel', + [PermissionFlagsBits.SendMessages.toString()]: 'Send Messages', + [PermissionFlagsBits.EmbedLinks.toString()]: 'Embed Links', + [PermissionFlagsBits.AttachFiles.toString()]: 'Attach Files', + [PermissionFlagsBits.ReadMessageHistory.toString()]: 'Read Message History' +}; + +function permissionLabel(permission: bigint): string { + return PERMISSION_LABELS[permission.toString()] ?? permission.toString(); +} + +export type RequirePermissionOptions = { + /** + * Discord permissions the bot itself must hold. Omit for dashboard-style + * member gates (e.g. ManageGuild) where the bot does not need the same bit. + */ + botPermissions?: bigint | readonly bigint[]; +}; + +/** + * Ensures the invoking member has `memberPermission`. Optionally also checks + * that the bot has one or more `botPermissions` (moderation actions). + */ export async function requirePermission( interaction: ChatInputCommandInteraction, - permission: bigint, - locale: Locale + memberPermission: bigint, + locale: Locale, + options?: RequirePermissionOptions ): Promise { const member = interaction.memberPermissions; - if (!member?.has(permission)) { + if (!member?.has(memberPermission)) { await interaction.reply({ content: t(locale, 'generic.noPermission'), ephemeral: true @@ -16,16 +51,36 @@ export async function requirePermission( return false; } - if (!interaction.guild?.members.me?.permissions.has(permission)) { + const botBits = options?.botPermissions; + if (botBits === undefined) { + return true; + } + + const required = Array.isArray(botBits) ? botBits : [botBits]; + const me = interaction.guild?.members.me; + if (!me) { await interaction.reply({ content: t(locale, 'generic.botMissingPermission').replace( '{permission}', - permission.toString() + required.map(permissionLabel).join(', ') ), ephemeral: true }); return false; } + for (const bit of required) { + if (!me.permissions.has(bit)) { + await interaction.reply({ + content: t(locale, 'generic.botMissingPermission').replace( + '{permission}', + permissionLabel(bit) + ), + ephemeral: true + }); + return false; + } + } + return true; } diff --git a/apps/bot/src/presence.ts b/apps/bot/src/presence.ts index c94bdff..2be5608 100644 --- a/apps/bot/src/presence.ts +++ b/apps/bot/src/presence.ts @@ -120,6 +120,23 @@ export async function readPresenceConfig(context: BotContext): Promise { const config = await readPresenceConfig(context); + + if (config.maintenanceMode) { + const text = + config.maintenanceMessage?.trim() || + 'Maintenance mode — please try again later.'; + await context.client.user?.setPresence({ + status: 'dnd', + activities: [ + { + name: text.slice(0, 128), + type: ActivityType.Watching + } + ] + }); + return; + } + const messages = config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText]; const index = Math.floor(Date.now() / 60_000) % messages.length; diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index a2f1535..e08d0aa 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -15,6 +15,8 @@ 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 selfrolesQueueName = 'selfroles'; +export const selfrolesQueue = new Queue(selfrolesQueueName, { connection: redis }); export const birthdayQueueName = 'birthdays'; export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis }); export const statsQueueName = 'stats'; diff --git a/apps/webui/package.json b/apps/webui/package.json index 910df60..d5b3b86 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -12,6 +12,7 @@ "prisma:generate": "prisma generate --schema=../bot/prisma/schema.prisma" }, "dependencies": { + "@emoji-mart/data": "^1.2.1", "@nexumi/shared": "workspace:^", "@prisma/client": "^5.22.0", "@radix-ui/react-avatar": "^1.2.3", diff --git a/apps/webui/src/app/api/auth/callback/route.ts b/apps/webui/src/app/api/auth/callback/route.ts index 19fb0be..0efb2e2 100644 --- a/apps/webui/src/app/api/auth/callback/route.ts +++ b/apps/webui/src/app/api/auth/callback/route.ts @@ -51,6 +51,7 @@ export async function GET(request: NextRequest) { cookieValue = await createSession({ user, accessToken: token.access_token, + refreshToken: token.refresh_token, tokenExpiresAt: Date.now() + token.expires_in * 1000 }); } catch (error) { diff --git a/apps/webui/src/app/api/guilds/[guildId]/emojis/route.ts b/apps/webui/src/app/api/guilds/[guildId]/emojis/route.ts new file mode 100644 index 0000000..5da4ce3 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/emojis/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { listGuildEmojisForDashboard } from '@/lib/discord-guild-resources'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const emojis = await listGuildEmojisForDashboard(guildId); + return NextResponse.json(emojis); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts index 300ed0e..1e581d4 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts @@ -2,7 +2,11 @@ import { GiveawayActionSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { endGiveawayDashboard, setGiveawayPaused } from '@/lib/module-configs/giveaways'; +import { + endGiveawayDashboard, + GiveawayEndError, + setGiveawayPaused +} from '@/lib/module-configs/giveaways'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json(giveaway); } catch (error) { + if (error instanceof GiveawayEndError) { + return NextResponse.json({ error: error.message }, { status: 502 }); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts index 7d7fc52..4a92a6b 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts @@ -2,7 +2,11 @@ import { SelfRolePanelDashboardUpdateSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { deleteSelfRolePanel, updateSelfRolePanel } from '@/lib/module-configs/selfroles'; +import { + deleteSelfRolePanel, + SelfRolePanelSyncError, + updateSelfRolePanel +} from '@/lib/module-configs/selfroles'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -31,6 +35,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json(updated); } catch (error) { + if (error instanceof SelfRolePanelSyncError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } return toApiErrorResponse(error); } } @@ -54,6 +61,9 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { return NextResponse.json({ ok: true }); } catch (error) { + if (error instanceof SelfRolePanelSyncError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts b/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts index 62fdc23..ceb28f7 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts @@ -2,7 +2,11 @@ import { SelfRolePanelDashboardCreateSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { createSelfRolePanel, listSelfRolePanels } from '@/lib/module-configs/selfroles'; +import { + createSelfRolePanel, + listSelfRolePanels, + SelfRolePanelSyncError +} from '@/lib/module-configs/selfroles'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json(panel, { status: 201 }); } catch (error) { + if (error instanceof SelfRolePanelSyncError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts index 059842a..522535a 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts @@ -5,6 +5,7 @@ import { writeDashboardAudit } from '@/lib/audit'; import { deleteTicketCategory, TicketCategoryConflictError, + TicketPanelSyncError, updateTicketCategory } from '@/lib/module-configs/tickets'; import { prisma } from '@/lib/prisma'; @@ -38,6 +39,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { if (error instanceof TicketCategoryConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }); } + if (error instanceof TicketPanelSyncError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.code === 'timeout' ? 504 : 502 } + ); + } return toApiErrorResponse(error); } } @@ -61,6 +68,12 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { return NextResponse.json({ ok: true }); } catch (error) { + if (error instanceof TicketPanelSyncError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.code === 'timeout' ? 504 : 502 } + ); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts index 97d44f3..9edd0e5 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts @@ -2,7 +2,7 @@ import { TicketCategoryDashboardCreateSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { createTicketCategory, listTicketCategories, TicketCategoryConflictError } from '@/lib/module-configs/tickets'; +import { createTicketCategory, listTicketCategories, TicketCategoryConflictError, TicketPanelSyncError } from '@/lib/module-configs/tickets'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -42,6 +42,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { if (error instanceof TicketCategoryConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }); } + if (error instanceof TicketPanelSyncError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.code === 'timeout' ? 504 : 502 } + ); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/open/[ticketId]/action/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/open/[ticketId]/action/route.ts new file mode 100644 index 0000000..057f31f --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/open/[ticketId]/action/route.ts @@ -0,0 +1,48 @@ +import { TicketDashboardActionSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { + applyTicketDashboardAction, + TicketActionSyncError +} from '@/lib/module-configs/tickets'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; ticketId: string }>; +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId, ticketId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const action = TicketDashboardActionSchema.parse(body); + + const after = await applyTicketDashboardAction( + guildId, + ticketId, + action, + session.user.id + ); + if (!after) { + return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: `tickets.${action.action}`, + path: `/dashboard/${guildId}/tickets`, + before: { ticketId }, + after + }); + + return NextResponse.json(after); + } catch (error) { + if (error instanceof TicketActionSyncError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/open/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/open/route.ts new file mode 100644 index 0000000..82c6026 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/open/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { listOpenTickets } from '@/lib/module-configs/tickets'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const tickets = await listOpenTickets(guildId); + return NextResponse.json({ tickets }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts index 9c6a074..dc5d39c 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts @@ -2,7 +2,11 @@ import { TicketConfigDashboardPatchSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { getTicketConfigDashboard, updateTicketConfigDashboard } from '@/lib/module-configs/tickets'; +import { + getTicketConfigDashboard, + TicketPanelSyncError, + updateTicketConfigDashboard +} from '@/lib/module-configs/tickets'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -41,6 +45,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json(after); } catch (error) { + if (error instanceof TicketPanelSyncError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.code === 'timeout' ? 504 : 502 } + ); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts index 8d1e109..cdfffcb 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts @@ -2,7 +2,11 @@ import { VerificationConfigDashboardPatchSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { getVerificationDashboard, updateVerificationDashboard } from '@/lib/module-configs/verification'; +import { + getVerificationDashboard, + updateVerificationDashboard, + VerificationPanelSyncError +} from '@/lib/module-configs/verification'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -45,6 +49,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json(after); } catch (error) { + if (error instanceof VerificationPanelSyncError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.code === 'timeout' ? 504 : 502 } + ); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/owner/about/route.ts b/apps/webui/src/app/api/owner/about/route.ts new file mode 100644 index 0000000..ea7f230 --- /dev/null +++ b/apps/webui/src/app/api/owner/about/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { BotAboutConfigPatchSchema } from '@nexumi/shared'; +import { toApiErrorResponse } from '@/lib/auth'; +import { getAboutConfig, updateAboutConfig } from '@/lib/owner-about'; +import { writeOwnerAudit } from '@/lib/owner-audit'; +import { requireOwner } from '@/lib/owner-auth'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + await requireOwner('VIEWER'); + return NextResponse.json(await getAboutConfig()); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function PATCH(request: Request) { + try { + const session = await requireOwner('ADMIN'); + const body = BotAboutConfigPatchSchema.parse(await request.json()); + const before = await getAboutConfig(); + const after = await updateAboutConfig(body); + await writeOwnerAudit(prisma, { + actorUserId: session.user.id, + action: 'about.update', + targetType: 'BotAboutConfig', + targetId: 'singleton', + before, + after + }); + return NextResponse.json(after); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/owner/guilds/route.ts b/apps/webui/src/app/api/owner/guilds/route.ts index d582ff4..6d7b909 100644 --- a/apps/webui/src/app/api/owner/guilds/route.ts +++ b/apps/webui/src/app/api/owner/guilds/route.ts @@ -4,27 +4,39 @@ import { toApiErrorResponse } from '@/lib/auth'; import { env } from '@/lib/env'; import { writeOwnerAudit } from '@/lib/owner-audit'; import { requireOwner } from '@/lib/owner-auth'; +import { createOwnerGuildInvite, enrichOwnerGuildListItems } from '@/lib/owner-guilds'; import { prisma } from '@/lib/prisma'; export async function GET(request: Request) { try { await requireOwner('VIEWER'); - const q = new URL(request.url).searchParams.get('q')?.trim() ?? ''; + const q = new URL(request.url).searchParams.get('q')?.trim().toLowerCase() ?? ''; const guilds = await prisma.guild.findMany({ - where: q ? { id: { contains: q } } : undefined, include: { settings: true }, orderBy: { createdAt: 'desc' }, - take: 100 + take: 200 }); const blacklist = await prisma.guildBlacklist.findMany(); const blacklisted = new Set(blacklist.map((entry) => entry.guildId)); - return NextResponse.json({ - guilds: guilds.map((guild) => ({ + const enriched = await enrichOwnerGuildListItems( + guilds.map((guild) => ({ id: guild.id, locale: guild.settings?.locale ?? 'de', createdAt: guild.createdAt.toISOString(), blacklisted: blacklisted.has(guild.id) - })), + })) + ); + + const filtered = + q.length === 0 + ? enriched + : enriched.filter( + (guild) => + guild.id.includes(q) || (guild.name?.toLowerCase().includes(q) ?? false) + ); + + return NextResponse.json({ + guilds: filtered, blacklist }); } catch (error) { @@ -34,12 +46,31 @@ export async function GET(request: Request) { export async function POST(request: Request) { try { - const session = await requireOwner('ADMIN'); const body = (await request.json()) as { action?: string; guildId?: string; reason?: string }; if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) { return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 }); } + if (body.action === 'createInvite') { + const session = await requireOwner('SUPPORT'); + try { + const invite = await createOwnerGuildInvite(body.guildId); + await writeOwnerAudit(prisma, { + actorUserId: session.user.id, + action: 'guilds.createInvite', + targetType: 'Guild', + targetId: body.guildId, + after: invite + }); + return NextResponse.json({ ok: true, ...invite }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invite creation failed'; + return NextResponse.json({ error: message }, { status: 502 }); + } + } + + const session = await requireOwner('ADMIN'); + if (body.action === 'leave') { const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, { method: 'DELETE', diff --git a/apps/webui/src/app/dashboard/[guildId]/layout.tsx b/apps/webui/src/app/dashboard/[guildId]/layout.tsx index ade1c86..9cb93fe 100644 --- a/apps/webui/src/app/dashboard/[guildId]/layout.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/layout.tsx @@ -2,7 +2,7 @@ import { notFound } from 'next/navigation'; import type { ReactNode } from 'react'; import { DashboardShell } from '@/components/layout/dashboard-shell'; import { requireGuildAccessOrRedirect } from '@/lib/auth'; -import { getManageableGuilds } from '@/lib/guilds'; +import { getManageableGuilds, resolveDashboardGuild } from '@/lib/guilds'; import { isOwnerUser } from '@/lib/owner-auth'; interface GuildLayoutProps { @@ -13,23 +13,26 @@ interface GuildLayoutProps { export default async function GuildLayout({ children, params }: GuildLayoutProps) { const { guildId } = await params; const session = await requireGuildAccessOrRedirect(guildId); - const guilds = await getManageableGuilds(session); - const currentGuild = guilds.find((guild) => guild.id === guildId); + const [resolved, guilds, showOwnerLink] = await Promise.all([ + resolveDashboardGuild(session, guildId), + getManageableGuilds(session), + isOwnerUser(session.user.id) + ]); - if (!currentGuild) { + if (!resolved) { notFound(); } const otherGuilds = guilds.filter((guild) => guild.id !== guildId); - const showOwnerLink = await isOwnerUser(session.user.id); return ( {children} diff --git a/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx b/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx index ef785c1..6499084 100644 --- a/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx @@ -1,7 +1,12 @@ +import { OpenTicketsManager } from '@/components/modules/open-tickets-manager'; import { TicketCategoriesManager } from '@/components/modules/ticket-categories-manager'; import { TicketConfigForm } from '@/components/modules/ticket-config-form'; import { getLocale, t } from '@/lib/i18n'; -import { getTicketConfigDashboard, listTicketCategories } from '@/lib/module-configs/tickets'; +import { + getTicketConfigDashboard, + listOpenTickets, + listTicketCategories +} from '@/lib/module-configs/tickets'; interface TicketsPageProps { params: Promise<{ guildId: string }>; @@ -9,10 +14,11 @@ interface TicketsPageProps { export default async function TicketsPage({ params }: TicketsPageProps) { const { guildId } = await params; - const [locale, config, categories] = await Promise.all([ + const [locale, config, categories, openTickets] = await Promise.all([ getLocale(), getTicketConfigDashboard(guildId), - listTicketCategories(guildId) + listTicketCategories(guildId), + listOpenTickets(guildId) ]); return ( @@ -21,6 +27,7 @@ export default async function TicketsPage({ params }: TicketsPageProps) {

{t(locale, 'modules.tickets.label')}

{t(locale, 'modules.tickets.description')}

+ diff --git a/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx b/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx index fe0533f..f0a4d1e 100644 --- a/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx @@ -1,6 +1,10 @@ +import { notFound } from 'next/navigation'; import { WelcomeForm } from '@/components/modules/welcome-form'; +import { requireGuildAccessOrRedirect } from '@/lib/auth'; +import { resolveDashboardGuild } from '@/lib/guilds'; import { getLocale, t } from '@/lib/i18n'; import { getWelcomeDashboard } from '@/lib/module-configs/welcome'; +import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars'; interface WelcomePageProps { params: Promise<{ guildId: string }>; @@ -8,7 +12,16 @@ interface WelcomePageProps { export default async function WelcomePage({ params }: WelcomePageProps) { const { guildId } = await params; - const [locale, config] = await Promise.all([getLocale(), getWelcomeDashboard(guildId)]); + const session = await requireGuildAccessOrRedirect(guildId); + const [locale, config, resolved] = await Promise.all([ + getLocale(), + getWelcomeDashboard(guildId), + resolveDashboardGuild(session, guildId) + ]); + + if (!resolved) { + notFound(); + } return (
@@ -16,7 +29,11 @@ export default async function WelcomePage({ params }: WelcomePageProps) {

{t(locale, 'modules.welcome.label')}

{t(locale, 'modules.welcome.description')}

- + ); } diff --git a/apps/webui/src/app/globals.css b/apps/webui/src/app/globals.css index fefdfdc..36c9bad 100644 --- a/apps/webui/src/app/globals.css +++ b/apps/webui/src/app/globals.css @@ -3,6 +3,7 @@ @custom-variant dark (&:is(.dark *)); :root { + color-scheme: light; --background: #f8f8fb; --foreground: #111116; --card: #ffffff; @@ -26,6 +27,7 @@ } .dark { + color-scheme: dark; --background: #0a0a0d; --foreground: #e6e6ea; --card: #131318; diff --git a/apps/webui/src/app/owner/about/page.tsx b/apps/webui/src/app/owner/about/page.tsx new file mode 100644 index 0000000..6bb521b --- /dev/null +++ b/apps/webui/src/app/owner/about/page.tsx @@ -0,0 +1,21 @@ +import { ownerRoleAtLeast } from '@nexumi/shared'; +import { AboutForm } from '@/components/owner/about-form'; +import { getLocale, t } from '@/lib/i18n'; +import { getAboutConfig } from '@/lib/owner-about'; +import { requireOwnerOrRedirect } from '@/lib/owner-auth'; + +export default async function OwnerAboutPage() { + const session = await requireOwnerOrRedirect('VIEWER'); + const [locale, config] = await Promise.all([getLocale(), getAboutConfig()]); + const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN'); + + return ( +
+
+

{t(locale, 'owner.about.title')}

+

{t(locale, 'owner.about.subtitle')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/owner/guilds/page.tsx b/apps/webui/src/app/owner/guilds/page.tsx index 7055a53..37d8bf2 100644 --- a/apps/webui/src/app/owner/guilds/page.tsx +++ b/apps/webui/src/app/owner/guilds/page.tsx @@ -1,5 +1,6 @@ import { GuildsManager } from '@/components/owner/owner-forms'; import { getLocale, t } from '@/lib/i18n'; +import { enrichOwnerGuildListItems } from '@/lib/owner-guilds'; import { prisma } from '@/lib/prisma'; export default async function OwnerGuildsPage() { @@ -13,6 +14,14 @@ export default async function OwnerGuildsPage() { prisma.guildBlacklist.findMany() ]); const blacklisted = new Set(blacklist.map((entry) => entry.guildId)); + const initialGuilds = await enrichOwnerGuildListItems( + guilds.map((guild) => ({ + id: guild.id, + locale: guild.settings?.locale ?? 'de', + createdAt: guild.createdAt.toISOString(), + blacklisted: blacklisted.has(guild.id) + })) + ); return (
@@ -20,14 +29,7 @@ export default async function OwnerGuildsPage() {

{t(locale, 'owner.guilds.title')}

{t(locale, 'owner.guilds.subtitle')}

- ({ - id: guild.id, - locale: guild.settings?.locale ?? 'de', - createdAt: guild.createdAt.toISOString(), - blacklisted: blacklisted.has(guild.id) - }))} - /> + ); } diff --git a/apps/webui/src/app/owner/presence/page.tsx b/apps/webui/src/app/owner/presence/page.tsx index b2a8246..71ea8b3 100644 --- a/apps/webui/src/app/owner/presence/page.tsx +++ b/apps/webui/src/app/owner/presence/page.tsx @@ -1,16 +1,21 @@ -import { PresenceForm } from '@/components/owner/owner-forms'; +import { ownerRoleAtLeast } from '@nexumi/shared'; +import { PresenceForm } from '@/components/owner/presence-form'; import { getLocale, t } from '@/lib/i18n'; +import { requireOwnerOrRedirect } from '@/lib/owner-auth'; import { getPresenceConfig } from '@/lib/owner-presence'; export default async function OwnerPresencePage() { + const session = await requireOwnerOrRedirect('VIEWER'); const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]); + const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN'); + return (

{t(locale, 'owner.presence.title')}

{t(locale, 'owner.presence.subtitle')}

- +
); } diff --git a/apps/webui/src/components/layout/dashboard-shell.tsx b/apps/webui/src/components/layout/dashboard-shell.tsx index 904d83c..031b6db 100644 --- a/apps/webui/src/components/layout/dashboard-shell.tsx +++ b/apps/webui/src/components/layout/dashboard-shell.tsx @@ -18,6 +18,8 @@ interface DashboardShellProps { otherGuilds: DashboardGuild[]; user: SessionUser; showOwnerLink?: boolean; + /** True when an Owner-panel user opened a guild they do not manage on Discord. */ + ownerBypass?: boolean; children: ReactNode; } @@ -27,6 +29,7 @@ export function DashboardShell({ otherGuilds, user, showOwnerLink = false, + ownerBypass = false, children }: DashboardShellProps) { const t = useTranslations(); @@ -59,7 +62,21 @@ export function DashboardShell({
-
{children}
+
+ {ownerBypass ? ( +
+

{t('dashboard.ownerBypass.title')}

+

{t('dashboard.ownerBypass.description')}

+ + {t('dashboard.ownerBypass.backToOwner')} + +
+ ) : null} + {children} +
diff --git a/apps/webui/src/components/modules/automod-form.tsx b/apps/webui/src/components/modules/automod-form.tsx index c295ec8..98d8075 100644 --- a/apps/webui/src/components/modules/automod-form.tsx +++ b/apps/webui/src/components/modules/automod-form.tsx @@ -6,8 +6,10 @@ import type { AutomodConfigDashboard, AutomodRuleDashboard } from '@nexumi/shared'; +import { mergeDefaultBlockedWords } from '@nexumi/shared'; import { FieldAnchor } from '@/components/layout/field-anchor'; import { useTranslations } from '@/components/locale-provider'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select'; import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select'; @@ -25,19 +27,26 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom try { const cleaned: AutomodConfigDashboard = { ...value, - rules: value.rules.map((rule) => ({ - ...rule, - durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null, - threshold: { - ...rule.threshold, - linkWhitelist: (rule.threshold.linkWhitelist ?? []).map((s) => s.trim()).filter(Boolean), - linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean) - }, - wordList: { - words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean), - regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean) - } - })) + rules: value.rules.map((rule) => { + const words = (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean); + const regexPatterns = (rule.wordList.regexPatterns ?? []) + .map((s) => s.trim()) + .filter(Boolean); + return { + ...rule, + durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null, + threshold: { + ...rule.threshold, + linkWhitelist: (rule.threshold.linkWhitelist ?? []).map((s) => s.trim()).filter(Boolean), + linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean) + }, + wordList: { + words, + regexPatterns, + ...(words.length === 0 ? { userCleared: true as const } : {}) + } + }; + }) }; const response = await fetch(`/api/guilds/${guildId}/automod`, { method: 'PATCH', @@ -279,7 +288,24 @@ function RuleCard({ {type === 'WORD_FILTER' ? (
- +
+ + +
onChange({ wordList: { ...rule.wordList, words } })} diff --git a/apps/webui/src/components/modules/logging-form.tsx b/apps/webui/src/components/modules/logging-form.tsx index d41742e..0008858 100644 --- a/apps/webui/src/components/modules/logging-form.tsx +++ b/apps/webui/src/components/modules/logging-form.tsx @@ -42,7 +42,8 @@ const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [ 'THREAD_UPDATE', 'THREAD_DELETE', 'GUILD_UPDATE', - 'MOD_ACTION' + 'MOD_ACTION', + 'VERIFICATION_FAIL' ]; interface LocalLogChannelGroup { diff --git a/apps/webui/src/components/modules/open-tickets-manager.tsx b/apps/webui/src/components/modules/open-tickets-manager.tsx new file mode 100644 index 0000000..427a2cc --- /dev/null +++ b/apps/webui/src/components/modules/open-tickets-manager.tsx @@ -0,0 +1,237 @@ +'use client'; + +import type { TicketDashboard, TicketPriority } from '@nexumi/shared'; +import { ExternalLink, RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { FieldAnchor } from '@/components/layout/field-anchor'; +import { useTranslations } from '@/components/locale-provider'; +import { Badge, type BadgeProps } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +const STATUS_VARIANTS: Record<'OPEN' | 'CLAIMED', BadgeProps['variant']> = { + OPEN: 'secondary', + CLAIMED: 'success' +}; + +const PRIORITY_VARIANTS: Record = { + LOW: 'outline', + NORMAL: 'secondary', + HIGH: 'default', + URGENT: 'destructive' +}; + +const PRIORITIES: TicketPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT']; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +function discordChannelUrl(guildId: string, ticket: TicketDashboard): string | null { + const channelId = ticket.channelId ?? ticket.threadId; + if (!channelId) { + return null; + } + return `https://discord.com/channels/${guildId}/${channelId}`; +} + +interface OpenTicketsManagerProps { + guildId: string; + initialTickets: TicketDashboard[]; +} + +export function OpenTicketsManager({ guildId, initialTickets }: OpenTicketsManagerProps) { + const t = useTranslations(); + const [tickets, setTickets] = useState(initialTickets); + const [pendingId, setPendingId] = useState(null); + const [reasons, setReasons] = useState>({}); + const [loading, setLoading] = useState(false); + + async function reload() { + setLoading(true); + try { + const response = await fetch(`/api/guilds/${guildId}/tickets/open`); + const body = (await response.json().catch(() => null)) as { tickets?: TicketDashboard[] } | null; + if (response.ok && body?.tickets) { + setTickets(body.tickets); + } else { + toast.error(t('common.saveError')); + } + } catch { + toast.error(t('common.networkError')); + } finally { + setLoading(false); + } + } + + async function runAction( + ticket: TicketDashboard, + payload: + | { action: 'close'; reason?: string } + | { action: 'claim' } + | { action: 'priority'; priority: TicketPriority } + ) { + setPendingId(ticket.id); + try { + const response = await fetch(`/api/guilds/${guildId}/tickets/open/${ticket.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const body = (await response.json().catch(() => null)) as + | (TicketDashboard & { error?: string }) + | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + + if (payload.action === 'close') { + setTickets((prev) => prev.filter((entry) => entry.id !== ticket.id)); + toast.success(t('modulePages.tickets.openList.closed')); + } else { + setTickets((prev) => prev.map((entry) => (entry.id === ticket.id ? body : entry))); + toast.success(t('common.saveSuccess')); + } + } catch { + toast.error(t('common.networkError')); + } finally { + setPendingId(null); + } + } + + return ( + + + +
+ {t('modulePages.tickets.openList.title')} + {t('modulePages.tickets.openList.description')} +
+ +
+ + {!loading && tickets.length === 0 && ( +

{t('modulePages.tickets.openList.empty')}

+ )} + {tickets.map((ticket) => { + const channelUrl = discordChannelUrl(guildId, ticket); + const busy = pendingId === ticket.id; + const statusKey = ticket.status === 'CLAIMED' ? 'CLAIMED' : 'OPEN'; + + return ( +
+
+
+

+ {ticket.subject?.trim() || t('modulePages.tickets.openList.noSubject')} +

+

+ {ticket.categoryName + ? t('modulePages.tickets.openList.categoryLine', { + category: ticket.categoryName + }) + : t('modulePages.tickets.openList.noCategory')} + {' · '} + <@{ticket.openerId}> + {ticket.claimedById ? ( + <> + {' · '} + {t('modulePages.tickets.openList.claimedBy', { + user: `<@${ticket.claimedById}>` + })} + + ) : null} + {' · '} + {formatDate(ticket.lastActivityAt)} +

+
+
+ + {t(`modulePages.tickets.openList.status.${statusKey}`)} + + + {t(`modulePages.tickets.openList.priority.${ticket.priority}`)} + +
+
+ +
+ + setReasons((prev) => ({ ...prev, [ticket.id]: event.target.value })) + } + /> +
+ {channelUrl ? ( + + ) : null} + + + +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/webui/src/components/modules/scheduler-manager.tsx b/apps/webui/src/components/modules/scheduler-manager.tsx index 1d5eaee..e1e0bb3 100644 --- a/apps/webui/src/components/modules/scheduler-manager.tsx +++ b/apps/webui/src/components/modules/scheduler-manager.tsx @@ -29,10 +29,12 @@ import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; type ScheduleContentMode = 'text' | 'embed' | 'components_v2'; +type ScheduleTimingMode = 'once' | 'cron'; const EMPTY_DRAFT = { channelId: '', contentMode: 'text' as ScheduleContentMode, + timingMode: 'once' as ScheduleTimingMode, content: '', embed: null as WelcomeEmbed | null, components: null as MessageComponentsV2 | null, @@ -76,6 +78,19 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) = return t('modulePages.scheduler.noContent'); } +function scheduleErrorMessage(code: string | undefined, t: (key: string) => string): string { + if (code === 'invalid_cron') { + return t('modulePages.scheduler.errors.invalidCron'); + } + if (code === 'invalid_when') { + return t('modulePages.scheduler.errors.invalidWhen'); + } + if (code === 'missing_schedule_time') { + return t('modulePages.scheduler.errors.missingScheduleTime'); + } + return code?.trim() ? code : t('common.saveError'); +} + interface SchedulerManagerProps { guildId: string; initialSchedules: ScheduledMessageDashboard[]; @@ -98,7 +113,10 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager (draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) || (draft.contentMode === 'components_v2' && componentsV2HasContent(components)); - if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) { + const cron = draft.timingMode === 'cron' ? draft.cron.trim() : ''; + const runAt = draft.timingMode === 'once' ? draft.runAt.trim() : ''; + + if (!draft.channelId.trim() || !hasContent || (!cron && !runAt)) { toast.error(t('modulePages.scheduler.formIncomplete')); return; } @@ -114,15 +132,16 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager embed, components, rolePingId: draft.rolePingId.trim() || undefined, - cron: draft.cron.trim() || null, - runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null + // Send naive datetime-local; server interprets it in the guild timezone. + cron: cron || null, + runAt: cron ? null : runAt || null }) }); const body = (await response.json().catch(() => null)) as | (ScheduledMessageDashboard & { error?: string }) | null; if (!response.ok || !body) { - toast.error(body?.error ?? t('common.saveError')); + toast.error(scheduleErrorMessage(body?.error, t)); return; } setSchedules((prev) => [...prev, body]); @@ -179,24 +198,55 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager placeholder={t('common.optional')} />
+
+ +
+ +
+ {(['once', 'cron'] as ScheduleTimingMode[]).map((mode) => ( + + ))} +
+
+ + {draft.timingMode === 'cron' ? (
- + setDraft((prev) => ({ ...prev, cron: event.target.value }))} placeholder="0 9 * * *" + autoComplete="off" />
+ ) : (
- + setDraft((prev) => ({ ...prev, runAt: event.target.value }))} />
- + )}
diff --git a/apps/webui/src/components/modules/selfroles-manager.tsx b/apps/webui/src/components/modules/selfroles-manager.tsx index a01da92..f50402e 100644 --- a/apps/webui/src/components/modules/selfroles-manager.tsx +++ b/apps/webui/src/components/modules/selfroles-manager.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared'; +import type { SelfRoleBehavior, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared'; import { Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -12,35 +12,20 @@ import { DiscordChannelSelect } from '@/components/ui/discord-channel-select'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; +import { + builderRowsToRoles, + emptyBuilderRow, + rolesToBuilderRows, + type SelfRoleBuilderRow, + SelfRoleEntriesBuilder +} from '@/components/ui/self-role-entries-builder'; const MODES: SelfRoleMode[] = ['BUTTONS', 'DROPDOWN', 'REACTIONS']; const BEHAVIORS: SelfRoleBehavior[] = ['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY']; -function rolesToText(roles: SelfRoleEntry[]): string { - return roles.map((role) => [role.roleId, role.label, role.emoji ?? '', role.description ?? ''].join(' | ')).join('\n'); -} - -function textToRoles(text: string): SelfRoleEntry[] { - return text - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const [roleId, label, emoji, description] = line.split('|').map((part) => part.trim()); - return { - roleId: roleId ?? '', - label: label || roleId || '', - emoji: emoji || undefined, - description: description || undefined - }; - }) - .filter((role) => role.roleId.length > 0); -} - -interface LocalPanel extends SelfRolePanelDashboard { +interface LocalPanel extends Omit { clientKey: string; - rolesText: string; + roles: SelfRoleBuilderRow[]; saving?: boolean; } @@ -50,7 +35,7 @@ const EMPTY_DRAFT = { description: '', mode: 'BUTTONS' as SelfRoleMode, behavior: 'NORMAL' as SelfRoleBehavior, - rolesText: '' + roles: [emptyBuilderRow()] as SelfRoleBuilderRow[] }; interface SelfRolesManagerProps { @@ -58,24 +43,43 @@ interface SelfRolesManagerProps { initialPanels: SelfRolePanelDashboard[]; } +function toastBuilderError( + t: ReturnType, + error: 'incomplete' | 'emojiRequired' | 'tooMany' +) { + if (error === 'emojiRequired') { + toast.error(t('modulePages.selfroles.emojiRequired')); + return; + } + if (error === 'tooMany') { + toast.error(t('modulePages.selfroles.tooManyRoles')); + return; + } + toast.error(t('modulePages.selfroles.formIncomplete')); +} + export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerProps) { const t = useTranslations(); const [panels, setPanels] = useState( initialPanels.map((panel, index) => ({ ...panel, clientKey: panel.id ?? `existing-${index}`, - rolesText: rolesToText(panel.roles) + roles: rolesToBuilderRows(panel.roles) })) ); const [draft, setDraft] = useState(EMPTY_DRAFT); const [creating, setCreating] = useState(false); async function handleCreate() { - const roles = textToRoles(draft.rolesText); - if (!draft.channelId.trim() || !draft.title.trim() || roles.length === 0) { + if (!draft.channelId.trim() || !draft.title.trim()) { toast.error(t('modulePages.selfroles.formIncomplete')); return; } + const parsed = builderRowsToRoles(draft.roles, draft.mode === 'REACTIONS'); + if (parsed.error || !parsed.value.length) { + toastBuilderError(t, parsed.error ?? 'incomplete'); + return; + } setCreating(true); try { const response = await fetch(`/api/guilds/${guildId}/selfroles`, { @@ -87,7 +91,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro description: draft.description.trim() || null, mode: draft.mode, behavior: draft.behavior, - roles + roles: parsed.value }) }); const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null; @@ -96,10 +100,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro return; } setPanels((prev) => [ - { ...body, clientKey: body.id ?? `new-${Date.now()}`, rolesText: rolesToText(body.roles) }, + { + ...body, + clientKey: body.id ?? `new-${Date.now()}`, + roles: rolesToBuilderRows(body.roles) + }, ...prev ]); - setDraft(EMPTY_DRAFT); + setDraft({ ...EMPTY_DRAFT, roles: [emptyBuilderRow()] }); toast.success(t('common.saveSuccess')); } catch { toast.error(t('common.saveError')); @@ -109,6 +117,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro } async function handleSave(panel: LocalPanel) { + if (!panel.id) { + return; + } + const parsed = builderRowsToRoles(panel.roles, panel.mode === 'REACTIONS'); + if (parsed.error || !parsed.value.length) { + toastBuilderError(t, parsed.error ?? 'incomplete'); + return; + } setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: true } : entry))); try { const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, { @@ -120,7 +136,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro description: panel.description, mode: panel.mode, behavior: panel.behavior, - roles: textToRoles(panel.rolesText) + roles: parsed.value }) }); const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null; @@ -128,11 +144,25 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro toast.error(body?.error ?? t('common.saveError')); return; } + setPanels((prev) => + prev.map((entry) => + entry.clientKey === panel.clientKey + ? { + ...entry, + ...body, + roles: rolesToBuilderRows(body.roles), + saving: false + } + : entry + ) + ); toast.success(t('common.saveSuccess')); } catch { toast.error(t('common.saveError')); } finally { - setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry))); + setPanels((prev) => + prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry)) + ); } } @@ -156,176 +186,220 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro return (
- - - {t('modulePages.selfroles.createTitle')} - {t('modulePages.selfroles.createDescription')} - - -
+ + + {t('modulePages.selfroles.createTitle')} + {t('modulePages.selfroles.createDescription')} + + +
+
+ + setDraft((prev) => ({ ...prev, channelId }))} + /> +
+
+ + setDraft((prev) => ({ ...prev, title: event.target.value }))} + /> +
+
+ + +
+
+ + +
+
- - setDraft((prev) => ({ ...prev, channelId }))} + + setDraft((prev) => ({ ...prev, description: event.target.value }))} />
- - setDraft((prev) => ({ ...prev, title: event.target.value }))} /> + + setDraft((prev) => ({ ...prev, roles }))} + />
-
- - -
-
- - -
-
-
- - setDraft((prev) => ({ ...prev, description: event.target.value }))} /> -
-
- -