Implement overdue giveaway recovery and enhance giveaway command handling
- Added functionality to recover overdue giveaways during bot startup, ensuring that giveaways that have passed their end time are processed correctly. - Introduced `cancelGiveawayJob` to safely remove scheduled jobs, preventing issues with locked jobs. - Updated giveaway command handling to trim IDs and validate existence within the guild context, improving error handling and user feedback. - Enhanced the role picker component to normalize search queries and handle errors more gracefully, improving user experience. - Implemented caching strategies for guild roles to avoid stale or empty responses, ensuring more reliable data retrieval.
This commit is contained in:
@@ -116,6 +116,21 @@ if (!isShard) {
|
||||
logger.error({ error }, 'Failed to ensure recurring jobs');
|
||||
captureException(error, { phase: 'ensureRecurringJobs' });
|
||||
}
|
||||
|
||||
try {
|
||||
await runOncePerCluster(
|
||||
'nexumi:lock:recover-overdue-giveaways',
|
||||
60,
|
||||
async () => {
|
||||
const { recoverOverdueGiveaways } = await import('./modules/giveaways/index.js');
|
||||
await recoverOverdueGiveaways(context);
|
||||
},
|
||||
'Overdue giveaway recovery'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to recover overdue giveaways');
|
||||
captureException(error, { phase: 'recoverOverdueGiveaways' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requirePermission } from '../../permissions.js';
|
||||
import { parseDuration } from '../moderation/duration.js';
|
||||
import { giveawayCommandData } from './command-definitions.js';
|
||||
import {
|
||||
cancelGiveawayJob,
|
||||
deleteGiveaway,
|
||||
endGiveaway,
|
||||
GiveawayError,
|
||||
@@ -100,14 +101,23 @@ const giveawayCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
if (sub === 'end') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const id = interaction.options.getString('id', true).trim();
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||
if (!giveaway || giveaway.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
await cancelGiveawayJob(id);
|
||||
await endGiveaway(context, id);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'reroll') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const id = interaction.options.getString('id', true).trim();
|
||||
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||
if (!existing || existing.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
const updated = await rerollGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'giveaway.rerolled', {
|
||||
@@ -136,14 +146,22 @@ const giveawayCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
if (sub === 'delete') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const id = interaction.options.getString('id', true).trim();
|
||||
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||
if (!existing || existing.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
await deleteGiveaway(context, id);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'pause') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const id = interaction.options.getString('id', true).trim();
|
||||
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||
if (!existing || existing.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
const updated = await pauseGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { giveawayCommands } from './commands.js';
|
||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js';
|
||||
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, recoverOverdueGiveaways, GiveawayError } from './service.js';
|
||||
|
||||
@@ -22,25 +22,46 @@ export class GiveawayError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function giveawayEndJobId(giveawayId: string): string {
|
||||
return `giveaway-end-${giveawayId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a scheduled/failed end job if present. Locked (active) jobs cannot be
|
||||
* removed — callers must not invoke this from inside the giveawayEnd worker.
|
||||
*/
|
||||
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
||||
const job = await giveawayQueue.getJob(giveawayEndJobId(giveawayId));
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('locked') || message.includes('could not be removed')) {
|
||||
logger.warn({ giveawayId, error }, 'Giveaway end job is locked; skipping remove');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
|
||||
await cancelGiveawayJob(giveawayId);
|
||||
const delay = Math.max(0, endsAt.getTime() - Date.now());
|
||||
await giveawayQueue.add(
|
||||
'giveawayEnd',
|
||||
{ giveawayId },
|
||||
{
|
||||
delay,
|
||||
jobId: `giveaway-end-${giveawayId}`,
|
||||
jobId: giveawayEndJobId(giveawayId),
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
||||
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
|
||||
await job?.remove();
|
||||
}
|
||||
|
||||
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
||||
const lines: string[] = [];
|
||||
if (giveaway.requiredRoleId) {
|
||||
@@ -316,6 +337,12 @@ async function dmWinners(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws winners and marks the giveaway ended. Does **not** cancel BullMQ jobs —
|
||||
* the slash-command / dashboard paths cancel the delayed job first; the
|
||||
* `giveawayEnd` worker must never remove its own locked job (that threw and
|
||||
* left giveaways stuck with the join button still visible).
|
||||
*/
|
||||
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway) {
|
||||
@@ -325,7 +352,6 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
||||
throw new GiveawayError('already_ended');
|
||||
}
|
||||
|
||||
await cancelGiveawayJob(giveawayId);
|
||||
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
||||
@@ -347,6 +373,40 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends giveaways whose `endsAt` has passed but `endedAt` is still null
|
||||
* (e.g. after a failed/locked BullMQ job). Safe to call on bot ready.
|
||||
*/
|
||||
export async function recoverOverdueGiveaways(context: BotContext): Promise<number> {
|
||||
const overdue = await context.prisma.giveaway.findMany({
|
||||
where: {
|
||||
endedAt: null,
|
||||
endsAt: { lte: new Date() }
|
||||
},
|
||||
take: 100,
|
||||
orderBy: { endsAt: 'asc' }
|
||||
});
|
||||
|
||||
let recovered = 0;
|
||||
for (const giveaway of overdue) {
|
||||
try {
|
||||
await cancelGiveawayJob(giveaway.id);
|
||||
await endGiveaway(context, giveaway.id);
|
||||
recovered += 1;
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayError && error.code === 'already_ended') {
|
||||
continue;
|
||||
}
|
||||
logger.error({ error, giveawayId: giveaway.id }, 'Failed to recover overdue giveaway');
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
logger.info({ recovered }, 'Recovered overdue giveaways');
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
export async function rerollGiveaway(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
|
||||
Reference in New Issue
Block a user