- Added `ComponentMessageBinding` model to manage message components in the database. - Updated `WelcomeConfig`, `Tag`, and `ScheduledMessage` models to support new component fields. - Integrated component handling in various modules, including Scheduler and Tags, to improve message customization. - Enhanced user experience by implementing new commands for creating and managing component messages in the utility module. - Updated localization files to reflect new component-related features and improve user guidance.
563 lines
18 KiB
TypeScript
563 lines
18 KiB
TypeScript
import {
|
|
EmbedBuilder,
|
|
PermissionFlagsBits,
|
|
type ChatInputCommandInteraction,
|
|
type GuildBasedChannel
|
|
} from 'discord.js';
|
|
import { t, tf } from '@nexumi/shared';
|
|
import type { SlashCommand } from '../../types.js';
|
|
import { getGuildLocale } from '../../i18n.js';
|
|
import { requirePermission } from '../../permissions.js';
|
|
import { showEmbedBuilderModal } from './buttons.js';
|
|
import {
|
|
afkCommandData,
|
|
avatarCommandData,
|
|
bannerCommandData,
|
|
channelinfoCommandData,
|
|
editsnipeCommandData,
|
|
embedCommandData,
|
|
emojiCommandData,
|
|
pollCommandData,
|
|
remindmeCommandData,
|
|
remindersCommandData,
|
|
roleinfoCommandData,
|
|
serverinfoCommandData,
|
|
snipeCommandData,
|
|
stickerCommandData,
|
|
timestampCommandData,
|
|
userinfoCommandData
|
|
} from './command-definitions.js';
|
|
import { createPoll } from './poll.js';
|
|
import { createReminder, deleteReminder, listReminders } from './reminders.js';
|
|
import { setAfk } from './afk.js';
|
|
import { getDeletedSnipe, getEditedSnipe, isSnipeEnabled } from './snipe.js';
|
|
import {
|
|
UtilityError,
|
|
buildChannelInfoEmbed,
|
|
buildRoleInfoEmbed,
|
|
buildServerInfoEmbed,
|
|
buildUserInfoEmbed,
|
|
extractFirstCustomEmoji,
|
|
formatDiscordTimestamps,
|
|
parseTimestampInput
|
|
} from './service.js';
|
|
|
|
async function ensureGuildCommand(
|
|
interaction: ChatInputCommandInteraction,
|
|
locale: 'de' | 'en'
|
|
): Promise<boolean> {
|
|
if (!interaction.inGuild()) {
|
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function handleUtilityError(
|
|
interaction: ChatInputCommandInteraction,
|
|
locale: 'de' | 'en',
|
|
error: unknown
|
|
): Promise<void> {
|
|
if (error instanceof UtilityError) {
|
|
await interaction.reply({
|
|
content: t(locale, `utility.error.${error.code}` as 'utility.error.invalid_when'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const userinfoCommand: SlashCommand = {
|
|
data: userinfoCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
|
|
const user = interaction.options.getUser('user') ?? interaction.user;
|
|
const member = await interaction.guild!.members.fetch(user.id).catch(() => null);
|
|
const embed = buildUserInfoEmbed(user, member, locale);
|
|
await interaction.reply({ embeds: [embed] });
|
|
}
|
|
};
|
|
|
|
const serverinfoCommand: SlashCommand = {
|
|
data: serverinfoCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
const embed = buildServerInfoEmbed(interaction.guild!, locale);
|
|
await interaction.reply({ embeds: [embed] });
|
|
}
|
|
};
|
|
|
|
const roleinfoCommand: SlashCommand = {
|
|
data: roleinfoCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
const role = interaction.options.getRole('role', true);
|
|
const guildRole =
|
|
interaction.guild!.roles.cache.get(role.id) ?? (await interaction.guild!.roles.fetch(role.id));
|
|
if (!guildRole) {
|
|
await interaction.reply({ content: t(locale, 'utility.error.role_not_found'), ephemeral: true });
|
|
return;
|
|
}
|
|
await interaction.reply({ embeds: [buildRoleInfoEmbed(guildRole, locale)] });
|
|
}
|
|
};
|
|
|
|
const channelinfoCommand: SlashCommand = {
|
|
data: channelinfoCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
const channel = interaction.options.getChannel('channel') ?? interaction.channel;
|
|
if (!channel || !('guild' in channel)) {
|
|
await interaction.reply({ content: t(locale, 'utility.error.channel_not_found'), ephemeral: true });
|
|
return;
|
|
}
|
|
await interaction.reply({ embeds: [buildChannelInfoEmbed(channel as GuildBasedChannel, locale)] });
|
|
}
|
|
};
|
|
|
|
const avatarCommand: SlashCommand = {
|
|
data: avatarCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
const user = interaction.options.getUser('user') ?? interaction.user;
|
|
const embed = new EmbedBuilder()
|
|
.setTitle(tf(locale, 'utility.avatar.title', { user: user.tag }))
|
|
.setImage(user.displayAvatarURL({ size: 512 }))
|
|
.setColor(0x6366f1);
|
|
await interaction.reply({ embeds: [embed] });
|
|
}
|
|
};
|
|
|
|
const bannerCommand: SlashCommand = {
|
|
data: bannerCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
const user = interaction.options.getUser('user') ?? interaction.user;
|
|
const fetched = await context.client.users.fetch(user.id, { force: true });
|
|
const banner = fetched.bannerURL({ size: 512 });
|
|
if (!banner) {
|
|
await interaction.reply({ content: t(locale, 'utility.banner.none'), ephemeral: true });
|
|
return;
|
|
}
|
|
const embed = new EmbedBuilder()
|
|
.setTitle(tf(locale, 'utility.banner.title', { user: fetched.tag }))
|
|
.setImage(banner)
|
|
.setColor(0x6366f1);
|
|
await interaction.reply({ embeds: [embed] });
|
|
}
|
|
};
|
|
|
|
const pollCommand: SlashCommand = {
|
|
data: pollCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
|
|
const question = interaction.options.getString('question', true);
|
|
const optionsInput = interaction.options.getString('options', true);
|
|
const multiSelect = interaction.options.getBoolean('multiselect') ?? false;
|
|
const anonymous = interaction.options.getBoolean('anonymous') ?? false;
|
|
const duration = interaction.options.getString('duration');
|
|
|
|
try {
|
|
await createPoll(
|
|
context,
|
|
{
|
|
guildId: interaction.guildId!,
|
|
channelId: interaction.channelId!,
|
|
creatorId: interaction.user.id,
|
|
question,
|
|
optionsInput,
|
|
multiSelect,
|
|
anonymous,
|
|
durationInput: duration
|
|
},
|
|
locale
|
|
);
|
|
await interaction.reply({ content: t(locale, 'utility.poll.created'), ephemeral: true });
|
|
} catch (error) {
|
|
await handleUtilityError(interaction, locale, error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const remindmeCommand: SlashCommand = {
|
|
data: remindmeCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
|
|
const whenInput = interaction.options.getString('when', true);
|
|
const content = interaction.options.getString('content', true);
|
|
const recurringCron = interaction.options.getString('recurring_cron');
|
|
|
|
try {
|
|
const id = await createReminder(context, {
|
|
guildId: interaction.guildId,
|
|
userId: interaction.user.id,
|
|
channelId: interaction.channelId!,
|
|
content,
|
|
whenInput,
|
|
recurringCron
|
|
});
|
|
await interaction.reply({
|
|
content: tf(locale, 'utility.remindme.created', { id: id.slice(0, 8) }),
|
|
ephemeral: true
|
|
});
|
|
} catch (error) {
|
|
await handleUtilityError(interaction, locale, error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const remindersCommand: SlashCommand = {
|
|
data: remindersCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
const sub = interaction.options.getSubcommand();
|
|
|
|
if (sub === 'list') {
|
|
const content = await listReminders(context, interaction.user.id, locale);
|
|
await interaction.reply({ content, ephemeral: true });
|
|
return;
|
|
}
|
|
|
|
const id = interaction.options.getString('id', true);
|
|
const deleted = await deleteReminder(context, interaction.user.id, id);
|
|
await interaction.reply({
|
|
content: t(locale, deleted ? 'utility.reminders.deleted' : 'utility.reminders.notFound'),
|
|
ephemeral: true
|
|
});
|
|
}
|
|
};
|
|
|
|
const afkCommand: SlashCommand = {
|
|
data: afkCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
|
|
const reason = interaction.options.getString('reason');
|
|
await setAfk(context, interaction.guildId!, interaction.user.id, reason);
|
|
await interaction.reply({
|
|
content: tf(locale, 'utility.afk.set', { reason: reason ?? '—' }),
|
|
ephemeral: true
|
|
});
|
|
}
|
|
};
|
|
|
|
const emojiCommand: SlashCommand = {
|
|
data: emojiCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
|
return;
|
|
}
|
|
|
|
const sub = interaction.options.getSubcommand();
|
|
|
|
if (sub === 'add') {
|
|
const name = interaction.options.getString('name', true);
|
|
const attachment = interaction.options.getAttachment('attachment');
|
|
const url = interaction.options.getString('url') ?? attachment?.url;
|
|
if (!url) {
|
|
await interaction.reply({ content: t(locale, 'utility.emoji.noSource'), ephemeral: true });
|
|
return;
|
|
}
|
|
const emoji = await interaction.guild!.emojis.create({ attachment: url, name });
|
|
await interaction.reply({
|
|
content: tf(locale, 'utility.emoji.added', { emoji: emoji.toString() }),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (sub === 'remove') {
|
|
const input = interaction.options.getString('emoji', true);
|
|
const match = input.match(/:(\d+)>$/) ?? input.match(/^(\d+)$/);
|
|
const emojiId = match?.[1] ?? input;
|
|
const emoji = interaction.guild!.emojis.cache.get(emojiId);
|
|
if (!emoji) {
|
|
await interaction.reply({ content: t(locale, 'utility.emoji.notFound'), ephemeral: true });
|
|
return;
|
|
}
|
|
await emoji.delete();
|
|
await interaction.reply({ content: t(locale, 'utility.emoji.removed'), ephemeral: true });
|
|
return;
|
|
}
|
|
|
|
const messageId = interaction.options.getString('message_id', true);
|
|
const channel = interaction.channel;
|
|
if (!channel?.isTextBased()) {
|
|
await interaction.reply({ content: t(locale, 'utility.error.channel_not_found'), ephemeral: true });
|
|
return;
|
|
}
|
|
const message = await channel.messages.fetch(messageId).catch(() => null);
|
|
if (!message) {
|
|
await interaction.reply({ content: t(locale, 'utility.emoji.messageNotFound'), ephemeral: true });
|
|
return;
|
|
}
|
|
const stolen = extractFirstCustomEmoji(message.content);
|
|
if (!stolen) {
|
|
await interaction.reply({ content: t(locale, 'utility.emoji.noEmojiInMessage'), ephemeral: true });
|
|
return;
|
|
}
|
|
const isAnimated = message.content.includes(`<a:${stolen.name}:${stolen.id}>`);
|
|
const emoji = await interaction.guild!.emojis.create({
|
|
attachment: `https://cdn.discordapp.com/emojis/${stolen.id}.${isAnimated ? 'gif' : 'png'}`,
|
|
name: stolen.name
|
|
});
|
|
await interaction.reply({
|
|
content: tf(locale, 'utility.emoji.stolen', { emoji: emoji.toString() }),
|
|
ephemeral: true
|
|
});
|
|
}
|
|
};
|
|
|
|
const stickerCommand: SlashCommand = {
|
|
data: stickerCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
|
return;
|
|
}
|
|
|
|
const name = interaction.options.getString('name', true);
|
|
const attachment = interaction.options.getAttachment('attachment');
|
|
const url = interaction.options.getString('url') ?? attachment?.url;
|
|
if (!url) {
|
|
await interaction.reply({ content: t(locale, 'utility.sticker.noSource'), ephemeral: true });
|
|
return;
|
|
}
|
|
|
|
const sticker = await interaction.guild!.stickers.create({
|
|
file: url,
|
|
name,
|
|
tags: name
|
|
});
|
|
await interaction.reply({
|
|
content: tf(locale, 'utility.sticker.added', { name: sticker.name }),
|
|
ephemeral: true
|
|
});
|
|
}
|
|
};
|
|
|
|
const timestampCommand: SlashCommand = {
|
|
data: timestampCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
const input = interaction.options.getString('datetime', true);
|
|
try {
|
|
const ms = parseTimestampInput(input);
|
|
const unix = Math.floor(ms / 1000);
|
|
await interaction.reply({
|
|
content: formatDiscordTimestamps(unix),
|
|
ephemeral: true
|
|
});
|
|
} catch (error) {
|
|
await handleUtilityError(interaction, locale, error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const snipeCommand: SlashCommand = {
|
|
data: snipeCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
if (!(await isSnipeEnabled(context, interaction.guildId!))) {
|
|
await interaction.reply({ content: t(locale, 'utility.snipe.disabled'), ephemeral: true });
|
|
return;
|
|
}
|
|
|
|
const result = await getDeletedSnipe(context, interaction.channelId!, locale);
|
|
if (!result) {
|
|
await interaction.reply({ content: t(locale, 'utility.snipe.none'), ephemeral: true });
|
|
return;
|
|
}
|
|
await interaction.reply({
|
|
embeds: [result.embed],
|
|
content: result.privacyNote,
|
|
ephemeral: true
|
|
});
|
|
}
|
|
};
|
|
|
|
const editsnipeCommand: SlashCommand = {
|
|
data: editsnipeCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
if (!(await isSnipeEnabled(context, interaction.guildId!))) {
|
|
await interaction.reply({ content: t(locale, 'utility.snipe.disabled'), ephemeral: true });
|
|
return;
|
|
}
|
|
|
|
const result = await getEditedSnipe(context, interaction.channelId!, locale);
|
|
if (!result) {
|
|
await interaction.reply({ content: t(locale, 'utility.snipe.none'), ephemeral: true });
|
|
return;
|
|
}
|
|
await interaction.reply({
|
|
embeds: [result.embed],
|
|
content: result.privacyNote,
|
|
ephemeral: true
|
|
});
|
|
}
|
|
};
|
|
|
|
const embedCommand: SlashCommand = {
|
|
data: embedCommandData,
|
|
async execute(interaction, context) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
|
|
return;
|
|
}
|
|
|
|
const sub = interaction.options.getSubcommand();
|
|
if (sub === 'builder') {
|
|
await showEmbedBuilderModal(interaction);
|
|
return;
|
|
}
|
|
|
|
if (sub === 'components') {
|
|
const { ChannelType } = await import('discord.js');
|
|
const channel = interaction.options.getChannel('channel', true);
|
|
const text = interaction.options.getString('text', true);
|
|
const imageUrl = interaction.options.getString('image_url');
|
|
const buttonLabel = interaction.options.getString('button_label');
|
|
const buttonUrl = interaction.options.getString('button_url');
|
|
|
|
if (!text.trim()) {
|
|
await interaction.reply({
|
|
content: t(locale, 'utility.embed.components.missingText'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!channel ||
|
|
(channel.type !== ChannelType.GuildText &&
|
|
channel.type !== ChannelType.GuildAnnouncement &&
|
|
channel.type !== ChannelType.PublicThread &&
|
|
channel.type !== ChannelType.PrivateThread)
|
|
) {
|
|
await interaction.reply({
|
|
content: t(locale, 'utility.error.channel_not_found'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
const containerChildren: import('@nexumi/shared').ComponentV2Node[] = [
|
|
{ type: 'text_display', content: text.trim() }
|
|
];
|
|
if (imageUrl?.trim()) {
|
|
containerChildren.push({
|
|
type: 'media_gallery',
|
|
items: [{ url: imageUrl.trim() }]
|
|
});
|
|
}
|
|
|
|
const components: import('@nexumi/shared').MessageComponentsV2 = {
|
|
components: [
|
|
{
|
|
type: 'container',
|
|
components: containerChildren
|
|
}
|
|
],
|
|
actions: {}
|
|
};
|
|
|
|
if (buttonLabel?.trim() && buttonUrl?.trim()) {
|
|
components.components.push({
|
|
type: 'action_row',
|
|
components: [
|
|
{
|
|
type: 'button',
|
|
style: 'Link',
|
|
label: buttonLabel.trim(),
|
|
url: buttonUrl.trim()
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
const binding = await context.prisma.componentMessageBinding.create({
|
|
data: {
|
|
guildId: interaction.guildId!,
|
|
channelId: channel.id,
|
|
payload: components,
|
|
createdById: interaction.user.id
|
|
}
|
|
});
|
|
|
|
const { buildComponentsV2Payload } = await import('../../lib/components-v2-payload.js');
|
|
const payload = buildComponentsV2Payload(components, {
|
|
source: 'm',
|
|
ref: binding.id
|
|
});
|
|
if (!payload) {
|
|
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
|
|
return;
|
|
}
|
|
|
|
const textChannel = await context.client.channels.fetch(channel.id);
|
|
if (!textChannel?.isTextBased() || textChannel.isDMBased()) {
|
|
await interaction.reply({
|
|
content: t(locale, 'utility.error.channel_not_found'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
const sent = await textChannel.send(payload);
|
|
await context.prisma.componentMessageBinding.update({
|
|
where: { id: binding.id },
|
|
data: { messageId: sent.id }
|
|
});
|
|
|
|
const webui = (await import('../../env.js')).env.WEBUI_URL;
|
|
const hint = webui
|
|
? tf(locale, 'utility.embed.components.dashboardHint', {
|
|
url: `${webui}/dashboard/${interaction.guildId}/messages`
|
|
})
|
|
: '';
|
|
|
|
await interaction.reply({
|
|
content: [t(locale, 'utility.embed.components.sent'), hint].filter(Boolean).join('\n'),
|
|
ephemeral: true
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
export const utilityCommands: SlashCommand[] = [
|
|
userinfoCommand,
|
|
serverinfoCommand,
|
|
roleinfoCommand,
|
|
channelinfoCommand,
|
|
avatarCommand,
|
|
bannerCommand,
|
|
pollCommand,
|
|
remindmeCommand,
|
|
remindersCommand,
|
|
afkCommand,
|
|
emojiCommand,
|
|
stickerCommand,
|
|
timestampCommand,
|
|
snipeCommand,
|
|
editsnipeCommand,
|
|
embedCommand
|
|
];
|