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,95 @@
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice
} from '@nexumi/shared';
import { ChannelType, SlashCommandBuilder } from 'discord.js';
export const selfrolesCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('selfroles')
.addSubcommandGroup((group) =>
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'selfroles.panel.create.description')
.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(
localizedChoice('selfroles.choice.mode.buttons', 'BUTTONS'),
localizedChoice('selfroles.choice.mode.dropdown', 'DROPDOWN'),
localizedChoice('selfroles.choice.mode.reactions', 'REACTIONS')
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('behavior').setRequired(true),
'selfroles.panel.create.options.behavior'
)
.addChoices(
localizedChoice('selfroles.choice.behavior.normal', 'NORMAL'),
localizedChoice('selfroles.choice.behavior.unique', 'UNIQUE'),
localizedChoice('selfroles.choice.behavior.verify', 'VERIFY'),
localizedChoice('selfroles.choice.behavior.temporary', 'TEMPORARY')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('roles').setRequired(true), 'selfroles.panel.create.options.roles')
)
.addChannelOption((o) =>
applyOptionDescription(o.setName('channel').setRequired(true), 'selfroles.panel.create.options.channel')
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('edit'), 'selfroles.panel.edit.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('panel_id').setRequired(true), 'selfroles.panel.common.options.panel_id')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('title'), 'selfroles.panel.create.options.title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('mode'), 'selfroles.panel.create.options.mode')
.addChoices(
localizedChoice('selfroles.choice.mode.buttons', 'BUTTONS'),
localizedChoice('selfroles.choice.mode.dropdown', 'DROPDOWN'),
localizedChoice('selfroles.choice.mode.reactions', 'REACTIONS')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('behavior'), 'selfroles.panel.create.options.behavior')
.addChoices(
localizedChoice('selfroles.choice.behavior.normal', 'NORMAL'),
localizedChoice('selfroles.choice.behavior.unique', 'UNIQUE'),
localizedChoice('selfroles.choice.behavior.verify', 'VERIFY'),
localizedChoice('selfroles.choice.behavior.temporary', 'TEMPORARY')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('roles'), 'selfroles.panel.create.options.roles')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('delete'), 'selfroles.panel.delete.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('panel_id').setRequired(true), 'selfroles.panel.common.options.panel_id')
)
)
),
'selfroles.description'
);

View File

