fix: Giveaway bugs

This commit is contained in:
smueller
2026-07-24 10:33:56 +02:00
parent bd1b55fff7
commit f2406fb6d7
7 changed files with 74 additions and 25 deletions

View File

@@ -13,7 +13,7 @@ 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 } from './modules/giveaways/index.js'; import { endGiveaway, runGiveawayCreateJob, 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';
@@ -303,7 +303,17 @@ export function startWorkers(context: BotContext): Worker[] {
giveawayQueueName, giveawayQueueName,
async (job) => { async (job) => {
if (job.name === 'giveawayEnd') { if (job.name === 'giveawayEnd') {
try {
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId); await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
} catch (error) {
if (
error instanceof GiveawayError &&
(error.code === 'already_ended' || error.code === 'not_found')
) {
return;
}
throw error;
}
return; return;
} }
if (job.name === 'giveawayCreate') { if (job.name === 'giveawayCreate') {

View File

@@ -1,3 +1,3 @@
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 } from './service.js'; export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js';

View File

@@ -59,6 +59,11 @@ function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder { export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
const ended = giveaway.endedAt !== null; const ended = giveaway.endedAt !== null;
const endsAtUnix = Math.floor(giveaway.endsAt.getTime() / 1000);
// Discord does not parse <t:…> timestamps in embed footers — only in
// description/fields/content. Keep relative + absolute for clarity.
const endsAtText = `<t:${endsAtUnix}:R> (<t:${endsAtUnix}:f>)`;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(t(locale, 'giveaway.embed.title')) .setTitle(t(locale, 'giveaway.embed.title'))
.setColor(ended ? 0x6b7280 : 0x6366f1) .setColor(ended ? 0x6b7280 : 0x6366f1)
@@ -86,19 +91,17 @@ export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): Emb
); );
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') }); embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
} else if (giveaway.paused) { } else if (giveaway.paused) {
embed.setDescription(t(locale, 'giveaway.embed.paused')); embed.setDescription(
embed.setFooter({ `${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
text: tf(locale, 'giveaway.embed.endsAt', { time: endsAtText
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>` })}`
}) );
});
} else { } else {
embed.setDescription(t(locale, 'giveaway.embed.active')); embed.setDescription(
embed.setFooter({ `${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
text: tf(locale, 'giveaway.embed.endsAt', { time: endsAtText
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>` })}`
}) );
});
} }
const requirements = requirementLines(locale, giveaway); const requirements = requirementLines(locale, giveaway);
@@ -313,10 +316,13 @@ async function dmWinners(
} }
} }
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<void> { 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 } });
if (!giveaway || giveaway.endedAt) { if (!giveaway) {
return; throw new GiveawayError('not_found');
}
if (giveaway.endedAt) {
throw new GiveawayError('already_ended');
} }
await cancelGiveawayJob(giveawayId); await cancelGiveawayJob(giveawayId);
@@ -338,6 +344,7 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
if (winners.length > 0) { if (winners.length > 0) {
await dmWinners(context, updated, winners, locale); await dmWinners(context, updated, winners, locale);
} }
return updated;
} }
export async function rerollGiveaway( export async function rerollGiveaway(

View File

@@ -83,9 +83,18 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }) body: JSON.stringify({ action })
}); });
const body = (await response.json().catch(() => null)) as (GiveawayDashboard & { error?: string }) | null; const body = (await response.json().catch(() => null)) as
if (!response.ok || !body) { | (GiveawayDashboard & { error?: string })
toast.error(body?.error ?? t('common.saveError')); | { error?: string }
| null;
if (!response.ok || !body || !('id' in body)) {
toast.error(
(body && 'error' in body && body.error) || t('common.saveError')
);
return;
}
if (action === 'end' && !body.endedAt) {
toast.error(t('common.saveError'));
return; return;
} }
setGiveaways((prev) => prev.map((entry) => (entry.id === giveaway.id ? body : entry))); setGiveaways((prev) => prev.map((entry) => (entry.id === giveaway.id ? body : entry)));

View File

