import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, type Guild, type GuildMember, type TextChannel } from 'discord.js'; import { pickRandomWinners, t, tf } from '@nexumi/shared'; import type { Giveaway } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; import { giveawayQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { joinButtonId } from './buttons.js'; export class GiveawayError extends Error { constructor(public readonly code: string) { super(code); } } export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise { const delay = Math.max(0, endsAt.getTime() - Date.now()); await giveawayQueue.add( 'giveawayEnd', { giveawayId }, { delay, jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50 } ); } export async function cancelGiveawayJob(giveawayId: string): Promise { 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) { lines.push(tf(locale, 'giveaway.requirement.role', { role: `<@&${giveaway.requiredRoleId}>` })); } if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) { lines.push(tf(locale, 'giveaway.requirement.level', { level: giveaway.requiredLevel })); } if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) { lines.push( tf(locale, 'giveaway.requirement.memberDays', { days: giveaway.requiredMemberDays }) ); } return lines; } export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder { const ended = giveaway.endedAt !== null; const endsAtUnix = Math.floor(giveaway.endsAt.getTime() / 1000); // Discord does not parse timestamps in embed footers — only in // description/fields/content. Keep relative + absolute for clarity. const endsAtText = ` ()`; const embed = new EmbedBuilder() .setTitle(t(locale, 'giveaway.embed.title')) .setColor(ended ? 0x6b7280 : 0x6366f1) .addFields( { name: t(locale, 'giveaway.embed.prize'), value: giveaway.prize, inline: true }, { name: t(locale, 'giveaway.embed.winners'), value: String(giveaway.winnerCount), inline: true }, { name: t(locale, 'giveaway.embed.entrants'), value: String(giveaway.entrants.length), inline: true } ); if (ended) { embed.setDescription( giveaway.winners.length > 0 ? tf(locale, 'giveaway.embed.endedWinners', { winners: giveaway.winners.map((id) => `<@${id}>`).join(', ') }) : t(locale, 'giveaway.embed.endedNoWinners') ); embed.setFooter({ text: t(locale, 'giveaway.embed.ended') }); } else if (giveaway.paused) { embed.setDescription( `${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', { time: endsAtText })}` ); } else { embed.setDescription( `${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', { time: endsAtText })}` ); } const requirements = requirementLines(locale, giveaway); if (requirements.length > 0) { embed.addFields({ name: t(locale, 'giveaway.embed.requirements'), value: requirements.join('\n') }); } embed.addFields({ name: t(locale, 'giveaway.embed.host'), value: `<@${giveaway.hostId}>`, inline: true }); return embed; } export function buildGiveawayComponents( locale: 'de' | 'en', giveaway: Giveaway ): ActionRowBuilder[] { if (giveaway.endedAt) { return []; } return [ new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(joinButtonId(giveaway.id)) .setLabel(t(locale, 'giveaway.button.join')) .setStyle(ButtonStyle.Primary) .setDisabled(giveaway.paused) ) ]; } async function updateGiveawayMessage( context: BotContext, giveaway: Giveaway, locale: 'de' | 'en' ): Promise { if (!giveaway.messageId) { return; } try { const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel; const message = await channel.messages.fetch(giveaway.messageId); await message.edit({ embeds: [buildGiveawayEmbed(locale, giveaway)], components: buildGiveawayComponents(locale, giveaway) }); } catch (error) { logger.warn({ error, giveawayId: giveaway.id }, 'Failed to update giveaway message'); } } export async function checkGiveawayRequirements( context: BotContext, guild: Guild, member: GuildMember, giveaway: Giveaway ): Promise { if (giveaway.requiredRoleId && !member.roles.cache.has(giveaway.requiredRoleId)) { return 'missing_role'; } if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) { const levelRow = await context.prisma.memberLevel.findUnique({ where: { guildId_userId: { guildId: guild.id, userId: member.id } } }); const level = levelRow?.level ?? 0; if (level < giveaway.requiredLevel) { return 'insufficient_level'; } } if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) { const joinedAt = member.joinedAt; const days = joinedAt ? Math.floor((Date.now() - joinedAt.getTime()) / 86_400_000) : 0; if (days < giveaway.requiredMemberDays) { return 'insufficient_member_days'; } } return null; } export async function startGiveaway( context: BotContext, params: { guildId: string; channelId: string; hostId: string; prize: string; winnerCount: number; durationMs: number; requiredRoleId?: string | null; requiredLevel?: number | null; requiredMemberDays?: number | null; }, locale: 'de' | 'en' ): Promise { await ensureGuild(context.prisma, params.guildId); const endsAt = new Date(Date.now() + params.durationMs); const giveaway = await context.prisma.giveaway.create({ data: { guildId: params.guildId, channelId: params.channelId, prize: params.prize, winnerCount: params.winnerCount, endsAt, requiredRoleId: params.requiredRoleId ?? null, requiredLevel: params.requiredLevel ?? null, requiredMemberDays: params.requiredMemberDays ?? null, hostId: params.hostId } }); const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel; const message = await channel.send({ embeds: [buildGiveawayEmbed(locale, giveaway)], components: buildGiveawayComponents(locale, giveaway) }); const updated = await context.prisma.giveaway.update({ where: { id: giveaway.id }, data: { messageId: message.id } }); await scheduleGiveawayEnd(updated.id, updated.endsAt); return updated; } export async function joinGiveaway( context: BotContext, giveawayId: string, userId: string, locale: 'de' | 'en' ): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); if (!giveaway || giveaway.endedAt) { throw new GiveawayError('not_found'); } if (giveaway.paused) { throw new GiveawayError('paused'); } if (giveaway.entrants.includes(userId)) { throw new GiveawayError('already_joined'); } const guild = await context.client.guilds.fetch(giveaway.guildId); const member = await guild.members.fetch(userId).catch(() => null); if (!member) { throw new GiveawayError('member_not_found'); } const requirementError = await checkGiveawayRequirements(context, guild, member, giveaway); if (requirementError) { throw new GiveawayError(requirementError); } const updated = await context.prisma.giveaway.update({ where: { id: giveawayId }, data: { entrants: { push: userId } } }); await updateGiveawayMessage(context, updated, locale); return updated; } async function filterEligibleEntrants( context: BotContext, guild: Guild, giveaway: Giveaway ): Promise { const eligible: string[] = []; for (const userId of giveaway.entrants) { const member = await guild.members.fetch(userId).catch(() => null); if (!member) { continue; } const error = await checkGiveawayRequirements(context, guild, member, giveaway); if (!error) { eligible.push(userId); } } return eligible; } async function dmWinners( context: BotContext, giveaway: Giveaway, winnerIds: string[], locale: 'de' | 'en' ): Promise { for (const userId of winnerIds) { try { const user = await context.client.users.fetch(userId); await user.send( tf(locale, 'giveaway.dm.won', { prize: giveaway.prize, guild: (await context.client.guilds.fetch(giveaway.guildId)).name }) ); } catch (error) { logger.warn({ error, userId, giveawayId: giveaway.id }, 'Failed to DM giveaway winner'); } } } export async function endGiveaway(context: BotContext, giveawayId: string): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); if (!giveaway) { throw new GiveawayError('not_found'); } if (giveaway.endedAt) { 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); const winners = pickRandomWinners(eligible, giveaway.winnerCount); const updated = await context.prisma.giveaway.update({ where: { id: giveawayId }, data: { endedAt: new Date(), winners, paused: false } }); await updateGiveawayMessage(context, updated, locale); if (winners.length > 0) { await dmWinners(context, updated, winners, locale); } return updated; } export async function rerollGiveaway( context: BotContext, giveawayId: string, locale: 'de' | 'en' ): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); if (!giveaway || !giveaway.endedAt) { throw new GiveawayError('not_ended'); } const guild = await context.client.guilds.fetch(giveaway.guildId); const eligible = (await filterEligibleEntrants(context, guild, giveaway)).filter( (id) => !giveaway.winners.includes(id) ); const newWinners = pickRandomWinners(eligible, giveaway.winnerCount); const updated = await context.prisma.giveaway.update({ where: { id: giveawayId }, data: { winners: newWinners } }); await updateGiveawayMessage(context, updated, locale); if (newWinners.length > 0) { await dmWinners(context, updated, newWinners, locale); } return updated; } export async function pauseGiveaway( context: BotContext, giveawayId: string, locale: 'de' | 'en' ): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); if (!giveaway || giveaway.endedAt) { throw new GiveawayError('not_found'); } const updated = await context.prisma.giveaway.update({ where: { id: giveawayId }, data: { paused: !giveaway.paused } }); await updateGiveawayMessage(context, updated, locale); return updated; } export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); if (!giveaway) { throw new GiveawayError('not_found'); } await cancelGiveawayJob(giveawayId); if (giveaway.messageId) { try { const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel; const message = await channel.messages.fetch(giveaway.messageId); await message.delete(); } catch { // Message may already be gone } } await context.prisma.giveaway.delete({ where: { id: giveawayId } }); } /** * Entry point for the `giveawayCreate` BullMQ job, enqueued by the WebUI * dashboard (which has no discord.js Client to post the giveaway message * itself). Resolves the guild's locale and delegates to {@link startGiveaway} * so dashboard-created giveaways behave identically to `/giveaway start`. */ export async function runGiveawayCreateJob( context: BotContext, params: { guildId: string; channelId: string; hostId: string; prize: string; winnerCount: number; durationMs: number; requiredRoleId?: string | null; requiredLevel?: number | null; requiredMemberDays?: number | null; } ): Promise<{ id: string }> { const locale = await getGuildLocale(context.prisma, params.guildId); const giveaway = await startGiveaway(context, params, locale); return { id: giveaway.id }; } export async function listActiveGiveaways( context: BotContext, guildId: string ): Promise { return context.prisma.giveaway.findMany({ where: { guildId, endedAt: null }, orderBy: { endsAt: 'asc' } }); }