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:
42
apps/bot/src/modules/giveaways/buttons.ts
Normal file
42
apps/bot/src/modules/giveaways/buttons.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ButtonInteraction } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { GiveawayError, joinGiveaway } from './service.js';
|
||||
|
||||
export const GIVEAWAY_JOIN_PREFIX = 'gw:join:';
|
||||
|
||||
export function joinButtonId(giveawayId: string): string {
|
||||
return `${GIVEAWAY_JOIN_PREFIX}${giveawayId}`;
|
||||
}
|
||||
|
||||
export function isGiveawayButton(customId: string): boolean {
|
||||
return customId.startsWith(GIVEAWAY_JOIN_PREFIX);
|
||||
}
|
||||
|
||||
export async function handleGiveawayButton(
|
||||
interaction: ButtonInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
const giveawayId = interaction.customId.slice(GIVEAWAY_JOIN_PREFIX.length);
|
||||
|
||||
if (!giveawayId || !interaction.guildId) {
|
||||
await interaction.reply({ content: t(locale, 'giveaway.error.not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await joinGiveaway(context, giveawayId, interaction.user.id, locale);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.joined'), ephemeral: true });
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `giveaway.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
61
apps/bot/src/modules/giveaways/command-definitions.ts
Normal file
61
apps/bot/src/modules/giveaways/command-definitions.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const giveawayCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('giveaway')
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('duration').setRequired(true), 'giveaway.start.options.duration')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('winners').setRequired(true).setMinValue(1).setMaxValue(25),
|
||||
'giveaway.start.options.winners'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('prize').setRequired(true), 'giveaway.start.options.prize')
|
||||
)
|
||||
.addRoleOption((o) =>
|
||||
applyOptionDescription(o.setName('role'), 'giveaway.start.options.role')
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('level').setMinValue(0).setMaxValue(500),
|
||||
'giveaway.start.options.level'
|
||||
)
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('member_days').setMinValue(0).setMaxValue(3650),
|
||||
'giveaway.start.options.member_days'
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('end'), 'giveaway.end.description').addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('reroll'), 'giveaway.reroll.description').addStringOption(
|
||||
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('list'), 'giveaway.list.description')
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('delete'), 'giveaway.delete.description').addStringOption(
|
||||
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('pause'), 'giveaway.pause.description').addStringOption(
|
||||
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||
)
|
||||
),
|
||||
'giveaway.description'
|
||||
);
|
||||
166
apps/bot/src/modules/giveaways/commands.ts
Normal file
166
apps/bot/src/modules/giveaways/commands.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { parseDuration } from '../moderation/duration.js';
|
||||
import { giveawayCommandData } from './command-definitions.js';
|
||||
import {
|
||||
deleteGiveaway,
|
||||
endGiveaway,
|
||||
GiveawayError,
|
||||
listActiveGiveaways,
|
||||
pauseGiveaway,
|
||||
rerollGiveaway,
|
||||
startGiveaway
|
||||
} from './service.js';
|
||||
|
||||
async function ensureManageGuild(
|
||||
interaction: Parameters<SlashCommand['execute']>[0],
|
||||
locale: 'de' | 'en'
|
||||
): Promise<boolean> {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||
}
|
||||
|
||||
const giveawayCommand: SlashCommand = {
|
||||
data: giveawayCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'start') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const durationInput = interaction.options.getString('duration', true);
|
||||
let durationMs: number;
|
||||
try {
|
||||
durationMs = parseDuration(durationInput);
|
||||
} catch {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'giveaway.error.invalid_duration'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationMs < 60_000) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'giveaway.error.duration_too_short'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'giveaway.error.invalid_channel'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const giveaway = await startGiveaway(
|
||||
context,
|
||||
{
|
||||
guildId: interaction.guildId!,
|
||||
channelId: channel.id,
|
||||
hostId: interaction.user.id,
|
||||
prize: interaction.options.getString('prize', true),
|
||||
winnerCount: interaction.options.getInteger('winners', true),
|
||||
durationMs,
|
||||
requiredRoleId: interaction.options.getRole('role')?.id,
|
||||
requiredLevel: interaction.options.getInteger('level'),
|
||||
requiredMemberDays: interaction.options.getInteger('member_days')
|
||||
},
|
||||
locale
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (sub === 'end') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
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 updated = await rerollGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'giveaway.rerolled', {
|
||||
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
||||
}),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const active = await listActiveGiveaways(context, interaction.guildId!);
|
||||
if (active.length === 0) {
|
||||
await interaction.reply({ content: t(locale, 'giveaway.list.empty'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const lines = active.map(
|
||||
(g) =>
|
||||
`\`${g.id}\` — **${g.prize}** (${g.entrants.length} ${t(locale, 'giveaway.list.entrants')}, <t:${Math.floor(g.endsAt.getTime() / 1000)}:R>)`
|
||||
);
|
||||
await interaction.reply({
|
||||
content: `${t(locale, 'giveaway.list.header')}\n${lines.join('\n')}`,
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'delete') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
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 updated = await pauseGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `giveaway.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const giveawayCommands = [giveawayCommand];
|
||||
3
apps/bot/src/modules/giveaways/index.ts
Normal file
3
apps/bot/src/modules/giveaways/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { giveawayCommands } from './commands.js';
|
||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||
export { endGiveaway, scheduleGiveawayEnd } from './service.js';
|
||||
419
apps/bot/src/modules/giveaways/service.ts
Normal file
419
apps/bot/src/modules/giveaways/service.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user