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,602 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
PermissionFlagsBits,
StringSelectMenuBuilder,
type APIMessageComponentEmoji,
type GuildMember,
type Message,
type TextChannel
} from 'discord.js';
import {
SelfRoleBehaviorSchema,
SelfRoleEntrySchema,
SelfRoleModeSchema,
type SelfRoleBehavior,
type SelfRoleEntry,
type SelfRoleMode,
t
} from '@nexumi/shared';
import type { SelfRolePanel } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { ticketQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
export class SelfRoleError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export const SELF_ROLE_BTN_PREFIX = 'sr:btn:';
export const SELF_ROLE_SEL_PREFIX = 'sr:sel:';
type RolesStorage = {
entries: SelfRoleEntry[];
temporaryDurationMs?: number;
};
export function parseRolesInput(input: string): SelfRoleEntry[] {
const parts = input
.split(',')
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) {
throw new SelfRoleError('invalid_roles');
}
const entries: SelfRoleEntry[] = [];
for (const part of parts) {
const segments = part.split(':');
if (segments.length < 2) {
throw new SelfRoleError('invalid_roles');
}
const [roleId, label, emoji] = segments;
if (!roleId || !label) {
throw new SelfRoleError('invalid_roles');
}
entries.push(
SelfRoleEntrySchema.parse({
roleId: roleId.trim(),
label: label.trim(),
emoji: emoji?.trim() || undefined
})
);
}
if (entries.length > 25) {
throw new SelfRoleError('too_many_roles');
}
return entries;
}
export function parseRolesJson(raw: unknown): RolesStorage {
if (Array.isArray(raw)) {
return {
entries: raw
.map((item) => SelfRoleEntrySchema.safeParse(item))
.filter((result) => result.success)
.map((result) => result.data!)
};
}
if (raw && typeof raw === 'object') {
const obj = raw as { entries?: unknown; temporaryDurationMs?: unknown };
const entries = Array.isArray(obj.entries)
? obj.entries
.map((item) => SelfRoleEntrySchema.safeParse(item))
.filter((result) => result.success)
.map((result) => result.data!)
: [];
const temporaryDurationMs =
typeof obj.temporaryDurationMs === 'number' && obj.temporaryDurationMs > 0
? obj.temporaryDurationMs
: undefined;
return { entries, temporaryDurationMs };
}
return { entries: [] };
}
export function serializeRolesJson(
entries: SelfRoleEntry[],
temporaryDurationMs?: number
): RolesStorage {
if (temporaryDurationMs !== undefined) {
return { entries, temporaryDurationMs };
}
return { entries };
}
function parseButtonEmoji(
emoji?: string
): APIMessageComponentEmoji | undefined {
if (!emoji) {
return undefined;
}
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
return {
id: customMatch[3]!,
name: customMatch[2],
animated: Boolean(customMatch[1])
};
}
if (/^\d+$/.test(emoji)) {
return { id: emoji };
}
return { name: emoji };
}
function parseReactionEmoji(emoji?: string): string {
if (!emoji) {
throw new SelfRoleError('reaction_emoji_required');
}
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
return customMatch[3]!;
}
if (/^\d+$/.test(emoji)) {
return emoji;
}
return emoji;
}
export function buildPanelEmbed(panel: SelfRolePanel, locale: 'de' | 'en'): EmbedBuilder {
const { entries } = parseRolesJson(panel.roles);
const roleLines = entries.map((entry) => {
const prefix = entry.emoji ? `${entry.emoji} ` : '';
return `${prefix}**${entry.label}** — <@&${entry.roleId}>`;
});
const embed = new EmbedBuilder()
.setTitle(panel.title)
.setColor(0x6366f1)
.setDescription(panel.description ?? t(locale, 'selfroles.panel.defaultDescription'));
if (roleLines.length > 0) {
embed.addFields({
name: t(locale, 'selfroles.panel.rolesField'),
value: roleLines.join('\n')
});
}
const behavior = SelfRoleBehaviorSchema.parse(panel.behavior);
if (behavior === 'TEMPORARY') {
const { temporaryDurationMs } = parseRolesJson(panel.roles);
if (temporaryDurationMs) {
embed.setFooter({
text: t(locale, 'selfroles.panel.temporaryFooter')
});
}
}
return embed;
}
export function buildPanelComponents(panel: SelfRolePanel): ActionRowBuilder<
ButtonBuilder | StringSelectMenuBuilder
>[] {
const mode = SelfRoleModeSchema.parse(panel.mode);
const { entries } = parseRolesJson(panel.roles);
if (mode === 'REACTIONS' || entries.length === 0) {
return [];
}
if (mode === 'DROPDOWN') {
const select = new StringSelectMenuBuilder()
.setCustomId(`${SELF_ROLE_SEL_PREFIX}${panel.id}`)
.setPlaceholder('Select a role')
.setMinValues(1)
.setMaxValues(1)
.addOptions(
entries.map((entry) => ({
label: entry.label.slice(0, 100),
value: entry.roleId,
description: entry.description?.slice(0, 100),
emoji: parseButtonEmoji(entry.emoji)
}))
);
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
}
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
let currentRow = new ActionRowBuilder<ButtonBuilder>();
let buttonsInRow = 0;
for (const entry of entries) {
if (buttonsInRow >= 5) {
rows.push(currentRow);
currentRow = new ActionRowBuilder<ButtonBuilder>();
buttonsInRow = 0;
}
const button = new ButtonBuilder()
.setCustomId(`${SELF_ROLE_BTN_PREFIX}${panel.id}:${entry.roleId}`)
.setLabel(entry.label.slice(0, 80))
.setStyle(ButtonStyle.Secondary);
const emoji = parseButtonEmoji(entry.emoji);
if (emoji) {
button.setEmoji(emoji);
}
currentRow.addComponents(button);
buttonsInRow += 1;
if (rows.length >= 5) {
break;
}
}
if (buttonsInRow > 0 && rows.length < 5) {
rows.push(currentRow);
}
return rows;
}
async function applyReactionEmojis(message: Message, entries: SelfRoleEntry[]): Promise<void> {
for (const entry of entries) {
try {
const emoji = parseReactionEmoji(entry.emoji);
await message.react(emoji);
} catch (error) {
logger.warn({ error, panelId: message.id, roleId: entry.roleId }, 'Failed to add self-role reaction');
}
}
}
export async function scheduleSelfRoleExpiry(
guildId: string,
userId: string,
roleId: string,
expiresAt: Date
): Promise<void> {
const delay = Math.max(0, expiresAt.getTime() - Date.now());
await ticketQueue.add(
'selfRoleExpire',
{ guildId, userId, roleId },
{
delay,
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
removeOnComplete: true,
removeOnFail: 50
}
);
}
export async function cancelSelfRoleExpiry(
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
await job?.remove();
}
export async function expireSelfRole(
context: BotContext,
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member?.roles.cache.has(roleId)) {
return;
}
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
logger.warn({ guildId, roleId }, 'Missing ManageRoles for self-role expiry');
return;
}
const role = guild.roles.cache.get(roleId);
if (!role || role.position >= me.roles.highest.position) {
logger.warn({ guildId, roleId }, 'Cannot remove self-role above bot hierarchy');
return;
}
await member.roles.remove(roleId, 'Temporary self-role expired').catch((error) => {
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove expired self-role');
});
}
async function assertAssignableRole(member: GuildMember, roleId: string): Promise<void> {
const guild = member.guild;
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
throw new SelfRoleError('bot_missing_permissions');
}
const role = guild.roles.cache.get(roleId);
if (!role) {
throw new SelfRoleError('role_not_found');
}
if (role.position >= me.roles.highest.position) {
throw new SelfRoleError('role_hierarchy');
}
if (role.managed) {
throw new SelfRoleError('role_managed');
}
}
export async function toggleSelfRole(
context: BotContext,
panel: SelfRolePanel,
member: GuildMember,
roleId: string
): Promise<'added' | 'removed' | 'unchanged'> {
const { entries, temporaryDurationMs } = parseRolesJson(panel.roles);
const entry = entries.find((item) => item.roleId === roleId);
if (!entry) {
throw new SelfRoleError('role_not_in_panel');
}
await assertAssignableRole(member, roleId);
const behavior = SelfRoleBehaviorSchema.parse(panel.behavior);
const panelRoleIds = entries.map((item) => item.roleId);
const hasRole = member.roles.cache.has(roleId);
if (behavior === 'VERIFY') {
if (hasRole) {
return 'unchanged';
}
await member.roles.add(roleId);
return 'added';
}
if (behavior === 'UNIQUE') {
if (hasRole) {
await member.roles.remove(roleId);
return 'removed';
}
const toRemove = panelRoleIds.filter((id) => id !== roleId && member.roles.cache.has(id));
if (toRemove.length > 0) {
await member.roles.remove(toRemove);
}
await member.roles.add(roleId);
return 'added';
}
if (behavior === 'TEMPORARY') {
if (hasRole) {
await member.roles.remove(roleId);
await cancelSelfRoleExpiry(member.guild.id, member.id, roleId);
return 'removed';
}
await member.roles.add(roleId);
if (temporaryDurationMs) {
const expiresAt = new Date(Date.now() + temporaryDurationMs);
await scheduleSelfRoleExpiry(member.guild.id, member.id, roleId, expiresAt);
}
return 'added';
}
if (hasRole) {
await member.roles.remove(roleId);
return 'removed';
}
await member.roles.add(roleId);
return 'added';
}
export async function findPanelByMessageId(
context: BotContext,
messageId: string
): Promise<SelfRolePanel | null> {
return context.prisma.selfRolePanel.findFirst({
where: { messageId }
});
}
export async function createSelfRolePanel(
context: BotContext,
params: {
guildId: string;
channelId: string;
title: string;
description?: string | null;
mode: SelfRoleMode;
behavior: SelfRoleBehavior;
roles: SelfRoleEntry[];
temporaryDurationMs?: number;
},
locale: 'de' | 'en'
): Promise<SelfRolePanel> {
await ensureGuild(context.prisma, params.guildId);
if (params.behavior === 'TEMPORARY' && !params.temporaryDurationMs) {
throw new SelfRoleError('duration_required');
}
if (params.mode === 'REACTIONS') {
const missingEmoji = params.roles.some((entry) => !entry.emoji);
if (missingEmoji) {
throw new SelfRoleError('reaction_emoji_required');
}
}
const panel = await context.prisma.selfRolePanel.create({
data: {
guildId: params.guildId,
channelId: params.channelId,
title: params.title,
description: params.description ?? null,
mode: params.mode,
behavior: params.behavior,
roles: serializeRolesJson(params.roles, params.temporaryDurationMs)
}
});
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
const message = await channel.send({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (params.mode === 'REACTIONS') {
await applyReactionEmojis(message, params.roles);
}
return context.prisma.selfRolePanel.update({
where: { id: panel.id },
data: { messageId: message.id }
});
}
export async function updateSelfRolePanel(
context: BotContext,
panelId: string,
guildId: string,
updates: {
title?: string;
description?: string | null;
mode?: SelfRoleMode;
behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[];
temporaryDurationMs?: number;
},
locale: 'de' | 'en'
): Promise<SelfRolePanel> {
const existing = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId }
});
if (!existing) {
throw new SelfRoleError('not_found');
}
const mode = updates.mode ?? SelfRoleModeSchema.parse(existing.mode);
const behavior = updates.behavior ?? SelfRoleBehaviorSchema.parse(existing.behavior);
const roles = updates.roles ?? parseRolesJson(existing.roles).entries;
const { temporaryDurationMs: existingDuration } = parseRolesJson(existing.roles);
const temporaryDurationMs = updates.temporaryDurationMs ?? existingDuration;
if (behavior === 'TEMPORARY' && !temporaryDurationMs) {
throw new SelfRoleError('duration_required');
}
if (mode === 'REACTIONS' && roles.some((entry) => !entry.emoji)) {
throw new SelfRoleError('reaction_emoji_required');
}
const panel = await context.prisma.selfRolePanel.update({
where: { id: panelId },
data: {
title: updates.title ?? existing.title,
description: updates.description !== undefined ? updates.description : existing.description,
mode,
behavior,
roles: serializeRolesJson(roles, temporaryDurationMs)
}
});
if (panel.messageId) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.edit({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (mode === 'REACTIONS') {
const existingReactions = message.reactions.cache;
for (const reaction of existingReactions.values()) {
await reaction.remove().catch(() => undefined);
}
await applyReactionEmojis(message, roles);
}
} catch (error) {
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
}
}
return panel;
}
export async function deleteSelfRolePanel(
context: BotContext,
panelId: string,
guildId: string
): Promise<void> {
const panel = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId }
});
if (!panel) {
throw new SelfRoleError('not_found');
}
if (panel.messageId) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.delete();
} catch {
// Message may already be gone
}
}
await context.prisma.selfRolePanel.delete({ where: { id: panelId } });
}
export function matchReactionEntry(
entries: SelfRoleEntry[],
emojiId: string | null,
emojiName: string
): SelfRoleEntry | null {
for (const entry of entries) {
if (!entry.emoji) {
continue;
}
const customMatch = entry.emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
if (emojiId === customMatch[3]) {
return entry;
}
continue;
}
if (/^\d+$/.test(entry.emoji)) {
if (emojiId === entry.emoji) {
return entry;
}
continue;
}
if (!emojiId && entry.emoji === emojiName) {
return entry;
}
}
return null;
}