diff --git a/bots/discord/src/commands/support/train/chat.ts b/bots/discord/src/commands/support/train/chat.ts new file mode 100644 index 0000000..572d8c5 --- /dev/null +++ b/bots/discord/src/commands/support/train/chat.ts @@ -0,0 +1,76 @@ +import Command from '$/classes/Command' +import CommandError, { CommandErrorType } from '$/classes/CommandError' +import { config } from '../../../context' +import type { FetchMessageOptions, MessageResolvable } from 'discord.js' +import type { ConfigMessageScanResponseLabelConfig } from 'config.schema' +import { createSuccessEmbed } from '$/utils/discord/embeds' + +const msRcConfig = config.messageScan?.humanCorrections?.allow + +export default new Command({ + name: 'train', + description: 'Train a specific message or text to a specific label', + type: Command.Type.ChatGuild, + requirements: { + users: msRcConfig?.users, + roles: msRcConfig?.members?.roles, + permissions: msRcConfig?.members?.permissions, + mode: 'any', + memberRequirementsForUsers: 'fail', + defaultCondition: 'fail', + }, + options: { + message: { + description: 'The message to train (use `latest` to train the latest message)', + type: Command.OptionType.String, + required: true, + }, + label: { + description: 'The label to train the message as', + type: Command.OptionType.String, + required: true, + }, + }, + allowMessageCommand: true, + async execute(context, trigger, { label, message: ref }) { + const { logger, config } = context + const { messageScan: msConfig } = config + + // If there's no config, we can't do anything + if (!msConfig?.humanCorrections) throw new CommandError(CommandErrorType.Generic, 'Response correction is off.') + const labels = msConfig.responses?.flatMap(r => + r.triggers.text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label), + ) + + const channel = await trigger.guild!.channels.fetch(trigger.channelId) + if (!channel?.isTextBased()) + throw new CommandError( + CommandErrorType.InvalidArgument, + 'This command can only be used in or on text channels', + ) + + if (!labels.includes(label)) + throw new CommandError( + CommandErrorType.InvalidArgument, + `The provided label is invalid.\nValid labels are:${labels.map(l => `\n- \`${l}\``).join('')}`, + ) + + const refMsg = await channel.messages.fetch( + (ref.startsWith('latest') ? { limit: 1 } : ref) as MessageResolvable | FetchMessageOptions, + ) + if (!refMsg) throw new CommandError(CommandErrorType.InvalidArgument, 'The provided message does not exist.') + + logger.debug(`User ${context.executor.id} is training message ${refMsg?.id} as ${label}`) + + await context.api.client.trainMessage(refMsg.content, label) + await trigger.reply({ + embeds: [ + createSuccessEmbed( + 'Message trained', + `The provided message has been trained as \`${label}\`. Thank you for your contribution!`, + ), + ], + ephemeral: true, + }) + }, +}) diff --git a/bots/discord/src/commands/support/train/context-menu.ts b/bots/discord/src/commands/support/train/context-menu.ts new file mode 100644 index 0000000..50dab3f --- /dev/null +++ b/bots/discord/src/commands/support/train/context-menu.ts @@ -0,0 +1,50 @@ +import Command from '$/classes/Command' +import CommandError, { CommandErrorType } from '$/classes/CommandError' +import { config } from '../../../context' +import { type APIStringSelectComponent, ComponentType } from 'discord.js' +import type { ConfigMessageScanResponseLabelConfig } from 'config.schema' + +const msRcConfig = config.messageScan?.humanCorrections?.allow + +export default new Command({ + name: 'Train Message', + type: Command.Type.ContextMenuGuildMessage, + requirements: { + users: msRcConfig?.users, + roles: msRcConfig?.members?.roles, + permissions: msRcConfig?.members?.permissions, + mode: 'any', + memberRequirementsForUsers: 'fail', + defaultCondition: 'fail', + }, + async execute(context, trigger) { + const { logger, config } = context + const { messageScan: msConfig } = config + + // If there's no config, we can't do anything + if (!msConfig?.humanCorrections) throw new CommandError(CommandErrorType.Generic, 'Response correction is off.') + + logger.debug(`User ${context.executor.id} is training message ${trigger.targetId}`) + + const labels = msConfig.responses.flatMap(r => + r.triggers.text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label), + ) + + await trigger.reply({ + content: 'Select a label to train this message as:', + components: [ + { + components: [ + { + custom_id: `tr_${trigger.targetMessage.channelId}_${trigger.targetId}`, + options: labels.map(label => ({ label, value: label })), + type: ComponentType.StringSelect, + } satisfies APIStringSelectComponent, + ], + type: ComponentType.ActionRow, + }, + ], + ephemeral: true, + }) + }, +}) diff --git a/bots/discord/src/events/discord/interactionCreate/correctResponse.ts b/bots/discord/src/events/discord/interactionCreate/correctResponse.ts index 81b67a8..5792a23 100644 --- a/bots/discord/src/events/discord/interactionCreate/correctResponse.ts +++ b/bots/discord/src/events/discord/interactionCreate/correctResponse.ts @@ -18,10 +18,10 @@ withContext(on, 'interactionCreate', async (context, interaction) => { if (!interaction.isStringSelectMenu() && !interaction.isButton()) return if (!interaction.customId.startsWith('cr_')) return - const [, key, action] = interaction.customId.split('_') as ['cr', string, 'select' | 'cancel' | 'delete'] - if (!key || !action) return + const [, msgId, action] = interaction.customId.split('_') as ['cr', string, 'select' | 'cancel' | 'delete'] + if (!msgId || !action) return - const response = await db.query.responses.findFirst({ where: eq(responses.replyId, key) }) + const response = await db.query.responses.findFirst({ where: eq(responses.replyId, msgId) }) // If the message isn't saved in my DB (unrelated message) if (!response) return void (await interaction.reply({ @@ -32,11 +32,11 @@ withContext(on, 'interactionCreate', async (context, interaction) => { try { // We're gonna pretend reactionChannel is a text-based channel, but it can be many more // But `messages` should always exist as a property - const reactionGuild = await interaction.client.guilds.fetch(response.guildId) - const reactionChannel = (await reactionGuild.channels.fetch(response.channelId)) as TextBasedChannel | null - const reactionMessage = await reactionChannel?.messages.fetch(key) + const guild = await interaction.client.guilds.fetch(response.guildId) + const channel = (await guild.channels.fetch(response.channelId)) as TextBasedChannel | null + const msg = await channel?.messages.fetch(msgId) - if (!reactionMessage) { + if (!msg) { await interaction.deferUpdate() await interaction.message.edit({ content: null, @@ -53,9 +53,9 @@ withContext(on, 'interactionCreate', async (context, interaction) => { } const editMessage = (content: string, description?: string) => - editInteractionMessage(interaction, reactionMessage.url, content, description) + editInteractionMessage(interaction, msg.url, content, description) const handleCorrection = (label: string) => - handleUserResponseCorrection(context, response, reactionMessage, label, interaction.user) + handleUserResponseCorrection(context, response, msg, label, interaction.user) if (response.correctedById) return await editMessage( diff --git a/bots/discord/src/events/discord/interactionCreate/trainMessage.ts b/bots/discord/src/events/discord/interactionCreate/trainMessage.ts new file mode 100644 index 0000000..917ba10 --- /dev/null +++ b/bots/discord/src/events/discord/interactionCreate/trainMessage.ts @@ -0,0 +1,52 @@ +import { createErrorEmbed, createStackTraceEmbed, createSuccessEmbed } from '$utils/discord/embeds' +import { on, withContext } from '$utils/discord/events' + +import type { TextBasedChannel } from 'discord.js' + +withContext(on, 'interactionCreate', async (context, interaction) => { + const { + logger, + config: { messageScan: msConfig }, + } = context + + if (!msConfig?.humanCorrections) return + if (!interaction.isStringSelectMenu()) return + if (!interaction.customId.startsWith('tr_')) return + + const [, channelId, msgId] = interaction.customId.split('_') as ['tr', string, string] + if (!channelId || !msgId) return + + try { + const channel = (await interaction.client.channels.fetch(channelId)) as TextBasedChannel | null + const msg = await channel?.messages.fetch(msgId) + + if (!msg) + return void (await interaction.reply({ + embeds: [ + createErrorEmbed( + 'Message not found', + 'Thank you for your contribution! Unfortunately, the message could not be found.', + ), + ], + ephemeral: true, + })) + + const selectedLabel = interaction.values[0]! + await context.api.client.trainMessage(msg.content, selectedLabel) + await interaction.reply({ + embeds: [ + createSuccessEmbed( + 'Message being trained', + `Thank you for your contribution! The selected message is being trained as \`${selectedLabel}\`. 🎉`, + ), + ], + ephemeral: true, + }) + } catch (e) { + logger.error('Failed to handle train message interaction:', e) + await interaction.reply({ + embeds: [createStackTraceEmbed(e)], + ephemeral: true, + }) + } +})