@@ -0,0 +1,203 @@
import { PermissionFlagsBits } from 'discord.js';
import {
SelfRoleBehaviorSchema,
SelfRoleModeSchema,
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 { selfrolesCommandData } from './command-definitions.js';
import {
createSelfRolePanel,
deleteSelfRolePanel,
parseRolesInput,
SelfRoleError,
updateSelfRolePanel
} 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);
}
function parseOptionalDuration(
input: string | null,
locale: 'de' | 'en',
interaction: Parameters<SlashCommand['execute']>[0]
): number | undefined | null {
if (!input) {
return undefined;
}
try {
return parseDuration(input);
} catch {
interaction.reply({
content: t(locale, 'selfroles.error.invalid_duration'),
ephemeral: true
});
return null;
}
}
const selfrolesCommand: SlashCommand = {
data: selfrolesCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const group = interaction.options.getSubcommandGroup(true);
const sub = interaction.options.getSubcommand(true);
if (group !== 'panel') {
return;
}
try {
if (sub === 'create') {
const behavior = SelfRoleBehaviorSchema.parse(
interaction.options.getString('behavior', true)
);
const mode = SelfRoleModeSchema.parse(interaction.options.getString('mode', true));
const channel = interaction.options.getChannel('channel', true);
if (!channel.isTextBased() || channel.isDMBased()) {
await interaction.reply({
content: t(locale, 'selfroles.error.invalid_channel'),
ephemeral: true
});
return;
}
let temporaryDurationMs: number | undefined;
if (behavior === 'TEMPORARY') {
const durationInput = interaction.options.getString('duration', true);
const parsed = parseOptionalDuration(durationInput, locale, interaction);
if (parsed === null) {
return;
}
if (!parsed || parsed < 60_000) {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_too_short'),
ephemeral: true
});
return;
}
temporaryDurationMs = parsed;
}
const roles = parseRolesInput(interaction.options.getString('roles', true));
const panel = await createSelfRolePanel(
context,
{
guildId: interaction.guildId!,
channelId: channel.id,
title: interaction.options.getString('title', true),
description: interaction.options.getString('description'),
mode,
behavior,
roles,
temporaryDurationMs
},
locale
);
await interaction.reply({
content: tf(locale, 'selfroles.panel.created', { id: panel.id }),
ephemeral: true
});
return;
}
if (sub === 'edit') {
const panelId = interaction.options.getString('panel_id', true);
const behaviorRaw = interaction.options.getString('behavior');
const behavior = behaviorRaw ? SelfRoleBehaviorSchema.parse(behaviorRaw) : undefined;
const modeRaw = interaction.options.getString('mode');
const mode = modeRaw ? SelfRoleModeSchema.parse(modeRaw) : undefined;
let roles;
const rolesInput = interaction.options.getString('roles');
if (rolesInput) {
roles = parseRolesInput(rolesInput);
}
let temporaryDurationMs: number | undefined;
const durationInput = interaction.options.getString('duration');
if (durationInput) {
const parsed = parseOptionalDuration(durationInput, locale, interaction);
if (parsed === null) {
return;
}
if (!parsed || parsed < 60_000) {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_too_short'),
ephemeral: true
});
return;
}
temporaryDurationMs = parsed;
} else if (behavior === 'TEMPORARY') {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_required'),
ephemeral: true
});
return;
}
const descriptionValue = interaction.options.getString('description');
const panel = await updateSelfRolePanel(
context,
panelId,
interaction.guildId!,
{
title: interaction.options.getString('title') ?? undefined,
description: descriptionValue !== null ? descriptionValue : undefined,
mode,
behavior,
roles,
temporaryDurationMs
},
locale
);
await interaction.reply({
content: tf(locale, 'selfroles.panel.updated', { id: panel.id }),
ephemeral: true
});
return;
}
if (sub === 'delete') {
const panelId = interaction.options.getString('panel_id', true);
await deleteSelfRolePanel(context, panelId, interaction.guildId!);
await interaction.reply({
content: t(locale, 'selfroles.panel.deleted'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof SelfRoleError) {
await interaction.reply({
content: t(locale, `selfroles.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const selfrolesCommands = [selfrolesCommand];

View File

@@ -0,0 +1,8 @@
export { selfrolesCommands } from './commands.js';
export { isSelfRoleInteraction, handleSelfRoleInteraction } from './interactions.js';
export { registerSelfRoleEvents } from './reactions.js';
export {
expireSelfRole,
scheduleSelfRoleExpiry,
cancelSelfRoleExpiry
} from './service.js';

View File

@@ -0,0 +1,132 @@
import type { ButtonInteraction, StringSelectMenuInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
SELF_ROLE_BTN_PREFIX,
SELF_ROLE_SEL_PREFIX,
SelfRoleError,
toggleSelfRole
} from './service.js';
export function isSelfRoleInteraction(customId: string): boolean {
return customId.startsWith(SELF_ROLE_BTN_PREFIX) || customId.startsWith(SELF_ROLE_SEL_PREFIX);
}
function parseButtonCustomId(customId: string): { panelId: string; roleId: string } | null {
if (!customId.startsWith(SELF_ROLE_BTN_PREFIX)) {
return null;
}
const rest = customId.slice(SELF_ROLE_BTN_PREFIX.length);
const colon = rest.indexOf(':');
if (colon === -1) {
return null;
}
const panelId = rest.slice(0, colon);
const roleId = rest.slice(colon + 1);
if (!panelId || !roleId) {
return null;
}
return { panelId, roleId };
}
function parseSelectCustomId(customId: string): string | null {
if (!customId.startsWith(SELF_ROLE_SEL_PREFIX)) {
return null;
}
return customId.slice(SELF_ROLE_SEL_PREFIX.length) || null;
}
async function replySelfRoleResult(
interaction: ButtonInteraction | StringSelectMenuInteraction,
locale: 'de' | 'en',
result: 'added' | 'removed' | 'unchanged',
behavior: string
): Promise<void> {
let key: string;
if (result === 'added') {
key = behavior === 'VERIFY' ? 'selfroles.role.verified' : 'selfroles.role.added';
} else if (result === 'removed') {
key = 'selfroles.role.removed';
} else {
key = 'selfroles.role.unchanged';
}
const content = t(locale, key);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true });
} else {
await interaction.reply({ content, ephemeral: true });
}
}
async function handleRoleToggle(
interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext,
panelId: string,
roleId: string
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const panel = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId: interaction.guild.id }
});
if (!panel) {
await interaction.reply({ content: t(locale, 'selfroles.error.not_found'), ephemeral: true });
return;
}
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({ content: t(locale, 'selfroles.error.member_not_found'), ephemeral: true });
return;
}
try {
const result = await toggleSelfRole(context, panel, member, roleId);
await replySelfRoleResult(interaction, locale, result, panel.behavior);
} catch (error) {
if (error instanceof SelfRoleError) {
await interaction.reply({
content: t(locale, `selfroles.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
export async function handleSelfRoleInteraction(
interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext
): Promise<void> {
if (interaction.isButton()) {
const parsed = parseButtonCustomId(interaction.customId);
if (!parsed) {
return;
}
await handleRoleToggle(interaction, context, parsed.panelId, parsed.roleId);
return;
}
const panelId = parseSelectCustomId(interaction.customId);
if (!panelId) {
return;
}
const roleId = interaction.values[0];
if (!roleId) {
return;
}
await handleRoleToggle(interaction, context, panelId, roleId);
}

View File

@@ -0,0 +1,132 @@
import { Events, type Client, type PartialUser, type User } from 'discord.js';
import { SelfRoleModeSchema, t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
import {
findPanelByMessageId,
matchReactionEntry,
parseRolesJson,
SelfRoleError,
toggleSelfRole
} from './service.js';
async function handleReactionToggle(
context: BotContext,
messageId: string,
userId: string,
emojiId: string | null,
emojiName: string,
add: boolean
): Promise<void> {
const panel = await findPanelByMessageId(context, messageId);
if (!panel || SelfRoleModeSchema.parse(panel.mode) !== 'REACTIONS') {
return;
}
const { entries } = parseRolesJson(panel.roles);
const entry = matchReactionEntry(entries, emojiId, emojiName);
if (!entry) {
return;
}
const guild = await context.client.guilds.fetch(panel.guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member || member.user.bot) {
return;
}
const behavior = panel.behavior;
const hasRole = member.roles.cache.has(entry.roleId);
if (add) {
if (behavior === 'VERIFY' || behavior === 'TEMPORARY') {
if (hasRole) {
return;
}
} else if (behavior === 'NORMAL' && hasRole) {
return;
}
} else {
if (behavior === 'VERIFY') {
return;
}
if (!hasRole) {
return;
}
}
try {
await toggleSelfRole(context, panel, member, entry.roleId);
} catch (error) {
if (error instanceof SelfRoleError) {
const locale = await getGuildLocale(context.prisma, panel.guildId);
try {
const user = await context.client.users.fetch(userId);
await user.send(t(locale, `selfroles.error.${error.code}`));
} catch {
// User may have DMs disabled
}
return;
}
logger.error({ error, panelId: panel.id, userId }, 'Self-role reaction handler failed');
}
}
export function registerSelfRoleEvents(client: Client, context: BotContext): void {
client.on(Events.MessageReactionAdd, async (reaction, user: User | PartialUser) => {
try {
if (user.bot || !reaction.message.guildId) {
return;
}
const message = reaction.message.partial
? await reaction.message.fetch().catch(() => null)
: reaction.message;
if (!message) {
return;
}
await handleReactionToggle(
context,
message.id,
user.id,
reaction.emoji.id,
reaction.emoji.name ?? '',
true
);
} catch (error) {
logger.error({ error }, 'Self-role reaction add failed');
}
});
client.on(Events.MessageReactionRemove, async (reaction: PartialMessageReaction, user: PartialUser) => {
try {
if (user.bot || !reaction.message.guildId) {
return;
}
const message = reaction.message.partial
? await reaction.message.fetch().catch(() => null)
: reaction.message;
if (!message) {
return;
}
await handleReactionToggle(
context,
message.id,
user.id,
reaction.emoji.id,
reaction.emoji.name ?? '',
false
);
} catch (error) {
logger.error({ error }, 'Self-role reaction remove failed');
}
});
}

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