@@ -115,16 +115,24 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string):
const existingJob = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`); const existingJob = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`);
await existingJob?.remove(); await existingJob?.remove();
await addJobAndAwait( const { confirmed } = await addJobAndAwait(
getGiveawayQueue(), getGiveawayQueue(),
getGiveawayQueueEvents(), getGiveawayQueueEvents(),
'giveawayEnd', 'giveawayEnd',
{ giveawayId }, { giveawayId },
{ jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 } { jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 },
30_000
); );
const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } }); const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } });
return updated ? toDashboard(updated) : null; if (!updated?.endedAt) {
throw new Error(
confirmed
? 'Giveaway end job finished without marking the giveaway as ended'
: 'Giveaway end timed out the bot may still be processing; refresh and retry'
);
}
return toDashboard(updated);
} }
export async function deleteGiveawayDashboard(guildId: string, giveawayId: string): Promise<boolean> { export async function deleteGiveawayDashboard(guildId: string, giveawayId: string): Promise<boolean> {

View File

@@ -258,7 +258,20 @@
- [ ] Stack an/aus: stapeln vs. nur aktuelle Belohnungsrolle - [ ] Stack an/aus: stapeln vs. nur aktuelle Belohnungsrolle
- [ ] Doppeltes Level → Fehlermeldung, speichern blockiert - [ ] Doppeltes Level → Fehlermeldung, speichern blockiert
## Post-Phase Automod/Moderation Dashboard Ausbau (Status: implementiert) ## Post-Phase Giveaway Bugs (Timestamp + End-Status) (Status: implementiert)
### Abgeschlossen (Code)
- Embed: Endzeit als Discord-Timestamp in der **Description** (Footer rendert `<t:…>` nicht)
- `/giveaway end` wirft bei fehlender/bereits beendeter ID Fehler statt Fake-Erfolg
- Dashboard „Jetzt beenden“ prüft `endedAt` nach Job; Timeout/Fehler → kein Erfolgs-Toast
### Manuell testen
- [ ] Neues Giveaway: Endzeit als „in X Stunden“ sichtbar, nicht als Roh-`<t:…>`
- [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich
- [ ] Dashboard „Jetzt beenden“ → Status beendet, Discord-Nachricht aktualisiert
### Abgeschlossen (Code) ### Abgeschlossen (Code)

View File

@@ -395,6 +395,7 @@ const de: Dictionary = {
'giveaway.list.entrants': 'Teilnehmer', 'giveaway.list.entrants': 'Teilnehmer',
'giveaway.dm.won': 'Glückwunsch! Du hast **{prize}** auf **{guild}** gewonnen!', 'giveaway.dm.won': 'Glückwunsch! Du hast **{prize}** auf **{guild}** gewonnen!',
'giveaway.error.not_found': 'Giveaway nicht gefunden oder bereits beendet.', 'giveaway.error.not_found': 'Giveaway nicht gefunden oder bereits beendet.',
'giveaway.error.already_ended': 'Dieses Giveaway ist bereits beendet.',
'giveaway.error.not_ended': 'Giveaway ist noch nicht beendet.', 'giveaway.error.not_ended': 'Giveaway ist noch nicht beendet.',
'giveaway.error.paused': 'Dieses Giveaway ist pausiert.', 'giveaway.error.paused': 'Dieses Giveaway ist pausiert.',
'giveaway.error.already_joined': 'Du nimmst bereits teil.', 'giveaway.error.already_joined': 'Du nimmst bereits teil.',
@@ -980,6 +981,7 @@ const en: Dictionary = {
'giveaway.list.entrants': 'entrants', 'giveaway.list.entrants': 'entrants',
'giveaway.dm.won': 'Congratulations! You won **{prize}** on **{guild}**!', 'giveaway.dm.won': 'Congratulations! You won **{prize}** on **{guild}**!',
'giveaway.error.not_found': 'Giveaway not found or already ended.', 'giveaway.error.not_found': 'Giveaway not found or already ended.',
'giveaway.error.already_ended': 'This giveaway has already ended.',
'giveaway.error.not_ended': 'Giveaway has not ended yet.', 'giveaway.error.not_ended': 'Giveaway has not ended yet.',
'giveaway.error.paused': 'This giveaway is paused.', 'giveaway.error.paused': 'This giveaway is paused.',
'giveaway.error.already_joined': 'You already entered.', 'giveaway.error.already_joined': 'You already entered.',