Add giveaway, ticket, self-role, starboard, and suggestion features to the bot

- Introduced new models in the Prisma schema for giveaways, tickets, self-role panels, starboard configurations, and suggestions, enhancing the bot's functionality.
- Implemented job handling for self-role expiration and ticket management, improving user experience.
- Updated command localization to support new features in both German and English.
- Enhanced the job processing system to include dedicated workers for managing tickets and self-role expirations.
- Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
This commit is contained in:
smueller
2026-07-22 13:08:37 +02:00
parent f2f856628d
commit a583db7a15
50 changed files with 7820 additions and 4 deletions

View File

@@ -0,0 +1,419 @@
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<void> {
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<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) {
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 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'));
embed.setFooter({
text: tf(locale, 'giveaway.embed.endsAt', {
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
})
});
} else {
embed.setDescription(t(locale, 'giveaway.embed.active'));
embed.setFooter({
text: tf(locale, 'giveaway.embed.endsAt', {
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
})
});
}
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<ButtonBuilder>[] {
if (giveaway.endedAt) {
return [];
}
return [
new ActionRowBuilder<ButtonBuilder>().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<void> {
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<string | null> {
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<Giveaway> {
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<Giveaway> {
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<string[]> {
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<void> {
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<void> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
return;
}
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);
}
}
export async function rerollGiveaway(
context: BotContext,
giveawayId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
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<Giveaway> {
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<void> {
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 } });
}
export async function listActiveGiveaways(
context: BotContext,
guildId: string
): Promise<Giveaway[]> {
return context.prisma.giveaway.findMany({
where: { guildId, endedAt: null },
orderBy: { endsAt: 'asc' }
});
}