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:
95
apps/bot/src/modules/selfroles/command-definitions.ts
Normal file
95
apps/bot/src/modules/selfroles/command-definitions.ts
Normal 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'
|
||||
);
|
||||
203
apps/bot/src/modules/selfroles/commands.ts
Normal file
203
apps/bot/src/modules/selfroles/commands.ts
Normal 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];
|
||||
8
apps/bot/src/modules/selfroles/index.ts
Normal file
8
apps/bot/src/modules/selfroles/index.ts
Normal 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';
|
||||
132
apps/bot/src/modules/selfroles/interactions.ts
Normal file
132
apps/bot/src/modules/selfroles/interactions.ts
Normal 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);
|
||||
}
|
||||
132
apps/bot/src/modules/selfroles/reactions.ts
Normal file
132
apps/bot/src/modules/selfroles/reactions.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
602
apps/bot/src/modules/selfroles/service.ts
Normal file
602
apps/bot/src/modules/selfroles/service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user