Implement command cooldown management and enhance command execution flow
- Introduced `applyCommandCooldown` function to manage per-command cooldowns after execution, improving command rate limiting. - Updated command execution flow to include cooldown application, ensuring users are informed of cooldown status. - Enhanced error handling in interaction replies, allowing for more graceful error management and user feedback. - Improved the handling of interaction responses across various commands, ensuring consistent user experience. - Added permission checks for new commands, ensuring only authorized users can execute specific actions.
This commit is contained in:
@@ -84,9 +84,31 @@ export async function evaluateCommandGate(
|
|||||||
if (existing > 0) {
|
if (existing > 0) {
|
||||||
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
|
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
|
||||||
}
|
}
|
||||||
await redis.set(key, '1', 'EX', override.cooldownSeconds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets the per-command cooldown after a successful execute. */
|
||||||
|
export async function applyCommandCooldown(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { t, tf, type Locale } from '@nexumi/shared';
|
|||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
|
import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js';
|
||||||
import {
|
import {
|
||||||
isGuildBlacklisted,
|
isGuildBlacklisted,
|
||||||
isModuleEnabled,
|
isModuleEnabled,
|
||||||
@@ -238,6 +238,7 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
|||||||
}
|
}
|
||||||
|
|
||||||
await command.execute(interaction, context);
|
await command.execute(interaction, context);
|
||||||
|
await applyCommandCooldown(interaction, context).catch(() => undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ok = false;
|
ok = false;
|
||||||
captureException(error, {
|
captureException(error, {
|
||||||
@@ -276,6 +277,16 @@ export async function routeContextMenu(
|
|||||||
return;
|
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);
|
const command = contextMenuMap.get(interaction.commandName);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
|
|||||||
@@ -222,14 +222,18 @@ if (!isShard) {
|
|||||||
interactionType: interaction.type,
|
interactionType: interaction.type,
|
||||||
guildId: interaction.guildId
|
guildId: interaction.guildId
|
||||||
});
|
});
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
try {
|
||||||
const message = t(locale, 'generic.error');
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (interaction.isRepliable()) {
|
const message = t(locale, 'generic.error');
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.isRepliable()) {
|
||||||
await interaction.followUp({ content: message, ephemeral: true });
|
if (interaction.replied || interaction.deferred) {
|
||||||
} else {
|
await interaction.followUp({ content: message, ephemeral: true });
|
||||||
await interaction.reply({ content: message, ephemeral: true });
|
} else {
|
||||||
|
await interaction.reply({ content: message, ephemeral: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (replyError) {
|
||||||
|
logger.warn({ replyError }, 'Failed to send interaction error reply');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import type {
|
|||||||
StringSelectMenuInteraction,
|
StringSelectMenuInteraction,
|
||||||
UserSelectMenuInteraction
|
UserSelectMenuInteraction
|
||||||
} from 'discord.js';
|
} 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 type { BotContext } from './types.js';
|
||||||
import {
|
import {
|
||||||
handleModerationConfirmation,
|
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(
|
export async function routeComponentInteraction(
|
||||||
interaction: AnyComponentInteraction,
|
interaction: AnyComponentInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const customId = interaction.customId;
|
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) {
|
for (const handler of handlers) {
|
||||||
if (handler.match(customId)) {
|
if (handler.match(customId)) {
|
||||||
await handler.handle(interaction, context);
|
await handler.handle(interaction, context);
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import { completeVerification } from './modules/verification/handlers.js';
|
|||||||
import { closePoll } from './modules/utility/poll.js';
|
import { closePoll } from './modules/utility/poll.js';
|
||||||
import { sendReminder } from './modules/utility/reminders.js';
|
import { sendReminder } from './modules/utility/reminders.js';
|
||||||
import { expireSelfRole } from './modules/selfroles/service.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 { closeInactiveTickets } from './modules/tickets/index.js';
|
||||||
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||||
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||||
@@ -87,6 +93,8 @@ type SelfRoleExpireJob = {
|
|||||||
|
|
||||||
type GiveawayEndJob = {
|
type GiveawayEndJob = {
|
||||||
giveawayId: string;
|
giveawayId: string;
|
||||||
|
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
|
||||||
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GiveawayCreateJob = {
|
type GiveawayCreateJob = {
|
||||||
@@ -101,6 +109,15 @@ type GiveawayCreateJob = {
|
|||||||
requiredMemberDays?: number | null;
|
requiredMemberDays?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GiveawayPauseJob = {
|
||||||
|
giveawayId: string;
|
||||||
|
paused: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GiveawayDeleteJob = {
|
||||||
|
giveawayId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
type BirthdayRoleExpireJob = {
|
type BirthdayRoleExpireJob = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -299,12 +316,24 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
const giveawayWorker = new Worker<GiveawayEndJob | GiveawayCreateJob>(
|
const giveawayWorker = new Worker<
|
||||||
|
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
|
||||||
|
>(
|
||||||
giveawayQueueName,
|
giveawayQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name === 'giveawayEnd') {
|
if (job.name === 'giveawayEnd') {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof GiveawayError &&
|
error instanceof GiveawayError &&
|
||||||
@@ -319,6 +348,13 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
if (job.name === 'giveawayCreate') {
|
if (job.name === 'giveawayCreate') {
|
||||||
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
|
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 }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
|
|||||||
29
apps/bot/src/lib/safe-remove-job.ts
Normal file
29
apps/bot/src/lib/safe-remove-job.ts
Normal file
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,41 @@ export function moduleForCommand(commandName: string): DashboardModuleId | null
|
|||||||
return COMMAND_MODULE_MAP[commandName] ?? 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(
|
export async function isFeatureFlagEnabled(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
key: string,
|
key: string,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
import { applyCommandDescription } from '@nexumi/shared';
|
import { applyCommandDescription } from '@nexumi/shared';
|
||||||
|
|
||||||
export const automodStatusCommandData = applyCommandDescription(
|
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')
|
applyCommandDescription(s.setName('status'), 'automod.status.description')
|
||||||
),
|
),
|
||||||
'automod.description'
|
'automod.description'
|
||||||
|
|||||||
@@ -327,7 +327,12 @@ async function handleMediaError(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (error instanceof FunMediaError) {
|
if (error instanceof FunMediaError) {
|
||||||
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -342,12 +347,13 @@ const memeCommand: SlashCommand = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await assertMediaEnabled(context.redis, guildId);
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
const meme = await fetchMeme();
|
const meme = await fetchMeme();
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(meme.title)
|
.setTitle(meme.title)
|
||||||
.setImage(meme.url)
|
.setImage(meme.url)
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await handleMediaError(interaction, locale, error);
|
await handleMediaError(interaction, locale, error);
|
||||||
}
|
}
|
||||||
@@ -363,12 +369,13 @@ const catCommand: SlashCommand = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await assertMediaEnabled(context.redis, guildId);
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
const url = await fetchCatImage();
|
const url = await fetchCatImage();
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(t(locale, 'fun.cat.title'))
|
.setTitle(t(locale, 'fun.cat.title'))
|
||||||
.setImage(url)
|
.setImage(url)
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await handleMediaError(interaction, locale, error);
|
await handleMediaError(interaction, locale, error);
|
||||||
}
|
}
|
||||||
@@ -384,12 +391,13 @@ const dogCommand: SlashCommand = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await assertMediaEnabled(context.redis, guildId);
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
const url = await fetchDogImage();
|
const url = await fetchDogImage();
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(t(locale, 'fun.dog.title'))
|
.setTitle(t(locale, 'fun.dog.title'))
|
||||||
.setImage(url)
|
.setImage(url)
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await handleMediaError(interaction, locale, error);
|
await handleMediaError(interaction, locale, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const giveawayCommandData = applyCommandDescription(
|
export const giveawayCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('giveaway')
|
.setName('giveaway')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ async function ensureManageGuild(
|
|||||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function replyEphemeral(
|
||||||
|
interaction: Parameters<SlashCommand['execute']>[0],
|
||||||
|
content: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.editReply({ content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
const giveawayCommand: SlashCommand = {
|
const giveawayCommand: SlashCommand = {
|
||||||
data: giveawayCommandData,
|
data: giveawayCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
@@ -72,6 +83,8 @@ const giveawayCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const giveaway = await startGiveaway(
|
const giveaway = await startGiveaway(
|
||||||
context,
|
context,
|
||||||
{
|
{
|
||||||
@@ -88,9 +101,8 @@ const giveawayCommand: SlashCommand = {
|
|||||||
locale
|
locale
|
||||||
);
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
|
content: tf(locale, 'giveaway.started', { id: giveaway.id })
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -106,9 +118,10 @@ const giveawayCommand: SlashCommand = {
|
|||||||
if (!giveaway || giveaway.guildId !== interaction.guildId) {
|
if (!giveaway || giveaway.guildId !== interaction.guildId) {
|
||||||
throw new GiveawayError('not_found');
|
throw new GiveawayError('not_found');
|
||||||
}
|
}
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
await cancelGiveawayJob(id);
|
await cancelGiveawayJob(id);
|
||||||
await endGiveaway(context, id);
|
await endGiveaway(context, id);
|
||||||
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
|
await interaction.editReply({ content: t(locale, 'giveaway.ended') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +131,12 @@ const giveawayCommand: SlashCommand = {
|
|||||||
if (!existing || existing.guildId !== interaction.guildId) {
|
if (!existing || existing.guildId !== interaction.guildId) {
|
||||||
throw new GiveawayError('not_found');
|
throw new GiveawayError('not_found');
|
||||||
}
|
}
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
const updated = await rerollGiveaway(context, id, locale);
|
const updated = await rerollGiveaway(context, id, locale);
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: tf(locale, 'giveaway.rerolled', {
|
content: tf(locale, 'giveaway.rerolled', {
|
||||||
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
||||||
}),
|
})
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -170,10 +183,7 @@ const giveawayCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GiveawayError) {
|
if (error instanceof GiveawayError) {
|
||||||
await interaction.reply({
|
await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`));
|
||||||
content: t(locale, `giveaway.error.${error.code}`),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
export { giveawayCommands } from './commands.js';
|
export { giveawayCommands } from './commands.js';
|
||||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.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';
|
||||||
|
|||||||
@@ -342,6 +342,9 @@ async function dmWinners(
|
|||||||
* the slash-command / dashboard paths cancel the delayed job first; the
|
* the slash-command / dashboard paths cancel the delayed job first; the
|
||||||
* `giveawayEnd` worker must never remove its own locked job (that threw and
|
* `giveawayEnd` worker must never remove its own locked job (that threw and
|
||||||
* left giveaways stuck with the join button still visible).
|
* 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<Giveaway> {
|
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
|
||||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
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 guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||||
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
||||||
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
|
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
|
||||||
|
const endedAt = new Date();
|
||||||
|
|
||||||
const updated = await context.prisma.giveaway.update({
|
const claimed = await context.prisma.giveaway.updateMany({
|
||||||
where: { id: giveawayId },
|
where: { id: giveawayId, endedAt: null },
|
||||||
data: {
|
data: {
|
||||||
endedAt: new Date(),
|
endedAt,
|
||||||
winners,
|
winners,
|
||||||
paused: false
|
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);
|
await updateGiveawayMessage(context, updated, locale);
|
||||||
if (winners.length > 0) {
|
if (winners.length > 0) {
|
||||||
await dmWinners(context, updated, winners, locale);
|
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
|
* 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<number> {
|
export async function recoverOverdueGiveaways(context: BotContext): Promise<number> {
|
||||||
const overdue = await context.prisma.giveaway.findMany({
|
const overdue = await context.prisma.giveaway.findMany({
|
||||||
where: {
|
where: {
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
|
paused: false,
|
||||||
endsAt: { lte: new Date() }
|
endsAt: { lte: new Date() }
|
||||||
},
|
},
|
||||||
take: 100,
|
take: 100,
|
||||||
@@ -438,22 +453,61 @@ export async function rerollGiveaway(
|
|||||||
export async function pauseGiveaway(
|
export async function pauseGiveaway(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
giveawayId: string,
|
giveawayId: string,
|
||||||
locale: 'de' | 'en'
|
locale: 'de' | 'en',
|
||||||
|
forcePaused?: boolean
|
||||||
): Promise<Giveaway> {
|
): Promise<Giveaway> {
|
||||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
if (!giveaway || giveaway.endedAt) {
|
if (!giveaway || giveaway.endedAt) {
|
||||||
throw new GiveawayError('not_found');
|
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({
|
const updated = await context.prisma.giveaway.update({
|
||||||
where: { id: giveawayId },
|
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);
|
await updateGiveawayMessage(context, updated, locale);
|
||||||
return updated;
|
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<void> {
|
||||||
|
await deleteGiveaway(context, giveawayId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
||||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const backupCommandData = applyCommandDescription(
|
export const backupCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('backup')
|
.setName('backup')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
|
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
|
||||||
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
|
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ const backupCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
|
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }),
|
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name })
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GuildBackupError) {
|
if (error instanceof GuildBackupError) {
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: t(locale, `guildbackup.error.${error.code}`),
|
content: t(locale, `guildbackup.error.${error.code}`)
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,9 @@ const backupCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'list') {
|
if (sub === 'list') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const backups = await listGuildBackups(context, guild.id);
|
const backups = await listGuildBackups(context, guild.id);
|
||||||
if (backups.length === 0) {
|
if (backups.length === 0) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
@@ -100,6 +103,9 @@ const backupCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'info') {
|
if (sub === 'info') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const backupId = interaction.options.getString('id', true);
|
const backupId = interaction.options.getString('id', true);
|
||||||
const backup = await getGuildBackup(context, guild.id, backupId);
|
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||||
if (!backup) {
|
if (!backup) {
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export async function executeBan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
await guild.members.ban(payload.userId, { reason: payload.reason });
|
await guild.members.ban(payload.userId, { reason: payload.reason });
|
||||||
const modCase = await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
@@ -73,7 +75,7 @@ export async function executeBan(
|
|||||||
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
|
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.editReply({
|
||||||
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
@@ -91,6 +93,8 @@ export async function executePurge(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
const channel = await guild.channels.fetch(payload.channelId);
|
const channel = await guild.channels.fetch(payload.channelId);
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
const regex = payload.regex ? new RegExp(payload.regex, 'i') : null;
|
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', {
|
content: tf(locale, 'moderation.purge.done', {
|
||||||
deletedCount,
|
deletedCount,
|
||||||
caseNumber: modCase.caseNumber
|
caseNumber: modCase.caseNumber
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async function ensureMod(
|
|||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return requirePermission(interaction, permission, locale);
|
return requirePermission(interaction, permission, locale, { botPermissions: permission });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replySuccessCase(
|
async function replySuccessCase(
|
||||||
@@ -56,8 +56,13 @@ async function replySuccessCase(
|
|||||||
locale: 'de' | 'en',
|
locale: 'de' | 'en',
|
||||||
caseNumber: number
|
caseNumber: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const content = tf(locale, 'moderation.success.case', { caseNumber });
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.editReply({ content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'moderation.success.case', { caseNumber }),
|
content,
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -460,6 +465,7 @@ const lockCommand: SlashCommand = {
|
|||||||
const guild = interaction.guild!;
|
const guild = interaction.guild!;
|
||||||
|
|
||||||
if (serverWide) {
|
if (serverWide) {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
for (const channel of guild.channels.cache.values()) {
|
for (const channel of guild.channels.cache.values()) {
|
||||||
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||||
continue;
|
continue;
|
||||||
@@ -495,6 +501,7 @@ const unlockCommand: SlashCommand = {
|
|||||||
const guild = interaction.guild!;
|
const guild = interaction.guild!;
|
||||||
|
|
||||||
if (serverWide) {
|
if (serverWide) {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
for (const channel of guild.channels.cache.values()) {
|
for (const channel of guild.channels.cache.values()) {
|
||||||
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { moderationQueue } from '../../queues.js';
|
|
||||||
import type { BotContext } from '../../types.js';
|
|
||||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
|
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';
|
import { MAX_TIMEOUT_MS } from './duration.js';
|
||||||
|
|
||||||
type CreateCaseInput = {
|
type CreateCaseInput = {
|
||||||
@@ -96,9 +97,7 @@ export async function scheduleTempBanExpire(
|
|||||||
) {
|
) {
|
||||||
const jobId = tempBanJobId(guildId, userId);
|
const jobId = tempBanJobId(guildId, userId);
|
||||||
const existing = await moderationQueue.getJob(jobId);
|
const existing = await moderationQueue.getJob(jobId);
|
||||||
if (existing) {
|
await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId });
|
||||||
await existing.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
await moderationQueue.add(
|
await moderationQueue.add(
|
||||||
'tempBanExpire',
|
'tempBanExpire',
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const scheduleCommandData = applyCommandDescription(
|
export const scheduleCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('schedule')
|
.setName('schedule')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((sub) =>
|
.addSubcommand((sub) =>
|
||||||
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
|
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
|
||||||
.addChannelOption((o) =>
|
.addChannelOption((o) =>
|
||||||
@@ -51,6 +52,7 @@ export const scheduleCommandData = applyCommandDescription(
|
|||||||
export const announceCommandData = applyCommandDescription(
|
export const announceCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('announce')
|
.setName('announce')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addChannelOption((o) =>
|
.addChannelOption((o) =>
|
||||||
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
|
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -181,14 +181,29 @@ async function enqueueScheduleJob(
|
|||||||
return String(job.id);
|
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<void> {
|
export async function removeScheduleJob(jobId: string | null): Promise<void> {
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await scheduleQueue.getJob(jobId);
|
const job = await scheduleQueue.getJob(jobId);
|
||||||
if (job) {
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
await job.remove();
|
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;
|
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) {
|
if (!schedule.cron) {
|
||||||
await removeScheduleJob(schedule.jobId);
|
|
||||||
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {
|
|||||||
applyOptionDescription,
|
applyOptionDescription,
|
||||||
localizedChoice
|
localizedChoice
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import { ChannelType, SlashCommandBuilder } from 'discord.js';
|
import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const selfrolesCommandData = applyCommandDescription(
|
export const selfrolesCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('selfroles')
|
.setName('selfroles')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommandGroup((group) =>
|
.addSubcommandGroup((group) =>
|
||||||
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
|
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
|
||||||
.addSubcommand((sub) =>
|
.addSubcommand((sub) =>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import type { SelfRolePanel } from '@prisma/client';
|
import type { SelfRolePanel } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||||
import { ticketQueue } from '../../queues.js';
|
import { ticketQueue } from '../../queues.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
@@ -266,13 +267,17 @@ export async function scheduleSelfRoleExpiry(
|
|||||||
roleId: string,
|
roleId: string,
|
||||||
expiresAt: Date
|
expiresAt: Date
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
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());
|
const delay = Math.max(0, expiresAt.getTime() - Date.now());
|
||||||
await ticketQueue.add(
|
await ticketQueue.add(
|
||||||
'selfRoleExpire',
|
'selfRoleExpire',
|
||||||
{ guildId, userId, roleId },
|
{ guildId, userId, roleId },
|
||||||
{
|
{
|
||||||
delay,
|
delay,
|
||||||
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
|
jobId,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
removeOnFail: 50
|
removeOnFail: 50
|
||||||
}
|
}
|
||||||
@@ -284,8 +289,9 @@ export async function cancelSelfRoleExpiry(
|
|||||||
userId: string,
|
userId: string,
|
||||||
roleId: string
|
roleId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
|
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
|
||||||
await job?.remove();
|
const job = await ticketQueue.getJob(jobId);
|
||||||
|
await safeRemoveBullJob(job, { label: 'selfRoleExpire', jobId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expireSelfRole(
|
export async function expireSelfRole(
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const tagCommandData = applyCommandDescription(
|
export const tagCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('tag')
|
.setName('tag')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((sub) =>
|
.addSubcommand((sub) =>
|
||||||
applyCommandDescription(sub.setName('create'), 'tag.create.description')
|
applyCommandDescription(sub.setName('create'), 'tag.create.description')
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import {
|
|||||||
applyOptionDescription,
|
applyOptionDescription,
|
||||||
localizedChoice
|
localizedChoice
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings.
|
// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings.
|
||||||
// SPEC `/ticket panel create` is represented as subcommand `panel_create`.
|
// SPEC `/ticket panel create` is represented as subcommand `panel_create`.
|
||||||
export const ticketCommandData = applyCommandDescription(
|
export const ticketCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('ticket')
|
.setName('ticket')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description')
|
applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description')
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
|
|||||||
@@ -257,7 +257,9 @@ const emojiCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
|
||||||
|
botPermissions: PermissionFlagsBits.ManageGuildExpressions
|
||||||
|
}))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +328,9 @@ const stickerCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
|
||||||
|
botPermissions: PermissionFlagsBits.ManageGuildExpressions
|
||||||
|
}))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +423,9 @@ const embedCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale, {
|
||||||
|
botPermissions: PermissionFlagsBits.ManageMessages
|
||||||
|
}))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,17 +32,18 @@ const translateContextMenu: ContextMenuCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const targetLang = locale === 'de' ? 'de' : 'en';
|
const targetLang = locale === 'de' ? 'de' : 'en';
|
||||||
const translated = await translateText(content, targetLang);
|
const translated = await translateText(content, targetLang);
|
||||||
|
|
||||||
if (!translated) {
|
if (!translated) {
|
||||||
await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true });
|
await interaction.editReply({ content: t(locale, 'utility.translate.failed') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: t(locale, 'utility.translate.result').replace('{text}', translated),
|
content: t(locale, 'utility.translate.result').replace('{text}', translated)
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type TextChannel } from 'discord.js';
|
|||||||
import { t, tf } from '@nexumi/shared';
|
import { t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||||
import { reminderQueue } from '../../queues.js';
|
import { reminderQueue } from '../../queues.js';
|
||||||
import { parseWhen, UtilityError } from './service.js';
|
import { parseWhen, UtilityError } from './service.js';
|
||||||
|
|
||||||
@@ -107,9 +108,7 @@ export async function deleteReminder(
|
|||||||
|
|
||||||
if (match.jobId) {
|
if (match.jobId) {
|
||||||
const job = await reminderQueue.getJob(match.jobId);
|
const job = await reminderQueue.getJob(match.jobId);
|
||||||
if (job) {
|
await safeRemoveBullJob(job, { label: 'reminder', jobId: match.jobId });
|
||||||
await job.remove();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.prisma.reminder.delete({ where: { id: match.id } });
|
await context.prisma.reminder.delete({ where: { id: match.id } });
|
||||||
|
|||||||
@@ -1,14 +1,49 @@
|
|||||||
import { type ChatInputCommandInteraction } from 'discord.js';
|
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import type { Locale } from '@nexumi/shared';
|
import type { Locale } from '@nexumi/shared';
|
||||||
|
|
||||||
|
const PERMISSION_LABELS: Partial<Record<string, string>> = {
|
||||||
|
[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(
|
export async function requirePermission(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
permission: bigint,
|
memberPermission: bigint,
|
||||||
locale: Locale
|
locale: Locale,
|
||||||
|
options?: RequirePermissionOptions
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const member = interaction.memberPermissions;
|
const member = interaction.memberPermissions;
|
||||||
if (!member?.has(permission)) {
|
if (!member?.has(memberPermission)) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, 'generic.noPermission'),
|
content: t(locale, 'generic.noPermission'),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
@@ -16,16 +51,36 @@ export async function requirePermission(
|
|||||||
return false;
|
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({
|
await interaction.reply({
|
||||||
content: t(locale, 'generic.botMissingPermission').replace(
|
content: t(locale, 'generic.botMissingPermission').replace(
|
||||||
'{permission}',
|
'{permission}',
|
||||||
permission.toString()
|
required.map(permissionLabel).join(', ')
|
||||||
),
|
),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export async function GET(request: NextRequest) {
|
|||||||
cookieValue = await createSession({
|
cookieValue = await createSession({
|
||||||
user,
|
user,
|
||||||
accessToken: token.access_token,
|
accessToken: token.access_token,
|
||||||
|
refreshToken: token.refresh_token,
|
||||||
tokenExpiresAt: Date.now() + token.expires_in * 1000
|
tokenExpiresAt: Date.now() + token.expires_in * 1000
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ function toOptions(channels: DiscordChannelOption[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSearch(value: string): string {
|
||||||
|
return value.trim().toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
interface ChannelSelectBaseProps {
|
interface ChannelSelectBaseProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
kinds?: ChannelKind[];
|
kinds?: ChannelKind[];
|
||||||
@@ -62,14 +66,39 @@ export function DiscordChannelSelect({
|
|||||||
allowClear = true
|
allowClear = true
|
||||||
}: DiscordChannelSelectProps) {
|
}: DiscordChannelSelectProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { channels, loading } = useGuildChannels(guildId);
|
const { channels, loading, error, reload } = useGuildChannels(guildId);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
const options = useMemo(() => toOptions(filterChannels(channels, kinds)), [channels, kinds]);
|
const options = useMemo(() => toOptions(filterChannels(channels, kinds)), [channels, kinds]);
|
||||||
const selected = options.find((option) => option.id === value);
|
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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) {
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -79,19 +108,29 @@ export function DiscordChannelSelect({
|
|||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
className="h-9 w-full justify-between px-3 font-normal"
|
className="h-9 w-full justify-between px-3 font-normal"
|
||||||
>
|
>
|
||||||
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
|
<span className={cn('truncate', !selected && !value && 'text-muted-foreground')}>
|
||||||
{selected
|
{triggerLabel}
|
||||||
? `${selected.prefix}${selected.name}`
|
|
||||||
: (placeholder ?? t('modulePages.commands.channelSearchPlaceholder'))}
|
|
||||||
</span>
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" align="start">
|
<PopoverContent className="p-0" align="start">
|
||||||
<Command>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput placeholder={placeholder ?? t('modulePages.commands.channelSearchPlaceholder')} />
|
<CommandInput
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
placeholder={placeholder ?? t('modulePages.commands.channelSearchPlaceholder')}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t('common.noResults')}</CommandEmpty>
|
<CommandEmpty>
|
||||||
|
{error ? (
|
||||||
|
<button type="button" className="underline" onClick={() => reload()}>
|
||||||
|
{t('common.saveError')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
t('common.noResults')
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{allowClear && value ? (
|
{allowClear && value ? (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
@@ -105,10 +144,10 @@ export function DiscordChannelSelect({
|
|||||||
<span className="text-muted-foreground">{t('common.clearSelection')}</span>
|
<span className="text-muted-foreground">{t('common.clearSelection')}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
) : null}
|
) : null}
|
||||||
{options.map((option) => (
|
{filtered.map((option) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.id}
|
key={option.id}
|
||||||
value={`${option.prefix}${option.name} ${option.id}`}
|
value={`${option.name} ${option.id}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(option.id);
|
onChange(option.id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@@ -1,44 +1,66 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { DiscordChannelOption } from '@nexumi/shared';
|
import type { DiscordChannelOption } from '@nexumi/shared';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
const cache = new Map<string, DiscordChannelOption[]>();
|
const cache = new Map<string, DiscordChannelOption[]>();
|
||||||
|
|
||||||
export function useGuildChannels(guildId: string): {
|
export function useGuildChannels(guildId: string): {
|
||||||
channels: DiscordChannelOption[];
|
channels: DiscordChannelOption[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error: boolean;
|
||||||
|
reload: () => void;
|
||||||
} {
|
} {
|
||||||
const [channels, setChannels] = useState<DiscordChannelOption[]>(() => cache.get(guildId) ?? []);
|
const [channels, setChannels] = useState<DiscordChannelOption[]>(() => cache.get(guildId) ?? []);
|
||||||
const [loading, setLoading] = useState(!cache.has(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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const cached = cache.get(guildId);
|
const cached = reloadToken === 0 ? cache.get(guildId) : undefined;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setChannels(cached);
|
setChannels(cached);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setError(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
void fetch(`/api/guilds/${guildId}/channels`)
|
void fetch(`/api/guilds/${guildId}/channels`)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (!response.ok) {
|
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) => {
|
.then((data) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cache.set(guildId, data);
|
if (data.length > 0) {
|
||||||
|
cache.set(guildId, data);
|
||||||
|
} else {
|
||||||
|
cache.delete(guildId);
|
||||||
|
}
|
||||||
setChannels(data);
|
setChannels(data);
|
||||||
|
setError(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
cache.delete(guildId);
|
||||||
setChannels([]);
|
setChannels([]);
|
||||||
|
setError(true);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -50,7 +72,7 @@ export function useGuildChannels(guildId: string): {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [guildId]);
|
}, [guildId, reloadToken]);
|
||||||
|
|
||||||
return { channels, loading };
|
return { channels, loading, error, reload };
|
||||||
}
|
}
|
||||||
|
|||||||
16
apps/webui/src/lib/api-errors.ts
Normal file
16
apps/webui/src/lib/api-errors.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,18 @@ import { redirect } from 'next/navigation';
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import { hasManageGuildPermission } from '@nexumi/shared';
|
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 { hasOwnerGuildBypass } from './owner-auth';
|
||||||
import { prisma } from './prisma';
|
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 {
|
export class UnauthorizedError extends Error {
|
||||||
constructor(message = 'Authentication required') {
|
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<SessionPayload> {
|
||||||
|
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.
|
* Use in API routes. Throws {@link UnauthorizedError} when no session exists.
|
||||||
*/
|
*/
|
||||||
@@ -29,7 +68,7 @@ export async function requireAuth(): Promise<SessionPayload> {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
throw new UnauthorizedError();
|
throw new UnauthorizedError();
|
||||||
}
|
}
|
||||||
return session;
|
return ensureFreshSession(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,14 +80,18 @@ export async function requireAuthOrRedirect(): Promise<SessionPayload> {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
return session;
|
try {
|
||||||
|
return await ensureFreshSession(session);
|
||||||
|
} catch {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the current session user has Manage Guild permission on the given
|
* 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
|
* guild (via Discord OAuth guild list) and that the bot is actually installed
|
||||||
* there (tracked via the `Guild` table). Owner-panel users bypass Discord
|
* there (tracked via the `Guild` table). Owner-panel users with SUPPORT+
|
||||||
* membership checks. Use in API routes.
|
* bypass Discord membership checks. Use in API routes.
|
||||||
*/
|
*/
|
||||||
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
|
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
@@ -61,12 +104,19 @@ export async function requireGuildAccess(guildId: string): Promise<SessionPayloa
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
try {
|
||||||
const guild = guilds.find((entry) => entry.id === guildId);
|
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
const guild = guilds.find((entry) => entry.id === guildId);
|
||||||
throw new ForbiddenError('Missing Manage Guild permission for this server');
|
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<Ses
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
try {
|
||||||
const guild = guilds.find((entry) => entry.id === guildId);
|
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
const guild = guilds.find((entry) => entry.id === guildId);
|
||||||
redirect('/dashboard');
|
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
} catch {
|
||||||
|
redirect('/login');
|
||||||
}
|
}
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toApiErrorResponse(error: unknown): NextResponse {
|
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 });
|
return NextResponse.json({ error: error.message }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (error instanceof ForbiddenError) {
|
if (error instanceof ForbiddenError) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
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) {
|
if (error instanceof ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Validation failed', issues: error.issues },
|
{ error: 'Validation failed', issues: error.issues },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DiscordChannelOption, DiscordRoleOption } from '@nexumi/shared';
|
import type { DiscordChannelOption, DiscordRoleOption } from '@nexumi/shared';
|
||||||
|
import { UpstreamError } from './api-errors';
|
||||||
import { DASHBOARD_CHANNEL_TYPES } from './discord-channel-types';
|
import { DASHBOARD_CHANNEL_TYPES } from './discord-channel-types';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { redis } from './redis';
|
import { redis } from './redis';
|
||||||
@@ -21,7 +22,11 @@ export async function discordBotFetch<T>(
|
|||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
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) {
|
if (response.status === 204) {
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
@@ -73,13 +78,18 @@ async function writeCache(cacheKey: string, value: unknown): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listGuildChannelsForDashboard(guildId: string): Promise<DiscordChannelOption[]> {
|
export async function listGuildChannelsForDashboard(guildId: string): Promise<DiscordChannelOption[]> {
|
||||||
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<DiscordChannelOption[]>(cacheKey);
|
const cached = await readCache<DiscordChannelOption[]>(cacheKey);
|
||||||
if (cached) {
|
if (cached && cached.length > 0) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
|
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
|
||||||
|
if (!Array.isArray(channels)) {
|
||||||
|
throw new Error(`Discord API returned non-array channels for guild ${guildId}`);
|
||||||
|
}
|
||||||
|
|
||||||
const options = channels
|
const options = channels
|
||||||
.filter((channel) => DASHBOARD_CHANNEL_TYPES.has(channel.type))
|
.filter((channel) => DASHBOARD_CHANNEL_TYPES.has(channel.type))
|
||||||
.map((channel) => ({
|
.map((channel) => ({
|
||||||
@@ -90,7 +100,9 @@ export async function listGuildChannelsForDashboard(guildId: string): Promise<Di
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
await writeCache(cacheKey, options);
|
if (options.length > 0) {
|
||||||
|
await writeCache(cacheKey, options);
|
||||||
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ export async function exchangeCodeForToken(code: string): Promise<DiscordTokenRe
|
|||||||
return (await response.json()) as DiscordTokenResponse;
|
return (await response.json()) as DiscordTokenResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshDiscordToken(refreshToken: string): Promise<DiscordTokenResponse> {
|
||||||
|
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 {
|
export interface DiscordUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -11,41 +11,68 @@ const ADMINISTRATOR_PERMISSION = '8';
|
|||||||
/**
|
/**
|
||||||
* Returns the guilds the current session user can manage (Manage Guild
|
* Returns the guilds the current session user can manage (Manage Guild
|
||||||
* permission), enriched with whether Nexumi is already installed there.
|
* 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.
|
* Guilds where the bot is present are sorted first.
|
||||||
*/
|
*/
|
||||||
export async function getManageableGuilds(session: SessionPayload): Promise<DashboardGuild[]> {
|
export async function getManageableGuilds(session: SessionPayload): Promise<DashboardGuild[]> {
|
||||||
const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken).catch(() => []);
|
||||||
const manageable = discordGuilds.filter((guild) => hasManageGuildPermission(guild.permissions));
|
const manageable = discordGuilds.filter((guild) => hasManageGuildPermission(guild.permissions));
|
||||||
|
const byId = new Map<string, DashboardGuild>();
|
||||||
|
|
||||||
if (manageable.length === 0) {
|
const managedIds = manageable.map((guild) => guild.id);
|
||||||
return [];
|
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({
|
for (const guild of manageable) {
|
||||||
where: { id: { in: manageable.map((guild) => guild.id) } },
|
byId.set(guild.id, {
|
||||||
select: { id: true }
|
|
||||||
});
|
|
||||||
const presentIds = new Set(presentGuilds.map((guild) => guild.id));
|
|
||||||
|
|
||||||
return manageable
|
|
||||||
.map<DashboardGuild>((guild) => ({
|
|
||||||
id: guild.id,
|
id: guild.id,
|
||||||
name: guild.name,
|
name: guild.name,
|
||||||
icon: guild.icon,
|
icon: guild.icon,
|
||||||
botPresent: presentIds.has(guild.id),
|
botPresent: presentManagedIds.has(guild.id),
|
||||||
permissions: guild.permissions
|
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
|
* 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(
|
export async function getManageableGuild(
|
||||||
session: SessionPayload,
|
session: SessionPayload,
|
||||||
@@ -66,7 +93,13 @@ export async function resolveDashboardGuild(
|
|||||||
): Promise<{ guild: DashboardGuild; ownerBypass: boolean } | null> {
|
): Promise<{ guild: DashboardGuild; ownerBypass: boolean } | null> {
|
||||||
const managed = await getManageableGuild(session, guildId);
|
const managed = await getManageableGuild(session, guildId);
|
||||||
if (managed?.botPresent) {
|
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))) {
|
if (!(await hasOwnerGuildBypass(session.user.id))) {
|
||||||
|
|||||||
@@ -124,7 +124,28 @@ export async function setGiveawayPaused(
|
|||||||
if (!existing || existing.endedAt) {
|
if (!existing || existing.endedAt) {
|
||||||
return null;
|
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);
|
return toDashboard(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +172,7 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string):
|
|||||||
getGiveawayQueue(),
|
getGiveawayQueue(),
|
||||||
getGiveawayQueueEvents(),
|
getGiveawayQueueEvents(),
|
||||||
'giveawayEnd',
|
'giveawayEnd',
|
||||||
{ giveawayId },
|
{ giveawayId, force: true },
|
||||||
{ jobId: manualJobId, removeOnComplete: true, removeOnFail: 50, delay: 0 },
|
{ jobId: manualJobId, removeOnComplete: true, removeOnFail: 50, delay: 0 },
|
||||||
30_000
|
30_000
|
||||||
);
|
);
|
||||||
@@ -181,6 +202,26 @@ export async function deleteGiveawayDashboard(guildId: string, giveawayId: strin
|
|||||||
|
|
||||||
await safeRemoveJob(scheduledEndJobId(giveawayId));
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,16 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI
|
|||||||
|
|
||||||
if (existing.jobId) {
|
if (existing.jobId) {
|
||||||
const job = await getScheduleQueue().getJob(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 } });
|
await prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
||||||
|
|||||||
@@ -32,11 +32,16 @@ export async function isOwnerUser(userId: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* 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<boolean> {
|
export async function hasOwnerGuildBypass(userId: string): Promise<boolean> {
|
||||||
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<OwnerSession> {
|
export async function requireOwner(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
|
||||||
|
|||||||
@@ -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
|
* 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
|
* dashboard can immediately reflect the result instead of polling.
|
||||||
* back to "not confirmed" (does not throw) on timeout - the DB row created
|
* Enqueue / Redis failures are rethrown. Only waitUntilFinished timeouts
|
||||||
* by the worker is still the source of truth and will show up on next load.
|
* (and failed jobs) return `{ confirmed: false }`.
|
||||||
*/
|
*/
|
||||||
export async function addJobAndAwait<T = unknown>(
|
export async function addJobAndAwait<T = unknown>(
|
||||||
queue: Queue,
|
queue: Queue,
|
||||||
@@ -156,7 +156,17 @@ export async function addJobAndAwait<T = unknown>(
|
|||||||
try {
|
try {
|
||||||
const result = (await job.waitUntilFinished(queueEvents, timeoutMs)) as T;
|
const result = (await job.waitUntilFinished(queueEvents, timeoutMs)) as T;
|
||||||
return { confirmed: true, result };
|
return { confirmed: true, result };
|
||||||
} catch {
|
} catch (error) {
|
||||||
return { confirmed: false, result: null };
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|||||||
const SessionPayloadSchema = z.object({
|
const SessionPayloadSchema = z.object({
|
||||||
user: SessionUserSchema,
|
user: SessionUserSchema,
|
||||||
accessToken: z.string().min(1),
|
accessToken: z.string().min(1),
|
||||||
|
refreshToken: z.string().min(1).optional(),
|
||||||
tokenExpiresAt: z.number().optional()
|
tokenExpiresAt: z.number().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,13 +89,17 @@ export async function createSession(payload: SessionPayload): Promise<string> {
|
|||||||
return buildCookieValue(sessionId);
|
return buildCookieValue(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSession(): Promise<SessionPayload | null> {
|
async function readSessionIdFromCookie(): Promise<string | null> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const sessionId = parseCookieValue(raw);
|
return parseCookieValue(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<SessionPayload | null> {
|
||||||
|
const sessionId = await readSessionIdFromCookie();
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -102,18 +107,28 @@ export async function getSession(): Promise<SessionPayload | null> {
|
|||||||
if (!stored) {
|
if (!stored) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored));
|
try {
|
||||||
return parsed.success ? parsed.data : null;
|
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<void> {
|
||||||
|
const sessionId = await readSessionIdFromCookie();
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroySession(): Promise<void> {
|
export async function destroySession(): Promise<void> {
|
||||||
const cookieStore = await cookies();
|
const sessionId = await readSessionIdFromCookie();
|
||||||
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
if (sessionId) {
|
||||||
if (raw) {
|
await redis.del(sessionRedisKey(sessionId));
|
||||||
const sessionId = parseCookieValue(raw);
|
|
||||||
if (sessionId) {
|
|
||||||
await redis.del(sessionRedisKey(sessionId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,29 @@
|
|||||||
- [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich
|
- [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich
|
||||||
- [ ] Dashboard „Jetzt beenden“ → Status beendet, Discord-Nachricht aktualisiert
|
- [ ] 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)
|
## Post-Phase – Giveaway End Fix + Role Picker (Status: implementiert)
|
||||||
|
|
||||||
### Abgeschlossen (Code)
|
### Abgeschlossen (Code)
|
||||||
|
|||||||
Reference in New Issue
Block a user