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 { 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 { 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(``); 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 ];