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.
This commit is contained in:
smueller
2026-07-22 13:21:28 +02:00
parent 1ceb8c3574
commit 52b10c9536
11 changed files with 271 additions and 90 deletions

View File

@@ -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]));

View File

@@ -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;

View File

@@ -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<TempBanJob>(
moderationQueueName,
@@ -143,13 +160,17 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
});
const ticketWorker = new Worker<SelfRoleExpireJob>(
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<GiveawayEndJob>(
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<void> {
@@ -183,6 +247,26 @@ export async function ensureRecurringJobs(): Promise<void> {
);
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<void> {

View File

@@ -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';

View File

@@ -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')
)

View File

@@ -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) =>

View File

@@ -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;
}

View File

@@ -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: []

View File

@@ -333,7 +333,7 @@ async function fetchTicketMessages(
}
}
function buildRatingComponents(ticketId: string, locale: 'de' | 'en'): ActionRowBuilder<ButtonBuilder> {
function buildRatingComponents(ticketId: string): ActionRowBuilder<ButtonBuilder> {
const row = new ActionRowBuilder<ButtonBuilder>();
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<void> {
const ticket = await context.prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket || ticket.openerId !== userId) {