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().addComponents(select)]; } const rows: ActionRowBuilder[] = []; let currentRow = new ActionRowBuilder(); let buttonsInRow = 0; for (const entry of entries) { if (buttonsInRow >= 5) { rows.push(currentRow); currentRow = new ActionRowBuilder(); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; }