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) {
|
||||
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
|
||||
}
|
||||
await redis.set(key, '1', 'EX', override.cooldownSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Sets the per-command cooldown after a successful execute. */
|
||||
export async function applyCommandCooldown(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
context: BotContext
|
||||
): Promise<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 { logger } from './logger.js';
|
||||
import { getGuildLocale } from './i18n.js';
|
||||
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
|
||||
import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js';
|
||||
import {
|
||||
isGuildBlacklisted,
|
||||
isModuleEnabled,
|
||||
@@ -238,6 +238,7 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
||||
}
|
||||
|
||||
await command.execute(interaction, context);
|
||||
await applyCommandCooldown(interaction, context).catch(() => undefined);
|
||||
} catch (error) {
|
||||
ok = false;
|
||||
captureException(error, {
|
||||
@@ -276,6 +277,16 @@ export async function routeContextMenu(
|
||||
return;
|
||||
}
|
||||
|
||||
const maintenance = await isMaintenanceMode(context);
|
||||
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||
await interaction.reply({
|
||||
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const command = contextMenuMap.get(interaction.commandName);
|
||||
if (!command) {
|
||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||
|
||||
@@ -222,14 +222,18 @@ if (!isShard) {
|
||||
interactionType: interaction.type,
|
||||
guildId: interaction.guildId
|
||||
});
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
const message = t(locale, 'generic.error');
|
||||
if (interaction.isRepliable()) {
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ content: message, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content: message, ephemeral: true });
|
||||
try {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
const message = t(locale, 'generic.error');
|
||||
if (interaction.isRepliable()) {
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ content: message, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content: message, ephemeral: true });
|
||||
}
|
||||
}
|
||||
} catch (replyError) {
|
||||
logger.warn({ replyError }, 'Failed to send interaction error reply');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@ import type {
|
||||
StringSelectMenuInteraction,
|
||||
UserSelectMenuInteraction
|
||||
} from 'discord.js';
|
||||
import { decodeComponentCustomId, t, type DashboardModuleId } from '@nexumi/shared';
|
||||
import { env } from './env.js';
|
||||
import { getGuildLocale } from './i18n.js';
|
||||
import { isModuleEnabled, moduleForComponent } from './module-gates.js';
|
||||
import { isMaintenanceMode } from './presence.js';
|
||||
import type { BotContext } from './types.js';
|
||||
import {
|
||||
handleModerationConfirmation,
|
||||
@@ -139,11 +144,67 @@ const handlers: InteractionHandler[] = [
|
||||
}
|
||||
];
|
||||
|
||||
function moduleForComponentsV2(customId: string): DashboardModuleId | null {
|
||||
const decoded = decodeComponentCustomId(customId);
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
switch (decoded.source) {
|
||||
case 'w':
|
||||
return 'welcome';
|
||||
case 't':
|
||||
return 'tags';
|
||||
case 's':
|
||||
return 'scheduler';
|
||||
case 'm':
|
||||
return 'messages';
|
||||
default:
|
||||
return 'messages';
|
||||
}
|
||||
}
|
||||
|
||||
export async function routeComponentInteraction(
|
||||
interaction: AnyComponentInteraction,
|
||||
context: BotContext
|
||||
): Promise<boolean> {
|
||||
const customId = interaction.customId;
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
|
||||
const maintenance = await isMaintenanceMode(context);
|
||||
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let moduleId = moduleForComponent(customId);
|
||||
if (!moduleId && isComponentsV2Interaction(customId)) {
|
||||
moduleId = moduleForComponentsV2(customId);
|
||||
}
|
||||
|
||||
if (moduleId && interaction.guildId) {
|
||||
const enabled = await isModuleEnabled(
|
||||
context.prisma,
|
||||
context.redis,
|
||||
interaction.guildId,
|
||||
moduleId
|
||||
);
|
||||
if (!enabled) {
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.moduleDisabled'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const handler of handlers) {
|
||||
if (handler.match(customId)) {
|
||||
await handler.handle(interaction, context);
|
||||
|
||||
@@ -13,7 +13,13 @@ import { completeVerification } from './modules/verification/handlers.js';
|
||||
import { closePoll } from './modules/utility/poll.js';
|
||||
import { sendReminder } from './modules/utility/reminders.js';
|
||||
import { expireSelfRole } from './modules/selfroles/service.js';
|
||||
import { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js';
|
||||
import {
|
||||
endGiveaway,
|
||||
runGiveawayCreateJob,
|
||||
runGiveawayPauseJob,
|
||||
runGiveawayDeleteJob,
|
||||
GiveawayError
|
||||
} from './modules/giveaways/index.js';
|
||||
import { closeInactiveTickets } from './modules/tickets/index.js';
|
||||
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||
@@ -87,6 +93,8 @@ type SelfRoleExpireJob = {
|
||||
|
||||
type GiveawayEndJob = {
|
||||
giveawayId: string;
|
||||
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type GiveawayCreateJob = {
|
||||
@@ -101,6 +109,15 @@ type GiveawayCreateJob = {
|
||||
requiredMemberDays?: number | null;
|
||||
};
|
||||
|
||||
type GiveawayPauseJob = {
|
||||
giveawayId: string;
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
type GiveawayDeleteJob = {
|
||||
giveawayId: string;
|
||||
};
|
||||
|
||||
|
||||
type BirthdayRoleExpireJob = {
|
||||
guildId: string;
|
||||
@@ -299,12 +316,24 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
||||
});
|
||||
|
||||
const giveawayWorker = new Worker<GiveawayEndJob | GiveawayCreateJob>(
|
||||
const giveawayWorker = new Worker<
|
||||
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
|
||||
>(
|
||||
giveawayQueueName,
|
||||
async (job) => {
|
||||
if (job.name === 'giveawayEnd') {
|
||||
try {
|
||||
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
|
||||
const data = job.data as GiveawayEndJob;
|
||||
if (!data.force) {
|
||||
const current = await context.prisma.giveaway.findUnique({
|
||||
where: { id: data.giveawayId }
|
||||
});
|
||||
// Timer/recover must not end a paused giveaway.
|
||||
if (current?.paused) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await endGiveaway(context, data.giveawayId);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof GiveawayError &&
|
||||
@@ -319,6 +348,13 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
if (job.name === 'giveawayCreate') {
|
||||
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
|
||||
}
|
||||
if (job.name === 'giveawayPause') {
|
||||
const data = job.data as GiveawayPauseJob;
|
||||
return runGiveawayPauseJob(context, data.giveawayId, data.paused);
|
||||
}
|
||||
if (job.name === 'giveawayDelete') {
|
||||
await runGiveawayDeleteJob(context, (job.data as GiveawayDeleteJob).giveawayId);
|
||||
}
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Component customId → dashboard module (null = no module gate). */
|
||||
export function moduleForComponent(customId: string): DashboardModuleId | null {
|
||||
if (customId.startsWith('mod:confirm:') || customId.startsWith('mod:cancel:')) {
|
||||
return 'moderation';
|
||||
}
|
||||
if (customId.startsWith('verify:')) {
|
||||
return 'verification';
|
||||
}
|
||||
if (customId.startsWith('eco:bj:')) {
|
||||
return 'economy';
|
||||
}
|
||||
if (customId.startsWith('fun:')) {
|
||||
return 'fun';
|
||||
}
|
||||
if (customId.startsWith('gw:')) {
|
||||
return 'giveaways';
|
||||
}
|
||||
if (customId.startsWith('ticket:') || customId === 'ticket:select') {
|
||||
return 'tickets';
|
||||
}
|
||||
if (customId.startsWith('sr:')) {
|
||||
return 'selfroles';
|
||||
}
|
||||
if (customId.startsWith('sug:')) {
|
||||
return 'suggestions';
|
||||
}
|
||||
if (customId.startsWith('tv:')) {
|
||||
return 'tempvoice';
|
||||
}
|
||||
if (customId.startsWith('gbackup:')) {
|
||||
return 'guildbackup';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isFeatureFlagEnabled(
|
||||
prisma: PrismaClient,
|
||||
key: string,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
import { applyCommandDescription } from '@nexumi/shared';
|
||||
|
||||
export const automodStatusCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder().setName('automod').addSubcommand((s) =>
|
||||
new SlashCommandBuilder()
|
||||
.setName('automod')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('status'), 'automod.status.description')
|
||||
),
|
||||
'automod.description'
|
||||
|
||||
@@ -327,7 +327,12 @@ async function handleMediaError(
|
||||
): Promise<void> {
|
||||
if (error instanceof FunMediaError) {
|
||||
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
|
||||
await interaction.reply({ content: t(locale, key), ephemeral: true });
|
||||
const content = t(locale, key);
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
@@ -342,12 +347,13 @@ const memeCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
await assertMediaEnabled(context.redis, guildId);
|
||||
await interaction.deferReply();
|
||||
const meme = await fetchMeme();
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(meme.title)
|
||||
.setImage(meme.url)
|
||||
.setColor(0x6366f1);
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
await handleMediaError(interaction, locale, error);
|
||||
}
|
||||
@@ -363,12 +369,13 @@ const catCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
await assertMediaEnabled(context.redis, guildId);
|
||||
await interaction.deferReply();
|
||||
const url = await fetchCatImage();
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(t(locale, 'fun.cat.title'))
|
||||
.setImage(url)
|
||||
.setColor(0x6366f1);
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
await handleMediaError(interaction, locale, error);
|
||||
}
|
||||
@@ -384,12 +391,13 @@ const dogCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
await assertMediaEnabled(context.redis, guildId);
|
||||
await interaction.deferReply();
|
||||
const url = await fetchDogImage();
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(t(locale, 'fun.dog.title'))
|
||||
.setImage(url)
|
||||
.setColor(0x6366f1);
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
await handleMediaError(interaction, locale, error);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const giveawayCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('giveaway')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
||||
.addStringOption((o) =>
|
||||
|
||||
@@ -27,6 +27,17 @@ async function ensureManageGuild(
|
||||
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 = {
|
||||
data: giveawayCommandData,
|
||||
async execute(interaction, context) {
|
||||
@@ -72,6 +83,8 @@ const giveawayCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const giveaway = await startGiveaway(
|
||||
context,
|
||||
{
|
||||
@@ -88,9 +101,8 @@ const giveawayCommand: SlashCommand = {
|
||||
locale
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
|
||||
ephemeral: true
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'giveaway.started', { id: giveaway.id })
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -106,9 +118,10 @@ const giveawayCommand: SlashCommand = {
|
||||
if (!giveaway || giveaway.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await cancelGiveawayJob(id);
|
||||
await endGiveaway(context, id);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
|
||||
await interaction.editReply({ content: t(locale, 'giveaway.ended') });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,12 +131,12 @@ const giveawayCommand: SlashCommand = {
|
||||
if (!existing || existing.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const updated = await rerollGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'giveaway.rerolled', {
|
||||
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
||||
}),
|
||||
ephemeral: true
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -170,10 +183,7 @@ const giveawayCommand: SlashCommand = {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `giveaway.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export { giveawayCommands } from './commands.js';
|
||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, recoverOverdueGiveaways, GiveawayError } from './service.js';
|
||||
export {
|
||||
endGiveaway,
|
||||
scheduleGiveawayEnd,
|
||||
runGiveawayCreateJob,
|
||||
runGiveawayPauseJob,
|
||||
runGiveawayDeleteJob,
|
||||
recoverOverdueGiveaways,
|
||||
GiveawayError
|
||||
} from './service.js';
|
||||
|
||||
@@ -342,6 +342,9 @@ async function dmWinners(
|
||||
* the slash-command / dashboard paths cancel the delayed job first; the
|
||||
* `giveawayEnd` worker must never remove its own locked job (that threw and
|
||||
* left giveaways stuck with the join button still visible).
|
||||
*
|
||||
* Uses a conditional update so concurrent ends (timer + slash + recover)
|
||||
* cannot each write different winners.
|
||||
*/
|
||||
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
@@ -356,16 +359,27 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
||||
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
||||
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
|
||||
const endedAt = new Date();
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveawayId },
|
||||
const claimed = await context.prisma.giveaway.updateMany({
|
||||
where: { id: giveawayId, endedAt: null },
|
||||
data: {
|
||||
endedAt: new Date(),
|
||||
endedAt,
|
||||
winners,
|
||||
paused: false
|
||||
}
|
||||
});
|
||||
|
||||
if (claimed.count === 0) {
|
||||
const current = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!current) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
throw new GiveawayError('already_ended');
|
||||
}
|
||||
|
||||
const updated = await context.prisma.giveaway.findUniqueOrThrow({ where: { id: giveawayId } });
|
||||
|
||||
await updateGiveawayMessage(context, updated, locale);
|
||||
if (winners.length > 0) {
|
||||
await dmWinners(context, updated, winners, locale);
|
||||
@@ -375,12 +389,13 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
||||
|
||||
/**
|
||||
* Ends giveaways whose `endsAt` has passed but `endedAt` is still null
|
||||
* (e.g. after a failed/locked BullMQ job). Safe to call on bot ready.
|
||||
* (e.g. after a failed/locked BullMQ job). Skips paused giveaways.
|
||||
*/
|
||||
export async function recoverOverdueGiveaways(context: BotContext): Promise<number> {
|
||||
const overdue = await context.prisma.giveaway.findMany({
|
||||
where: {
|
||||
endedAt: null,
|
||||
paused: false,
|
||||
endsAt: { lte: new Date() }
|
||||
},
|
||||
take: 100,
|
||||
@@ -438,22 +453,61 @@ export async function rerollGiveaway(
|
||||
export async function pauseGiveaway(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
locale: 'de' | 'en'
|
||||
locale: 'de' | 'en',
|
||||
forcePaused?: boolean
|
||||
): Promise<Giveaway> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway || giveaway.endedAt) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
|
||||
const nextPaused = forcePaused ?? !giveaway.paused;
|
||||
if (nextPaused === giveaway.paused) {
|
||||
await updateGiveawayMessage(context, giveaway, locale);
|
||||
return giveaway;
|
||||
}
|
||||
|
||||
if (nextPaused) {
|
||||
// Stop the end timer while paused; remaining wall-clock is preserved via endsAt.
|
||||
await cancelGiveawayJob(giveawayId);
|
||||
}
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveawayId },
|
||||
data: { paused: !giveaway.paused }
|
||||
data: { paused: nextPaused }
|
||||
});
|
||||
|
||||
if (!nextPaused) {
|
||||
// Resume: re-schedule with delay based on remaining time until endsAt
|
||||
// (delay 0 if endsAt already passed while paused → ends immediately).
|
||||
await scheduleGiveawayEnd(updated.id, updated.endsAt);
|
||||
}
|
||||
|
||||
await updateGiveawayMessage(context, updated, locale);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard entry points — same Discord side-effects as slash commands.
|
||||
*/
|
||||
export async function runGiveawayPauseJob(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
paused: boolean
|
||||
): Promise<{ id: string; paused: boolean }> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||
const updated = await pauseGiveaway(context, giveawayId, locale, paused);
|
||||
return { id: updated.id, paused: updated.paused };
|
||||
}
|
||||
|
||||
export async function runGiveawayDeleteJob(context: BotContext, giveawayId: string): Promise<void> {
|
||||
await deleteGiveaway(context, giveawayId);
|
||||
}
|
||||
|
||||
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const backupCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('backup')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
|
||||
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
|
||||
|
||||
@@ -55,17 +55,17 @@ const backupCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }),
|
||||
ephemeral: true
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name })
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof GuildBackupError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `guildbackup.error.${error.code}`),
|
||||
ephemeral: true
|
||||
await interaction.editReply({
|
||||
content: t(locale, `guildbackup.error.${error.code}`)
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -75,6 +75,9 @@ const backupCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
const backups = await listGuildBackups(context, guild.id);
|
||||
if (backups.length === 0) {
|
||||
await interaction.reply({
|
||||
@@ -100,6 +103,9 @@ const backupCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
if (sub === 'info') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
const backupId = interaction.options.getString('id', true);
|
||||
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||
if (!backup) {
|
||||
|
||||
@@ -53,6 +53,8 @@ export async function executeBan(
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
await guild.members.ban(payload.userId, { reason: payload.reason });
|
||||
const modCase = await createCase(context, {
|
||||
guildId: guild.id,
|
||||
@@ -73,7 +75,7 @@ export async function executeBan(
|
||||
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
|
||||
}
|
||||
|
||||
await interaction.update({
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
||||
components: []
|
||||
});
|
||||
@@ -91,6 +93,8 @@ export async function executePurge(
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
const channel = await guild.channels.fetch(payload.channelId);
|
||||
let deletedCount = 0;
|
||||
const regex = payload.regex ? new RegExp(payload.regex, 'i') : null;
|
||||
@@ -134,7 +138,7 @@ export async function executePurge(
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.update({
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'moderation.purge.done', {
|
||||
deletedCount,
|
||||
caseNumber: modCase.caseNumber
|
||||
|
||||
@@ -48,7 +48,7 @@ async function ensureMod(
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, permission, locale);
|
||||
return requirePermission(interaction, permission, locale, { botPermissions: permission });
|
||||
}
|
||||
|
||||
async function replySuccessCase(
|
||||
@@ -56,8 +56,13 @@ async function replySuccessCase(
|
||||
locale: 'de' | 'en',
|
||||
caseNumber: number
|
||||
): Promise<void> {
|
||||
const content = tf(locale, 'moderation.success.case', { caseNumber });
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'moderation.success.case', { caseNumber }),
|
||||
content,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
@@ -460,6 +465,7 @@ const lockCommand: SlashCommand = {
|
||||
const guild = interaction.guild!;
|
||||
|
||||
if (serverWide) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
for (const channel of guild.channels.cache.values()) {
|
||||
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||
continue;
|
||||
@@ -495,6 +501,7 @@ const unlockCommand: SlashCommand = {
|
||||
const guild = interaction.guild!;
|
||||
|
||||
if (serverWide) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
for (const channel of guild.channels.cache.values()) {
|
||||
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||
continue;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { moderationQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { moderationQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { MAX_TIMEOUT_MS } from './duration.js';
|
||||
|
||||
type CreateCaseInput = {
|
||||
@@ -96,9 +97,7 @@ export async function scheduleTempBanExpire(
|
||||
) {
|
||||
const jobId = tempBanJobId(guildId, userId);
|
||||
const existing = await moderationQueue.getJob(jobId);
|
||||
if (existing) {
|
||||
await existing.remove();
|
||||
}
|
||||
await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId });
|
||||
|
||||
await moderationQueue.add(
|
||||
'tempBanExpire',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const scheduleCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('schedule')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
|
||||
.addChannelOption((o) =>
|
||||
@@ -51,6 +52,7 @@ export const scheduleCommandData = applyCommandDescription(
|
||||
export const announceCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('announce')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
|
||||
)
|
||||
|
||||
@@ -181,14 +181,29 @@ async function enqueueScheduleJob(
|
||||
return String(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a scheduled/failed job if present. Locked (active) jobs cannot be
|
||||
* removed — do not call this from inside the scheduleSend worker for the
|
||||
* job that is currently running.
|
||||
*/
|
||||
export async function removeScheduleJob(jobId: string | null): Promise<void> {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await scheduleQueue.getJob(jobId);
|
||||
if (job) {
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('locked') || message.includes('could not be removed')) {
|
||||
logger.warn({ jobId, error }, 'Schedule job is locked; skipping remove');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,8 +365,10 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
|
||||
throw error;
|
||||
}
|
||||
|
||||
// One-shot: delete the DB row only. Do not remove the active BullMQ job
|
||||
// from inside its own worker (locked remove throws → retry → duplicate send).
|
||||
// removeOnComplete on enqueue cleans the job up after the processor returns.
|
||||
if (!schedule.cron) {
|
||||
await removeScheduleJob(schedule.jobId);
|
||||
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
applyOptionDescription,
|
||||
localizedChoice
|
||||
} from '@nexumi/shared';
|
||||
import { ChannelType, SlashCommandBuilder } from 'discord.js';
|
||||
import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const selfrolesCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('selfroles')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommandGroup((group) =>
|
||||
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
|
||||
.addSubcommand((sub) =>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { SelfRolePanel } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { ticketQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
|
||||
@@ -266,13 +267,17 @@ export async function scheduleSelfRoleExpiry(
|
||||
roleId: string,
|
||||
expiresAt: Date
|
||||
): Promise<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());
|
||||
await ticketQueue.add(
|
||||
'selfRoleExpire',
|
||||
{ guildId, userId, roleId },
|
||||
{
|
||||
delay,
|
||||
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
|
||||
jobId,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
}
|
||||
@@ -284,8 +289,9 @@ export async function cancelSelfRoleExpiry(
|
||||
userId: string,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
|
||||
await job?.remove();
|
||||
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
|
||||
const job = await ticketQueue.getJob(jobId);
|
||||
await safeRemoveBullJob(job, { label: 'selfRoleExpire', jobId });
|
||||
}
|
||||
|
||||
export async function expireSelfRole(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const tagCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('tag')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('create'), 'tag.create.description')
|
||||
.addStringOption((o) =>
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
applyOptionDescription,
|
||||
localizedChoice
|
||||
} from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings.
|
||||
// SPEC `/ticket panel create` is represented as subcommand `panel_create`.
|
||||
export const ticketCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('ticket')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description')
|
||||
.addStringOption((o) =>
|
||||
|
||||
@@ -257,7 +257,9 @@ const emojiCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
|
||||
botPermissions: PermissionFlagsBits.ManageGuildExpressions
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -326,7 +328,9 @@ const stickerCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
|
||||
botPermissions: PermissionFlagsBits.ManageGuildExpressions
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -419,7 +423,9 @@ const embedCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale, {
|
||||
botPermissions: PermissionFlagsBits.ManageMessages
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,17 +32,18 @@ const translateContextMenu: ContextMenuCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const targetLang = locale === 'de' ? 'de' : 'en';
|
||||
const translated = await translateText(content, targetLang);
|
||||
|
||||
if (!translated) {
|
||||
await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true });
|
||||
await interaction.editReply({ content: t(locale, 'utility.translate.failed') });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
content: t(locale, 'utility.translate.result').replace('{text}', translated),
|
||||
ephemeral: true
|
||||
await interaction.editReply({
|
||||
content: t(locale, 'utility.translate.result').replace('{text}', translated)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type TextChannel } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { reminderQueue } from '../../queues.js';
|
||||
import { parseWhen, UtilityError } from './service.js';
|
||||
|
||||
@@ -107,9 +108,7 @@ export async function deleteReminder(
|
||||
|
||||
if (match.jobId) {
|
||||
const job = await reminderQueue.getJob(match.jobId);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
}
|
||||
await safeRemoveBullJob(job, { label: 'reminder', jobId: match.jobId });
|
||||
}
|
||||
|
||||
await context.prisma.reminder.delete({ where: { id: match.id } });
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
import { type ChatInputCommandInteraction } from 'discord.js';
|
||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { Locale } from '@nexumi/shared';
|
||||
|
||||
const PERMISSION_LABELS: Partial<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(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
permission: bigint,
|
||||
locale: Locale
|
||||
memberPermission: bigint,
|
||||
locale: Locale,
|
||||
options?: RequirePermissionOptions
|
||||
): Promise<boolean> {
|
||||
const member = interaction.memberPermissions;
|
||||
if (!member?.has(permission)) {
|
||||
if (!member?.has(memberPermission)) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.noPermission'),
|
||||
ephemeral: true
|
||||
@@ -16,16 +51,36 @@ export async function requirePermission(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!interaction.guild?.members.me?.permissions.has(permission)) {
|
||||
const botBits = options?.botPermissions;
|
||||
if (botBits === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const required = Array.isArray(botBits) ? botBits : [botBits];
|
||||
const me = interaction.guild?.members.me;
|
||||
if (!me) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.botMissingPermission').replace(
|
||||
'{permission}',
|
||||
permission.toString()
|
||||
required.map(permissionLabel).join(', ')
|
||||
),
|
||||
ephemeral: true
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const bit of required) {
|
||||
if (!me.permissions.has(bit)) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.botMissingPermission').replace(
|
||||
'{permission}',
|
||||
permissionLabel(bit)
|
||||
),
|
||||
ephemeral: true
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user