From 52b10c95368493dcdfc8689729334da449f45039 Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 13:21:28 +0200 Subject: [PATCH] Add new features for giveaways, tickets, self-roles, tags, starboard, suggestions, and birthdays - Integrated new command modules for managing giveaways, tickets, self-roles, tags, starboard, suggestions, and birthdays, enhancing the bot's capabilities. - Implemented job handling for giveaway endings, ticket management, and birthday role expirations, improving automation and user experience. - Updated command definitions and interaction handling to support new features, ensuring smooth user interactions. - Enhanced the job processing system with dedicated workers for managing various tasks, including auto-closing tickets and running birthday checks. - Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps. --- apps/bot/src/commands.ts | 18 +++- apps/bot/src/index.ts | 80 +++++++++++++++- apps/bot/src/jobs.ts | 92 ++++++++++++++++++- apps/bot/src/modules/birthdays/service.ts | 2 +- .../modules/selfroles/command-definitions.ts | 6 +- .../modules/tickets/command-definitions.ts | 58 ++++++------ apps/bot/src/modules/tickets/commands.ts | 7 +- apps/bot/src/modules/tickets/interactions.ts | 2 +- apps/bot/src/modules/tickets/service.ts | 7 +- docs/PHASE-TRACKING.md | 69 ++++++-------- packages/shared/src/phase4.test.ts | 20 ++++ 11 files changed, 271 insertions(+), 90 deletions(-) create mode 100644 packages/shared/src/phase4.test.ts diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index 69d6299..1746aa6 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -16,6 +16,14 @@ import { levelingCommands } from './modules/leveling/commands.js'; import { economyCommands } from './modules/economy/commands.js'; import { utilityCommands, utilityContextMenus } from './modules/utility/index.js'; import { funCommands } from './modules/fun/index.js'; +import { giveawayCommands } from './modules/giveaways/index.js'; +import { ticketsCommands } from './modules/tickets/index.js'; +import { selfrolesCommands } from './modules/selfroles/index.js'; +import { tagsCommands } from './modules/tags/index.js'; +import { starboardCommands } from './modules/starboard/index.js'; +import { suggestionsCommands } from './modules/suggestions/index.js'; +import { birthdayCommands } from './modules/birthdays/index.js'; +import { tempVoiceCommands } from './modules/tempvoice/index.js'; import type { BotContext, SlashCommand } from './types.js'; const commands: SlashCommand[] = [ @@ -26,7 +34,15 @@ const commands: SlashCommand[] = [ ...levelingCommands, ...economyCommands, ...utilityCommands, - ...funCommands + ...funCommands, + ...giveawayCommands, + ...ticketsCommands, + ...selfrolesCommands, + ...tagsCommands, + ...starboardCommands, + ...suggestionsCommands, + ...birthdayCommands, + ...tempVoiceCommands ]; const map = new Map(commands.map((c) => [c.data.name, c])); const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c])); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 9dc0445..d560a4f 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -39,6 +39,29 @@ import { registerUtilityEvents } from './modules/utility/index.js'; import { handleFunButton, isFunButton } from './modules/fun/index.js'; +import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js'; +import { + handleTicketInteraction, + isTicketInteraction, + registerTicketEvents +} from './modules/tickets/index.js'; +import { + handleSelfRoleInteraction, + isSelfRoleInteraction, + registerSelfRoleEvents +} from './modules/selfroles/index.js'; +import { registerTagEvents } from './modules/tags/index.js'; +import { registerStarboardEvents } from './modules/starboard/index.js'; +import { + handleSuggestionButton, + isSuggestionButton +} from './modules/suggestions/index.js'; +import { + handleTempVoiceInteraction, + isTempVoiceInteraction, + isTempVoiceModal, + registerTempVoiceEvents +} from './modules/tempvoice/index.js'; const isShard = process.argv.includes('--shard'); @@ -57,13 +80,19 @@ if (!isShard) { GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildModeration, GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildEmojisAndStickers, GatewayIntentBits.GuildInvites, GatewayIntentBits.MessageContent ], - partials: [Partials.Channel, Partials.Message, Partials.GuildMember] + partials: [ + Partials.Channel, + Partials.Message, + Partials.GuildMember, + Partials.Reaction + ] }); const context: BotContext = { client, prisma, redis }; @@ -74,10 +103,19 @@ if (!isShard) { registerVerificationEvents(context); registerLevelingEvents(context); registerUtilityEvents(context); + registerTicketEvents(context); + registerSelfRoleEvents(client, context); + registerTagEvents(client, context); + registerStarboardEvents(context); + registerTempVoiceEvents(context); client.on(Events.ClientReady, async () => { logger.info({ tag: client.user?.tag }, 'Bot ready'); - await registerCommands(); + try { + await registerCommands(); + } catch (error) { + logger.error({ error }, 'Failed to register application commands'); + } }); client.on(Events.MessageCreate, async (message) => { @@ -125,6 +163,44 @@ if (!isShard) { return; } + if (interaction.isButton() && isGiveawayButton(interaction.customId)) { + await handleGiveawayButton(interaction, context); + return; + } + + if ( + (interaction.isButton() || + interaction.isStringSelectMenu() || + interaction.isModalSubmit()) && + isTicketInteraction(interaction.customId) + ) { + await handleTicketInteraction(interaction, context); + return; + } + + if ( + (interaction.isButton() || interaction.isStringSelectMenu()) && + isSelfRoleInteraction(interaction.customId) + ) { + await handleSelfRoleInteraction(interaction, context); + return; + } + + if (interaction.isButton() && isSuggestionButton(interaction.customId)) { + await handleSuggestionButton(interaction, context); + return; + } + + if (interaction.isButton() && isTempVoiceInteraction(interaction.customId)) { + await handleTempVoiceInteraction(interaction, context); + return; + } + + if (interaction.isModalSubmit() && isTempVoiceModal(interaction.customId)) { + await handleTempVoiceInteraction(interaction, context); + return; + } + if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) { await handleEmbedModal(interaction, context); return; diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 21edc2d..30fb60b 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -12,13 +12,20 @@ import { completeVerification } from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; import { expireSelfRole } from './modules/selfroles/service.js'; +import { endGiveaway } from './modules/giveaways/index.js'; +import { closeInactiveTickets } from './modules/tickets/index.js'; +import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { automodQueue, automodQueueName, backupQueue, backupQueueName, + birthdayQueue, + birthdayQueueName, + giveawayQueueName, moderationQueueName, reminderQueueName, + ticketQueue, ticketQueueName, verificationQueueName } from './queues.js'; @@ -57,6 +64,16 @@ type SelfRoleExpireJob = { roleId: string; }; +type GiveawayEndJob = { + giveawayId: string; +}; + +type BirthdayRoleExpireJob = { + guildId: string; + userId: string; + roleId: string; +}; + export function startWorkers(context: BotContext): Worker[] { const moderationWorker = new Worker( moderationQueueName, @@ -143,13 +160,17 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Reminder job failed'); }); - const ticketWorker = new Worker( + const ticketWorker = new Worker( ticketQueueName, async (job) => { - if (job.name !== 'selfRoleExpire') { + if (job.name === 'selfRoleExpire') { + const data = job.data as SelfRoleExpireJob; + await expireSelfRole(context, data.guildId, data.userId, data.roleId); return; } - await expireSelfRole(context, job.data.guildId, job.data.userId, job.data.roleId); + if (job.name === 'ticketAutoClose') { + await closeInactiveTickets(context); + } }, { connection: redis } ); @@ -158,7 +179,50 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); }); - return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker, ticketWorker]; + const giveawayWorker = new Worker( + giveawayQueueName, + async (job) => { + if (job.name !== 'giveawayEnd') { + return; + } + await endGiveaway(context, job.data.giveawayId); + }, + { connection: redis } + ); + + giveawayWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Giveaway job failed'); + }); + + const birthdayWorker = new Worker( + birthdayQueueName, + async (job) => { + if (job.name === 'birthdayDailyCheck') { + await runBirthdayCheck(context); + return; + } + if (job.name === 'birthdayRoleExpire') { + const data = job.data as BirthdayRoleExpireJob; + await expireBirthdayRole(context, data.guildId, data.userId, data.roleId); + } + }, + { connection: redis } + ); + + birthdayWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Birthday job failed'); + }); + + return [ + moderationWorker, + backupWorker, + automodWorker, + verificationWorker, + reminderWorker, + ticketWorker, + giveawayWorker, + birthdayWorker + ]; } export async function ensureRecurringJobs(): Promise { @@ -183,6 +247,26 @@ export async function ensureRecurringJobs(): Promise { ); await automodQueue.add('refreshPhishingList', {}, { removeOnComplete: 20, removeOnFail: 20 }); + + await ticketQueue.add( + 'ticketAutoClose', + {}, + { + repeat: { pattern: '0 * * * *' }, + removeOnComplete: 20, + removeOnFail: 20 + } + ); + + await birthdayQueue.add( + 'birthdayDailyCheck', + {}, + { + repeat: { pattern: '0 * * * *' }, + removeOnComplete: 20, + removeOnFail: 20 + } + ); } async function runPgDumpBackup(): Promise { diff --git a/apps/bot/src/modules/birthdays/service.ts b/apps/bot/src/modules/birthdays/service.ts index b82bfce..ed4258b 100644 --- a/apps/bot/src/modules/birthdays/service.ts +++ b/apps/bot/src/modules/birthdays/service.ts @@ -1,6 +1,6 @@ import { PermissionFlagsBits, type Guild, type GuildMember, type TextChannel } from 'discord.js'; import { z } from 'zod'; -import { applyTagPlaceholders, tf } from '@nexumi/shared'; +import { applyTagPlaceholders } from '@nexumi/shared'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; import { birthdayQueue } from '../../queues.js'; diff --git a/apps/bot/src/modules/selfroles/command-definitions.ts b/apps/bot/src/modules/selfroles/command-definitions.ts index 983d7b4..e40ffa8 100644 --- a/apps/bot/src/modules/selfroles/command-definitions.ts +++ b/apps/bot/src/modules/selfroles/command-definitions.ts @@ -15,9 +15,6 @@ export const selfrolesCommandData = applyCommandDescription( .addStringOption((o) => applyOptionDescription(o.setName('title').setRequired(true), 'selfroles.panel.create.options.title') ) - .addStringOption((o) => - applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description') - ) .addStringOption((o) => applyOptionDescription(o.setName('mode').setRequired(true), 'selfroles.panel.create.options.mode') .addChoices( @@ -45,6 +42,9 @@ export const selfrolesCommandData = applyCommandDescription( applyOptionDescription(o.setName('channel').setRequired(true), 'selfroles.panel.create.options.channel') .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) ) + .addStringOption((o) => + applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description') + ) .addStringOption((o) => applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration') ) diff --git a/apps/bot/src/modules/tickets/command-definitions.ts b/apps/bot/src/modules/tickets/command-definitions.ts index f2ff815..14b3b87 100644 --- a/apps/bot/src/modules/tickets/command-definitions.ts +++ b/apps/bot/src/modules/tickets/command-definitions.ts @@ -5,41 +5,37 @@ import { } from '@nexumi/shared'; import { SlashCommandBuilder } from 'discord.js'; +// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings. +// SPEC `/ticket panel create` is represented as subcommand `panel_create`. export const ticketCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('ticket') - .addSubcommandGroup((group) => - applyCommandDescription(group.setName('panel'), 'ticket.panel.description').addSubcommand( - (s) => - applyCommandDescription(s.setName('create'), 'ticket.panel.create.description') - .addStringOption((o) => - applyOptionDescription(o.setName('name').setRequired(true), 'ticket.panel.create.options.name') - ) - .addStringOption((o) => - applyOptionDescription( - o.setName('description'), - 'ticket.panel.create.options.description' - ) - ) - ) + .addSubcommand((s) => + applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description') + .addStringOption((o) => + applyOptionDescription(o.setName('name').setRequired(true), 'ticket.panel.create.options.name') + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('description'), + 'ticket.panel.create.options.description' + ) + ) ) - .addSubcommandGroup((group) => - applyCommandDescription(group.setName('category'), 'ticket.category.description').addSubcommand( - (s) => - applyCommandDescription(s.setName('create'), 'ticket.category.create.description') - .addStringOption((o) => - applyOptionDescription( - o.setName('name').setRequired(true), - 'ticket.category.create.options.name' - ) - ) - .addRoleOption((o) => - applyOptionDescription( - o.setName('support_role').setRequired(true), - 'ticket.category.create.options.support_role' - ) - ) - ) + .addSubcommand((s) => + applyCommandDescription(s.setName('category_create'), 'ticket.category.create.description') + .addStringOption((o) => + applyOptionDescription( + o.setName('name').setRequired(true), + 'ticket.category.create.options.name' + ) + ) + .addRoleOption((o) => + applyOptionDescription( + o.setName('support_role').setRequired(true), + 'ticket.category.create.options.support_role' + ) + ) ) .addSubcommand((s) => applyCommandDescription(s.setName('close'), 'ticket.close.description').addStringOption((o) => diff --git a/apps/bot/src/modules/tickets/commands.ts b/apps/bot/src/modules/tickets/commands.ts index 164f4da..2b87054 100644 --- a/apps/bot/src/modules/tickets/commands.ts +++ b/apps/bot/src/modules/tickets/commands.ts @@ -41,10 +41,9 @@ const ticketCommand: SlashCommand = { return; } - const group = interaction.options.getSubcommandGroup(false); - const sub = interaction.options.getSubcommand(false); + const sub = interaction.options.getSubcommand(true); - if (group === 'panel' && sub === 'create') { + if (sub === 'panel_create') { if (!(await ensureManageGuild(interaction, locale))) { return; } @@ -75,7 +74,7 @@ const ticketCommand: SlashCommand = { return; } - if (group === 'category' && sub === 'create') { + if (sub === 'category_create') { if (!(await ensureManageGuild(interaction, locale))) { return; } diff --git a/apps/bot/src/modules/tickets/interactions.ts b/apps/bot/src/modules/tickets/interactions.ts index 0ced144..e8c8fea 100644 --- a/apps/bot/src/modules/tickets/interactions.ts +++ b/apps/bot/src/modules/tickets/interactions.ts @@ -113,7 +113,7 @@ export async function handleTicketInteraction( const parsedRate = parseRateButton(interaction.customId); if (parsedRate) { try { - await rateTicket(context, parsedRate.ticketId, interaction.user.id, parsedRate.rating, locale); + await rateTicket(context, parsedRate.ticketId, interaction.user.id, parsedRate.rating); await interaction.update({ content: tf(locale, 'ticket.rated', { rating: parsedRate.rating }), components: [] diff --git a/apps/bot/src/modules/tickets/service.ts b/apps/bot/src/modules/tickets/service.ts index d88a18f..8adef4c 100644 --- a/apps/bot/src/modules/tickets/service.ts +++ b/apps/bot/src/modules/tickets/service.ts @@ -333,7 +333,7 @@ async function fetchTicketMessages( } } -function buildRatingComponents(ticketId: string, locale: 'de' | 'en'): ActionRowBuilder { +function buildRatingComponents(ticketId: string): ActionRowBuilder { const row = new ActionRowBuilder(); for (let rating = 1; rating <= 5; rating += 1) { row.addComponents( @@ -704,7 +704,7 @@ export async function closeTicket( try { await opener.send({ content: t(locale, 'ticket.ratePrompt'), - components: [buildRatingComponents(ticket.id, locale)] + components: [buildRatingComponents(ticket.id)] }); } catch { // User may have DMs disabled @@ -729,8 +729,7 @@ export async function rateTicket( context: BotContext, ticketId: string, userId: string, - rating: number, - locale: 'de' | 'en' + rating: number ): Promise { const ticket = await context.prisma.ticket.findUnique({ where: { id: ticketId } }); if (!ticket || ticket.openerId !== userId) { diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 3813a62..9c7923c 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -173,48 +173,39 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede - `/rps`, `/choose`, `/trivia`, `/tictactoe`, `/connect4`, `/hangman` - `/meme`, `/cat`, `/dog` (abschaltbar via Redis `fun:config:{guildId}`) +### Manuelle Discord-Tests (Phase 3 – bestätigt) + +- Manuell auf Test-Server bestätigt (User-Freigabe). + +## Phase 4 – Giveaways, Tickets, Selfroles, Tags, Starboard, Suggestions, Birthdays, Temp-Voice (Status: implementiert, manuelle Tests ausstehend) + +### Abgeschlossen + +- Prisma-Migration `20260722160000_phase4_modules` +- Shared `packages/shared/src/phase4.ts` + Tests +- Alle Module in `commands.ts` / `index.ts` / `jobs.ts` verdrahtet +- Intents: `GuildMessageReactions`; Partials: `Reaction` +- Queues: `giveaways`, `tickets` (inkl. SelfRole-Expire + Auto-Close), `birthdays` + +### Module + +| Modul | Features | +|--------|----------| +| **Giveaways** | `/giveaway start\|end\|reroll\|list\|delete\|pause`, Join-Button, Auto-End | +| **Tickets** | Panel/Kategorie, Close/Claim/…, HTML-Transkript, Rating, Auto-Close | +| **Selfroles** | Panel create/edit/delete (Buttons/Dropdown/Reactions + Behaviors) | +| **Tags** | create/edit/delete/list/info/run, Auto-Responder | +| **Starboard** | `/starboard setup`, Reaction-Events | +| **Suggestions** | `/suggest`, Staff-Status, Voting-Buttons | +| **Birthdays** | set/remove/next/list/setup, stündlicher Check, 24h-Rolle | +| **Temp-Voice** | setup + Steuerung + Join-to-Create + Control-Panel | + ### Manuelle Discord-Tests (offen) -- Leveling: Nachrichten/Voice → XP, `/rank`, Level-Up, `/xp` -- Economy: Daily/Work, Shop, Blackjack-Buttons, `/eco give` -- Utility: Poll, Reminder, AFK, Snipe, Embed, Translate -- Fun: `/flip` vs `/coinflip`, Spiele, Medien-APIs -- Stack neu bauen: `docker compose up -d --build` (Migration anwenden) +- Giveaway, Ticket-Flow, Selfroles, Tags, Starboard, Suggestions, Birthday, Temp-Voice +- `docker compose up -d --build` (Migration + Guild-Command-Sync) ## Nächster geplanter Schritt -- Manuelle Phase-3-Tests, dann nach Freigabe Phase 4 (Giveaways, Tickets, Reaction Roles, Custom Commands, Geburtstage, Temp-Voice). - -### Starboard (`apps/bot/src/modules/starboard/`) - -- `/starboard setup` (Kanal, Schwellenwert, Emoji, Self-Star) -- Reaktions-Events: Nachrichten mit genügend ⭐ (konfigurierbar) → Embed im Starboard-Kanal -- NSFW-/Bot-/Ignore-Kanal-Ausschluss, Self-Star-Regel, Eintrag in `StarboardEntry` - -### Suggestions (`apps/bot/src/modules/suggestions/`) - -- `/suggest`, `/suggestion setup|approve|deny|consider|implement` -- Upvote/Downvote-Buttons (`sug:vote:{id}:up|down`), optional Thread -- Status-Update mit Kanal-Verschiebung (approved/denied) und DM an Autor - -### Manuelle Discord-Tests Starboard/Suggestions (offen) - -- `/starboard setup`, Reaktionen bis/unter Schwellenwert, Self-Star aus/an -- `/suggestion setup`, `/suggest`, Voting, Staff-Status + DM - -### Giveaways (`apps/bot/src/modules/giveaways/`) - -- `/giveaway start|end|reroll|list|delete|pause` -- Button `gw:join:{giveawayId}`, BullMQ-Job `giveawayEnd` (Export: `endGiveaway`) -- Anforderungen: Rolle, Level, Mitgliedschaftsdauer; DM an Gewinner - -### Tickets (`apps/bot/src/modules/tickets/`) - -- `/ticket panel create`, `/ticket category create`, `/ticket close|claim|add|remove|rename|priority` -- Panel-Buttons/Select, Modal, Kanal-/Thread-Modus, HTML-Transkript, Bewertung, Auto-Close-Export - -### Manuelle Discord-Tests Giveaways/Tickets (offen) - -- Giveaway: Start, Join, Ende/Reroll/Pause; Ticket: Panel, Öffnen, Close, Transkript, Rating -- Wiring in `commands.ts`, `index.ts`, `jobs.ts` (Parent-Agent) +- Manuelle Phase-4-Tests, dann nach Freigabe Phase 5 (Statistiken/Invite-Tracking, Social-Feeds, Scheduler, Backup). diff --git a/packages/shared/src/phase4.test.ts b/packages/shared/src/phase4.test.ts new file mode 100644 index 0000000..971edb6 --- /dev/null +++ b/packages/shared/src/phase4.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { applyTagPlaceholders, escapeHtml, pickRandomWinners } from './phase4.js'; + +describe('phase4 helpers', () => { + it('picks unique random winners', () => { + const winners = pickRandomWinners([1, 2, 3, 4, 5], 3); + expect(winners).toHaveLength(3); + expect(new Set(winners).size).toBe(3); + }); + + it('applies tag placeholders', () => { + expect(applyTagPlaceholders('Hi {user} on {server}', { user: '<@1>', server: 'Nexumi' })).toBe( + 'Hi <@1> on Nexumi' + ); + }); + + it('escapes html', () => { + expect(escapeHtml('&"')).toBe('<b>&"'); + }); +});