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:
@@ -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]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
20
packages/shared/src/phase4.test.ts
Normal file
20
packages/shared/src/phase4.test.ts
Normal file
@@ -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('<b>&"')).toBe('<b>&"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user