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 ee828a7..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, @@ -238,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, { @@ -276,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 5757d55..a1ba7af 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -222,14 +222,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: true }); + } else { + await interaction.reply({ content: message, ephemeral: true }); + } } + } 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..e4f67e2 100644 --- a/apps/bot/src/interaction-registry.ts +++ b/apps/bot/src/interaction-registry.ts @@ -7,6 +7,11 @@ 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 { isModuleEnabled, moduleForComponent } from './module-gates.js'; +import { isMaintenanceMode } from './presence.js'; import type { BotContext } from './types.js'; import { handleModerationConfirmation, @@ -139,11 +144,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: true + }); + } + 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: true + }); + } + 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..9b06a38 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -13,7 +13,13 @@ import { completeVerification } from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; import { expireSelfRole } from './modules/selfroles/service.js'; -import { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js'; +import { + endGiveaway, + runGiveawayCreateJob, + runGiveawayPauseJob, + runGiveawayDeleteJob, + GiveawayError +} from './modules/giveaways/index.js'; import { closeInactiveTickets } from './modules/tickets/index.js'; import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; @@ -87,6 +93,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 +109,15 @@ type GiveawayCreateJob = { requiredMemberDays?: number | null; }; +type GiveawayPauseJob = { + giveawayId: string; + paused: boolean; +}; + +type GiveawayDeleteJob = { + giveawayId: string; +}; + type BirthdayRoleExpireJob = { guildId: string; @@ -299,12 +316,24 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); }); - const giveawayWorker = new Worker( + 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 +348,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 } ); 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..b785218 --- /dev/null +++ b/apps/bot/src/lib/safe-remove-job.ts @@ -0,0 +1,29 @@ +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. + */ +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')) { + logger.warn( + { error, jobId: context?.jobId ?? job.id, label: context?.label }, + 'BullMQ job is locked; skipping remove' + ); + return; + } + throw error; + } +} 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/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/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 58292ac..0e7cb59 100644 --- a/apps/bot/src/modules/giveaways/commands.ts +++ b/apps/bot/src/modules/giveaways/commands.ts @@ -27,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) { @@ -72,6 +83,8 @@ const giveawayCommand: SlashCommand = { return; } + await interaction.deferReply({ ephemeral: true }); + const giveaway = await startGiveaway( context, { @@ -88,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; } @@ -106,9 +118,10 @@ const giveawayCommand: SlashCommand = { 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; } @@ -118,12 +131,12 @@ const giveawayCommand: SlashCommand = { 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; } @@ -170,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 110a162..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, recoverOverdueGiveaways, 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 e0d07d2..b60668b 100644 --- a/apps/bot/src/modules/giveaways/service.ts +++ b/apps/bot/src/modules/giveaways/service.ts @@ -342,6 +342,9 @@ async function dmWinners( * 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 } }); @@ -356,16 +359,27 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom 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); @@ -375,12 +389,13 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom /** * Ends giveaways whose `endsAt` has passed but `endedAt` is still null - * (e.g. after a failed/locked BullMQ job). Safe to call on bot ready. + * (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, @@ -438,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/moderation/actions.ts b/apps/bot/src/modules/moderation/actions.ts index 1210990..3747ad7 100644 --- a/apps/bot/src/modules/moderation/actions.ts +++ b/apps/bot/src/modules/moderation/actions.ts @@ -53,6 +53,8 @@ export async function executeBan( } } + await interaction.deferUpdate(); + await guild.members.ban(payload.userId, { reason: payload.reason }); const modCase = await createCase(context, { guildId: guild.id, @@ -73,7 +75,7 @@ export async function executeBan( await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason); } - await interaction.update({ + await interaction.editReply({ content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }), components: [] }); @@ -91,6 +93,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 +138,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/commands.ts b/apps/bot/src/modules/moderation/commands.ts index c42cc21..6f0c49b 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -48,7 +48,7 @@ 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( @@ -56,8 +56,13 @@ async function replySuccessCase( locale: 'de' | 'en', caseNumber: number ): Promise { + const content = 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 }); } @@ -460,6 +465,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 +501,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..7c3ce9b 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -1,8 +1,9 @@ -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 { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; +import { moderationQueue } from '../../queues.js'; +import type { BotContext } from '../../types.js'; import { MAX_TIMEOUT_MS } from './duration.js'; type CreateCaseInput = { @@ -96,9 +97,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', 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..f9e46e5 100644 --- a/apps/bot/src/modules/scheduler/service.ts +++ b/apps/bot/src/modules/scheduler/service.ts @@ -181,14 +181,29 @@ async function enqueueScheduleJob( return String(job.id); } +/** + * Removes a scheduled/failed job if present. 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): Promise { if (!jobId) { return; } const job = await scheduleQueue.getJob(jobId); - if (job) { + 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({ jobId, error }, 'Schedule job is locked; skipping remove'); + return; + } + throw error; } } @@ -350,8 +365,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/service.ts b/apps/bot/src/modules/selfroles/service.ts index a2621fc..8eca792 100644 --- a/apps/bot/src/modules/selfroles/service.ts +++ b/apps/bot/src/modules/selfroles/service.ts @@ -22,6 +22,7 @@ import { import type { SelfRolePanel } from '@prisma/client'; import { ensureGuild } from '../../guild.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 +267,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 +289,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( 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/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/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/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/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/components/ui/discord-channel-select.tsx b/apps/webui/src/components/ui/discord-channel-select.tsx index 8e9b7a9..2c57bf2 100644 --- a/apps/webui/src/components/ui/discord-channel-select.tsx +++ b/apps/webui/src/components/ui/discord-channel-select.tsx @@ -38,6 +38,10 @@ function toOptions(channels: DiscordChannelOption[]) { })); } +function normalizeSearch(value: string): string { + return value.trim().toLocaleLowerCase(); +} + interface ChannelSelectBaseProps { guildId: string; kinds?: ChannelKind[]; @@ -62,14 +66,39 @@ export function DiscordChannelSelect({ allowClear = true }: DiscordChannelSelectProps) { const t = useTranslations(); - const { channels, loading } = useGuildChannels(guildId); + const { channels, loading, error, reload } = useGuildChannels(guildId); const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); const options = useMemo(() => toOptions(filterChannels(channels, kinds)), [channels, kinds]); const selected = options.find((option) => option.id === value); + const filtered = useMemo(() => { + const needle = normalizeSearch(query); + if (!needle) { + return options; + } + return options.filter((option) => { + const haystack = normalizeSearch(`${option.prefix}${option.name} ${option.id}`); + return haystack.includes(needle); + }); + }, [options, query]); + + const triggerLabel = selected + ? `${selected.prefix}${selected.name}` + : value + ? `#${value}` + : (placeholder ?? t('modulePages.commands.channelSearchPlaceholder')); return ( - + { + setOpen(next); + if (!next) { + setQuery(''); + } + }} + > - - + + - {t('common.noResults')} + + {error ? ( + + ) : ( + t('common.noResults') + )} + {allowClear && value ? ( {t('common.clearSelection')} ) : null} - {options.map((option) => ( + {filtered.map((option) => ( { onChange(option.id); setOpen(false); diff --git a/apps/webui/src/hooks/use-guild-channels.ts b/apps/webui/src/hooks/use-guild-channels.ts index 45e6a93..4cfdcab 100644 --- a/apps/webui/src/hooks/use-guild-channels.ts +++ b/apps/webui/src/hooks/use-guild-channels.ts @@ -1,44 +1,66 @@ 'use client'; import type { DiscordChannelOption } from '@nexumi/shared'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; const cache = new Map(); export function useGuildChannels(guildId: string): { channels: DiscordChannelOption[]; loading: boolean; + error: boolean; + reload: () => void; } { const [channels, setChannels] = useState(() => cache.get(guildId) ?? []); const [loading, setLoading] = useState(!cache.has(guildId)); + const [error, setError] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + const reload = useCallback(() => { + cache.delete(guildId); + setReloadToken((token) => token + 1); + }, [guildId]); useEffect(() => { let cancelled = false; - const cached = cache.get(guildId); + const cached = reloadToken === 0 ? cache.get(guildId) : undefined; if (cached) { setChannels(cached); setLoading(false); + setError(false); return; } setLoading(true); + setError(false); void fetch(`/api/guilds/${guildId}/channels`) .then(async (response) => { if (!response.ok) { - return [] as DiscordChannelOption[]; + throw new Error(`Channels request failed with ${response.status}`); } - return (await response.json()) as DiscordChannelOption[]; + const data = (await response.json()) as DiscordChannelOption[]; + if (!Array.isArray(data)) { + throw new Error('Channels response was not an array'); + } + return data; }) .then((data) => { if (cancelled) { return; } - cache.set(guildId, data); + if (data.length > 0) { + cache.set(guildId, data); + } else { + cache.delete(guildId); + } setChannels(data); + setError(false); }) .catch(() => { if (!cancelled) { + cache.delete(guildId); setChannels([]); + setError(true); } }) .finally(() => { @@ -50,7 +72,7 @@ export function useGuildChannels(guildId: string): { return () => { cancelled = true; }; - }, [guildId]); + }, [guildId, reloadToken]); - return { channels, loading }; + return { channels, loading, error, reload }; } diff --git a/apps/webui/src/lib/api-errors.ts b/apps/webui/src/lib/api-errors.ts new file mode 100644 index 0000000..197002b --- /dev/null +++ b/apps/webui/src/lib/api-errors.ts @@ -0,0 +1,16 @@ +export class BadRequestError extends Error { + constructor(message: string) { + super(message); + this.name = 'BadRequestError'; + } +} + +export class UpstreamError extends Error { + constructor( + message: string, + public readonly status = 502 + ) { + super(message); + this.name = 'UpstreamError'; + } +} diff --git a/apps/webui/src/lib/auth.ts b/apps/webui/src/lib/auth.ts index 80f328a..c3f753b 100644 --- a/apps/webui/src/lib/auth.ts +++ b/apps/webui/src/lib/auth.ts @@ -2,10 +2,18 @@ import { redirect } from 'next/navigation'; import { NextResponse } from 'next/server'; import { ZodError } from 'zod'; import { hasManageGuildPermission } from '@nexumi/shared'; -import { fetchDiscordUserGuildsCached } from './discord-oauth'; +import { fetchDiscordUserGuildsCached, refreshDiscordToken } from './discord-oauth'; +import { BadRequestError, UpstreamError } from './api-errors'; import { hasOwnerGuildBypass } from './owner-auth'; import { prisma } from './prisma'; -import { getSession, type SessionPayload } from './session'; +import { + getSession, + SessionRequiredError, + updateSession, + type SessionPayload +} from './session'; + +export { BadRequestError, UpstreamError } from './api-errors'; export class UnauthorizedError extends Error { constructor(message = 'Authentication required') { @@ -21,6 +29,37 @@ export class ForbiddenError extends Error { } } +const TOKEN_REFRESH_SKEW_MS = 60_000; + +/** + * Refreshes the Discord access token when expired (or about to expire). + * Throws {@link UnauthorizedError} when refresh is impossible. + */ +export async function ensureFreshSession(session: SessionPayload): Promise { + const expiresAt = session.tokenExpiresAt ?? 0; + if (expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) { + return session; + } + + if (!session.refreshToken) { + throw new UnauthorizedError('Session expired – please log in again'); + } + + try { + const token = await refreshDiscordToken(session.refreshToken); + const next: SessionPayload = { + ...session, + accessToken: token.access_token, + refreshToken: token.refresh_token || session.refreshToken, + tokenExpiresAt: Date.now() + token.expires_in * 1000 + }; + await updateSession(next); + return next; + } catch { + throw new UnauthorizedError('Session expired – please log in again'); + } +} + /** * Use in API routes. Throws {@link UnauthorizedError} when no session exists. */ @@ -29,7 +68,7 @@ export async function requireAuth(): Promise { if (!session) { throw new UnauthorizedError(); } - return session; + return ensureFreshSession(session); } /** @@ -41,14 +80,18 @@ export async function requireAuthOrRedirect(): Promise { if (!session) { redirect('/login'); } - return session; + try { + return await ensureFreshSession(session); + } catch { + redirect('/login'); + } } /** * Ensures the current session user has Manage Guild permission on the given * guild (via Discord OAuth guild list) and that the bot is actually installed - * there (tracked via the `Guild` table). Owner-panel users bypass Discord - * membership checks. Use in API routes. + * there (tracked via the `Guild` table). Owner-panel users with SUPPORT+ + * bypass Discord membership checks. Use in API routes. */ export async function requireGuildAccess(guildId: string): Promise { const session = await requireAuth(); @@ -61,12 +104,19 @@ export async function requireGuildAccess(guildId: string): Promise entry.id === guildId); - if (!guild || !hasManageGuildPermission(guild.permissions)) { - throw new ForbiddenError('Missing Manage Guild permission for this server'); + try { + const guilds = await fetchDiscordUserGuildsCached(session.accessToken); + const guild = guilds.find((entry) => entry.id === guildId); + if (!guild || !hasManageGuildPermission(guild.permissions)) { + throw new ForbiddenError('Missing Manage Guild permission for this server'); + } + return session; + } catch (error) { + if (error instanceof ForbiddenError) { + throw error; + } + throw new UnauthorizedError('Discord session expired – please log in again'); } - return session; } /** @@ -84,21 +134,31 @@ export async function requireGuildAccessOrRedirect(guildId: string): Promise entry.id === guildId); - if (!guild || !hasManageGuildPermission(guild.permissions)) { - redirect('/dashboard'); + try { + const guilds = await fetchDiscordUserGuildsCached(session.accessToken); + const guild = guilds.find((entry) => entry.id === guildId); + if (!guild || !hasManageGuildPermission(guild.permissions)) { + redirect('/dashboard'); + } + return session; + } catch { + redirect('/login'); } - return session; } export function toApiErrorResponse(error: unknown): NextResponse { - if (error instanceof UnauthorizedError) { + if (error instanceof UnauthorizedError || error instanceof SessionRequiredError) { return NextResponse.json({ error: error.message }, { status: 401 }); } if (error instanceof ForbiddenError) { return NextResponse.json({ error: error.message }, { status: 403 }); } + if (error instanceof BadRequestError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof UpstreamError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } if (error instanceof ZodError) { return NextResponse.json( { error: 'Validation failed', issues: error.issues }, diff --git a/apps/webui/src/lib/discord-guild-resources.ts b/apps/webui/src/lib/discord-guild-resources.ts index b90bd70..072a0f2 100644 --- a/apps/webui/src/lib/discord-guild-resources.ts +++ b/apps/webui/src/lib/discord-guild-resources.ts @@ -1,4 +1,5 @@ import type { DiscordChannelOption, DiscordRoleOption } from '@nexumi/shared'; +import { UpstreamError } from './api-errors'; import { DASHBOARD_CHANNEL_TYPES } from './discord-channel-types'; import { env } from './env'; import { redis } from './redis'; @@ -21,7 +22,11 @@ export async function discordBotFetch( cache: 'no-store' }); if (!response.ok) { - throw new Error(`Discord API request to ${path} failed with status ${response.status}`); + const status = + response.status === 401 || response.status === 403 || response.status === 429 + ? response.status + : 502; + throw new UpstreamError(`Discord API request to ${path} failed with status ${response.status}`, status); } if (response.status === 204) { return undefined as T; @@ -73,13 +78,18 @@ async function writeCache(cacheKey: string, value: unknown): Promise { } export async function listGuildChannelsForDashboard(guildId: string): Promise { - const cacheKey = `dashboard:guild:${guildId}:channels:v2`; + // v3: ignore empty cache hits (same bug class as roles empty-cache freeze) + const cacheKey = `dashboard:guild:${guildId}:channels:v3`; const cached = await readCache(cacheKey); - if (cached) { + if (cached && cached.length > 0) { return cached; } const channels = await discordBotFetch(`/guilds/${guildId}/channels`); + if (!Array.isArray(channels)) { + throw new Error(`Discord API returned non-array channels for guild ${guildId}`); + } + const options = channels .filter((channel) => DASHBOARD_CHANNEL_TYPES.has(channel.type)) .map((channel) => ({ @@ -90,7 +100,9 @@ export async function listGuildChannelsForDashboard(guildId: string): Promise a.name.localeCompare(b.name)); - await writeCache(cacheKey, options); + if (options.length > 0) { + await writeCache(cacheKey, options); + } return options; } diff --git a/apps/webui/src/lib/discord-oauth.ts b/apps/webui/src/lib/discord-oauth.ts index f9a5fa6..9401b5a 100644 --- a/apps/webui/src/lib/discord-oauth.ts +++ b/apps/webui/src/lib/discord-oauth.ts @@ -52,6 +52,28 @@ export async function exchangeCodeForToken(code: string): Promise { + const body = new URLSearchParams({ + client_id: env.BOT_CLIENT_ID, + client_secret: env.BOT_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken + }); + + const response = await fetch(`${DISCORD_API_BASE}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + cache: 'no-store' + }); + + if (!response.ok) { + throw new Error(`Discord token refresh failed with status ${response.status}`); + } + + return (await response.json()) as DiscordTokenResponse; +} + export interface DiscordUser { id: string; username: string; diff --git a/apps/webui/src/lib/guilds.ts b/apps/webui/src/lib/guilds.ts index d1b500e..f2e019f 100644 --- a/apps/webui/src/lib/guilds.ts +++ b/apps/webui/src/lib/guilds.ts @@ -11,41 +11,68 @@ const ADMINISTRATOR_PERMISSION = '8'; /** * Returns the guilds the current session user can manage (Manage Guild * permission), enriched with whether Nexumi is already installed there. + * SUPPORT+ owner-panel users also see all bot-installed guilds. * Guilds where the bot is present are sorted first. */ export async function getManageableGuilds(session: SessionPayload): Promise { - const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken); + const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken).catch(() => []); const manageable = discordGuilds.filter((guild) => hasManageGuildPermission(guild.permissions)); + const byId = new Map(); - if (manageable.length === 0) { - return []; - } + const managedIds = manageable.map((guild) => guild.id); + const presentManaged = + managedIds.length > 0 + ? await prisma.guild.findMany({ + where: { id: { in: managedIds } }, + select: { id: true } + }) + : []; + const presentManagedIds = new Set(presentManaged.map((guild) => guild.id)); - const presentGuilds = await prisma.guild.findMany({ - where: { id: { in: manageable.map((guild) => guild.id) } }, - select: { id: true } - }); - const presentIds = new Set(presentGuilds.map((guild) => guild.id)); - - return manageable - .map((guild) => ({ + for (const guild of manageable) { + byId.set(guild.id, { id: guild.id, name: guild.name, icon: guild.icon, - botPresent: presentIds.has(guild.id), + botPresent: presentManagedIds.has(guild.id), permissions: guild.permissions - })) - .sort((a, b) => { - if (a.botPresent !== b.botPresent) { - return a.botPresent ? -1 : 1; - } - return a.name.localeCompare(b.name); }); + } + + if (await hasOwnerGuildBypass(session.user.id)) { + const installed = await prisma.guild.findMany({ + select: { id: true }, + take: 200, + orderBy: { createdAt: 'desc' } + }); + for (const row of installed) { + if (byId.has(row.id)) { + const existing = byId.get(row.id)!; + byId.set(row.id, { ...existing, botPresent: true }); + continue; + } + const meta = await getOwnerGuildDiscordMeta(row.id); + byId.set(row.id, { + id: row.id, + name: meta?.name ?? `Guild ${row.id}`, + icon: meta?.icon ?? null, + botPresent: true, + permissions: ADMINISTRATOR_PERMISSION + }); + } + } + + return [...byId.values()].sort((a, b) => { + if (a.botPresent !== b.botPresent) { + return a.botPresent ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); } /** * Looks up a single manageable guild by id, or null when the session user - * does not manage it via Discord OAuth. + * does not manage it via Discord OAuth / owner bypass. */ export async function getManageableGuild( session: SessionPayload, @@ -66,7 +93,13 @@ export async function resolveDashboardGuild( ): Promise<{ guild: DashboardGuild; ownerBypass: boolean } | null> { const managed = await getManageableGuild(session, guildId); if (managed?.botPresent) { - return { guild: managed, ownerBypass: false }; + const bypass = await hasOwnerGuildBypass(session.user.id); + const onDiscord = Boolean( + (await fetchDiscordUserGuildsCached(session.accessToken).catch(() => [])).find( + (entry) => entry.id === guildId && hasManageGuildPermission(entry.permissions) + ) + ); + return { guild: managed, ownerBypass: bypass && !onDiscord }; } if (!(await hasOwnerGuildBypass(session.user.id))) { diff --git a/apps/webui/src/lib/module-configs/giveaways.ts b/apps/webui/src/lib/module-configs/giveaways.ts index 60dbfc0..7e601df 100644 --- a/apps/webui/src/lib/module-configs/giveaways.ts +++ b/apps/webui/src/lib/module-configs/giveaways.ts @@ -124,7 +124,28 @@ export async function setGiveawayPaused( if (!existing || existing.endedAt) { return null; } - const updated = await prisma.giveaway.update({ where: { id: giveawayId }, data: { paused } }); + + const { confirmed } = await addJobAndAwait( + getGiveawayQueue(), + getGiveawayQueueEvents(), + 'giveawayPause', + { giveawayId, paused }, + { + jobId: `giveaway-pause-${giveawayId}-${paused ? '1' : '0'}-${Date.now()}`, + removeOnComplete: true, + removeOnFail: 25 + }, + 30_000 + ); + + const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } }); + if (!updated || updated.paused !== paused) { + throw new GiveawayEndError( + confirmed + ? 'Giveaway pause job finished without updating pause state' + : 'Giveaway pause timed out – the bot may still be processing; refresh and retry' + ); + } return toDashboard(updated); } @@ -151,7 +172,7 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string): getGiveawayQueue(), getGiveawayQueueEvents(), 'giveawayEnd', - { giveawayId }, + { giveawayId, force: true }, { jobId: manualJobId, removeOnComplete: true, removeOnFail: 50, delay: 0 }, 30_000 ); @@ -181,6 +202,26 @@ export async function deleteGiveawayDashboard(guildId: string, giveawayId: strin await safeRemoveJob(scheduledEndJobId(giveawayId)); - await prisma.giveaway.delete({ where: { id: giveawayId } }); + const { confirmed } = await addJobAndAwait( + getGiveawayQueue(), + getGiveawayQueueEvents(), + 'giveawayDelete', + { giveawayId }, + { + jobId: `giveaway-delete-${giveawayId}-${Date.now()}`, + removeOnComplete: true, + removeOnFail: 25 + }, + 30_000 + ); + + const remaining = await prisma.giveaway.findUnique({ where: { id: giveawayId } }); + if (remaining) { + throw new GiveawayEndError( + confirmed + ? 'Giveaway delete job finished without deleting the giveaway' + : 'Giveaway delete timed out – the bot may still be processing; refresh and retry' + ); + } return true; } diff --git a/apps/webui/src/lib/module-configs/scheduler.ts b/apps/webui/src/lib/module-configs/scheduler.ts index 6e00ea9..e824935 100644 --- a/apps/webui/src/lib/module-configs/scheduler.ts +++ b/apps/webui/src/lib/module-configs/scheduler.ts @@ -136,7 +136,16 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI if (existing.jobId) { const job = await getScheduleQueue().getJob(existing.jobId); - await job?.remove(); + if (job) { + 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')) { + throw error; + } + } + } } await prisma.scheduledMessage.delete({ where: { id: scheduleId } }); diff --git a/apps/webui/src/lib/owner-auth.ts b/apps/webui/src/lib/owner-auth.ts index 2542a28..cce2d02 100644 --- a/apps/webui/src/lib/owner-auth.ts +++ b/apps/webui/src/lib/owner-auth.ts @@ -32,11 +32,16 @@ export async function isOwnerUser(userId: string): Promise { } /** - * Owner-panel team members (any role) may open every guild where the bot is + * Owner-panel team members with SUPPORT+ may open every guild where the bot is * installed — for support without needing Manage Guild on Discord. + * VIEWER is owner-panel read-only and does not bypass guild dashboard APIs. */ export async function hasOwnerGuildBypass(userId: string): Promise { - return (await resolveOwnerRole(userId)) !== null; + const role = await resolveOwnerRole(userId); + if (!role) { + return false; + } + return ownerRoleAtLeast(role, 'SUPPORT'); } export async function requireOwner(minimum: OwnerRole = 'VIEWER'): Promise { diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts index 0328ff1..9eefce8 100644 --- a/apps/webui/src/lib/queues.ts +++ b/apps/webui/src/lib/queues.ts @@ -140,9 +140,9 @@ const DEFAULT_WAIT_TIMEOUT_MS = 15_000; /** * Adds a job and waits (bounded) for the bot worker to finish it, so the - * dashboard can immediately reflect the result instead of polling. Falls - * back to "not confirmed" (does not throw) on timeout - the DB row created - * by the worker is still the source of truth and will show up on next load. + * dashboard can immediately reflect the result instead of polling. + * Enqueue / Redis failures are rethrown. Only waitUntilFinished timeouts + * (and failed jobs) return `{ confirmed: false }`. */ export async function addJobAndAwait( queue: Queue, @@ -156,7 +156,17 @@ export async function addJobAndAwait( try { const result = (await job.waitUntilFinished(queueEvents, timeoutMs)) as T; return { confirmed: true, result }; - } catch { - return { confirmed: false, result: null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // BullMQ timeout message typically includes "timed out" + if (/timed out|timeout/i.test(message)) { + return { confirmed: false, result: null }; + } + // Job failed in the worker — treat as unconfirmed rather than crashing + // the API with a raw stack when the dashboard can refresh from DB. + if (/failed with reason|job.*failed/i.test(message)) { + return { confirmed: false, result: null }; + } + throw error; } } diff --git a/apps/webui/src/lib/session.ts b/apps/webui/src/lib/session.ts index db35fa5..c9d2045 100644 --- a/apps/webui/src/lib/session.ts +++ b/apps/webui/src/lib/session.ts @@ -13,6 +13,7 @@ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; const SessionPayloadSchema = z.object({ user: SessionUserSchema, accessToken: z.string().min(1), + refreshToken: z.string().min(1).optional(), tokenExpiresAt: z.number().optional() }); @@ -88,13 +89,17 @@ export async function createSession(payload: SessionPayload): Promise { return buildCookieValue(sessionId); } -export async function getSession(): Promise { +async function readSessionIdFromCookie(): Promise { const cookieStore = await cookies(); const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value; if (!raw) { return null; } - const sessionId = parseCookieValue(raw); + return parseCookieValue(raw); +} + +export async function getSession(): Promise { + const sessionId = await readSessionIdFromCookie(); if (!sessionId) { return null; } @@ -102,18 +107,28 @@ export async function getSession(): Promise { if (!stored) { return null; } - const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored)); - return parsed.success ? parsed.data : null; + try { + const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored)); + return parsed.success ? parsed.data : null; + } catch { + await redis.del(sessionRedisKey(sessionId)); + return null; + } +} + +/** Overwrites the current session payload in Redis (same session id / cookie). */ +export async function updateSession(payload: SessionPayload): Promise { + const sessionId = await readSessionIdFromCookie(); + if (!sessionId) { + return; + } + await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS); } export async function destroySession(): Promise { - const cookieStore = await cookies(); - const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value; - if (raw) { - const sessionId = parseCookieValue(raw); - if (sessionId) { - await redis.del(sessionRedisKey(sessionId)); - } + const sessionId = await readSessionIdFromCookie(); + if (sessionId) { + await redis.del(sessionRedisKey(sessionId)); } } diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 54415c5..0fe1f25 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -272,6 +272,29 @@ - [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich - [ ] Dashboard „Jetzt beenden“ → Status beendet, Discord-Nachricht aktualisiert +## Post-Phase – Audit Remediation (Reliability + Security) (Status: implementiert) + +### Abgeschlossen (Code) + +- Scheduler: Self-Remove des aktiven Jobs entfernt; safeRemove für externe Cancels +- Channel-Picker: Fehler/leere Responses nicht cachen; Error+Reload wie Rollen +- `requirePermission`: Member- vs. Bot-Permissions getrennt; Defer auf Ban/Purge/Lock/Backup/Giveaway/Media/Translate +- Giveaway: atomares End (`updateMany`), Pause cancel/reschedule, Dashboard Pause/Delete via BullMQ +- Component-/Context-Gates: Maintenance + Modul-Toggle +- Owner-Guild-Bypass ab SUPPORT; OAuth `refresh_token` + Session-Refresh +- Queue safeRemove (Temp-Ban, Reminders, Selfroles); `addJobAndAwait` rethrowt Enqueue-Fehler +- DefaultMemberPermissions für Admin-Commands; Cooldown erst nach erfolgreichem Execute +- API: BadRequest/Upstream-Errors; Discord Roles/Channels 401/403/429/502 + +### Manuell testen + +- [ ] One-shot Schedule sendet einmal (kein Doppel-Post nach Worker-Retry) +- [ ] Channel-Dropdown bei API-Fehler: Retry statt permanent „Keine Treffer“ +- [ ] Giveaway Pause im Dashboard → Button disabled; Unpause → Timer weiter; End trotz Pause +- [ ] Modul aus → Join-Button / Ticket-Panel antwortet „Modul deaktiviert“ +- [ ] VIEWER-Owner ohne Manage Guild: kein Dashboard-Guild-Zugriff; SUPPORT+: Bypass +- [ ] Session nach Token-Ablauf refresht ohne erzwungenen Re-Login (mit gültigem refresh_token) + ## Post-Phase – Giveaway End Fix + Role Picker (Status: implementiert) ### Abgeschlossen (Code)