- 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.
603 lines
15 KiB
TypeScript
603 lines
15 KiB
TypeScript
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;
|
|
}
|