import 'source-map-support/register'; import 'reflect-metadata'; import * as Discord from 'discord.js'; import Markov, { MarkovGenerateOptions, MarkovConstructorOptions, AddDataProps, } from 'markov-strings-db'; import { createConnection } from 'typeorm'; import { MarkovInputData } from 'markov-strings-db/dist/src/entity/MarkovInputData'; import type { PackageJsonPerson } from 'types-package-json'; import makeEta from 'simple-eta'; import formatDistanceToNow from 'date-fns/formatDistanceToNow'; import addSeconds from 'date-fns/addSeconds'; import type { APIInteractionGuildMember, APISelectMenuComponent } from 'discord-api-types'; import L from './logger'; import { Channel } from './entity/Channel'; import { Guild } from './entity/Guild'; import { config } from './config'; import { CHANNEL_OPTIONS_MAX, deployCommands, helpCommand, inviteCommand, listenChannelCommand, messageCommand, trainCommand, } from './deploy-commands'; import { getRandomElement, getVersion, packageJson } from './util'; interface MarkovDataCustom { attachments: string[]; } interface SelectMenuChannel { id: string; listen?: boolean; name?: string; } const INVALID_PERMISSIONS_MESSAGE = 'You do not have the permissions for this action.'; const INVALID_GUILD_MESSAGE = 'This action must be performed within a server.'; const client = new Discord.Client({ intents: [Discord.Intents.FLAGS.GUILD_MESSAGES, Discord.Intents.FLAGS.GUILDS], presence: { activities: [ { type: 'PLAYING', name: config.activity, url: packageJson().homepage, }, ], }, }); const markovOpts: MarkovConstructorOptions = { stateSize: config.stateSize, }; const markovGenerateOptions: MarkovGenerateOptions = { filter: (result): boolean => { return result.score >= config.minScore; }, maxTries: config.maxTries, }; async function getMarkovByGuildId(guildId: string): Promise { const markov = new Markov({ id: guildId, options: { ...markovOpts, id: guildId } }); L.trace({ guildId }, 'Setting up markov instance'); await markov.setup(); // Connect the markov instance to the DB to assign it an ID return markov; } /** * Returns a thread channels parent guild channel ID, otherwise it just returns a channel ID */ function getGuildChannelId(channel: Discord.TextBasedChannel): string | null { if (channel.isThread()) { return channel.parentId; } return channel.id; } async function isValidChannel(channel: Discord.TextBasedChannel): Promise { const channelId = getGuildChannelId(channel); if (!channelId) return false; const dbChannel = await Channel.findOne(channelId); return dbChannel?.listen || false; } function isHumanAuthoredMessage(message: Discord.Message | Discord.PartialMessage): boolean { return !(message.author?.bot || message.system); } async function getValidChannels(guild: Discord.Guild): Promise { L.trace('Getting valid channels from database'); const dbChannels = await Channel.find({ guild: Guild.create({ id: guild.id }), listen: true }); const channels = ( await Promise.all( dbChannels.map(async (dbc) => { return guild.channels.fetch(dbc.id.toString()); }) ) ).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel); return channels; } async function getTextChannels(guild: Discord.Guild): Promise { L.trace('Getting text channels for select menu'); const MAX_SELECT_OPTIONS = 25; const textChannels = guild.channels.cache.filter( (c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel ); const foundDbChannels = await Channel.findByIds(Array.from(textChannels.keys())); const foundDbChannelsWithName: SelectMenuChannel[] = foundDbChannels.map((c) => ({ ...c, name: textChannels.find((t) => t.id === c.id)?.name, })); const notFoundDbChannels: SelectMenuChannel[] = textChannels .filter((c) => !foundDbChannels.find((d) => d.id === c.id)) .map((c) => ({ id: c.id, listen: false, name: textChannels.find((t) => t.id === c.id)?.name })); const limitedDbChannels = foundDbChannelsWithName .concat(notFoundDbChannels) .slice(0, MAX_SELECT_OPTIONS); return limitedDbChannels; } async function addValidChannels(channels: Discord.TextChannel[], guildId: string): Promise { L.trace(`Adding ${channels.length} channels to valid list`); const dbChannels = channels.map((c) => { return Channel.create({ id: c.id, guild: Guild.create({ id: guildId }), listen: true }); }); await Channel.save(dbChannels); } async function removeValidChannels( channels: Discord.TextChannel[], guildId: string ): Promise { L.trace(`Removing ${channels.length} channels from valid list`); const dbChannels = channels.map((c) => { return Channel.create({ id: c.id, guild: Guild.create({ id: guildId }), listen: false }); }); await Channel.save(dbChannels); } /** * Checks if the author of a command has moderator-like permissions. * @param {GuildMember} member Sender of the message * @return {Boolean} True if the sender is a moderator. * */ function isModerator(member: Discord.GuildMember | APIInteractionGuildMember | null): boolean { const MODERATOR_PERMISSIONS: Discord.PermissionResolvable[] = [ 'ADMINISTRATOR', 'MANAGE_CHANNELS', 'KICK_MEMBERS', 'MOVE_MEMBERS', ]; if (!member) return false; if (member instanceof Discord.GuildMember) { return ( MODERATOR_PERMISSIONS.some((p) => member.permissions.has(p)) || config.ownerIds.includes(member.id) ); } // TODO: How to parse API permissions? L.debug({ permissions: member.permissions }); return true; } /** * Checks if the author of a command has a role in the `userRoleIds` config option (if present). * @param {GuildMember} member Sender of the message * @return {Boolean} True if the sender is a moderator. * */ function isAllowedUser(member: Discord.GuildMember | APIInteractionGuildMember | null): boolean { if (!config.userRoleIds.length) return true; if (!member) return false; if (member instanceof Discord.GuildMember) { return config.userRoleIds.some((p) => member.roles.cache.has(p)); } // TODO: How to parse API permissions? L.debug({ permissions: member.permissions }); return true; } type MessageCommands = 'respond' | 'train' | 'help' | 'invite' | 'debug' | 'tts' | null; /** * Reads a new message and checks if and which command it is. * @param {Message} message Message to be interpreted as a command * @return {String} Command string */ function validateMessage(message: Discord.Message): MessageCommands { const messageText = message.content.toLowerCase(); let command: MessageCommands = null; const thisPrefix = messageText.substring(0, config.messageCommandPrefix.length); if (thisPrefix === config.messageCommandPrefix) { const split = messageText.split(' '); if (split[0] === config.messageCommandPrefix && split.length === 1) { command = 'respond'; } else if (split[1] === 'train') { command = 'train'; } else if (split[1] === 'help') { command = 'help'; } else if (split[1] === 'invite') { command = 'invite'; } else if (split[1] === 'debug') { command = 'debug'; } else if (split[1] === 'tts') { command = 'tts'; } } return command; } function messageToData(message: Discord.Message): AddDataProps { const attachmentUrls = message.attachments.map((a) => a.url); let custom: MarkovDataCustom | undefined; if (attachmentUrls.length) custom = { attachments: attachmentUrls }; const tags: string[] = [message.id]; if (message.channel.isThread()) tags.push(message.channelId); // Add thread channel ID const channelId = getGuildChannelId(message.channel); if (channelId) tags.push(channelId); // Add guild channel ID if (message.guildId) tags.push(message.guildId); // Add guild ID return { string: message.content, custom, tags, }; } /** * Recursively gets all messages in a text channel's history. */ async function saveGuildMessageHistory( interaction: Discord.Message | Discord.CommandInteraction ): Promise { if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE; if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE; const markov = await getMarkovByGuildId(interaction.guildId); const channels = await getValidChannels(interaction.guild); if (!channels.length) { L.warn({ guildId: interaction.guildId }, 'No channels to train from'); return 'No channels configured to learn from. Set some with `/listen add`.'; } L.debug('Deleting old data'); await markov.delete(); const channelIds = channels.map((c) => c.id); L.debug({ channelIds }, `Training from text channels`); const messageContent = `Parsing past messages from ${channels.length} channel(s).`; const NO_COMPLETED_CHANNELS_TEXT = 'None'; const completedChannelsField: Discord.EmbedFieldData = { name: 'Completed Channels', value: NO_COMPLETED_CHANNELS_TEXT, inline: true, }; const currentChannelField: Discord.EmbedFieldData = { name: 'Current Channel', value: `<#${channels[0].id}>`, inline: true, }; const currentChannelPercent: Discord.EmbedFieldData = { name: 'Channel Progress', value: '0%', inline: true, }; const currentChannelEta: Discord.EmbedFieldData = { name: 'Channel Time Remaining', value: 'Pending...', inline: true, }; const embedOptions: Discord.MessageEmbedOptions = { title: 'Training Progress', fields: [completedChannelsField, currentChannelField, currentChannelPercent, currentChannelEta], }; const embed = new Discord.MessageEmbed(embedOptions); let progressMessage: Discord.Message; const updateMessageData = { content: messageContent, embeds: [embed] }; if (interaction instanceof Discord.Message) { progressMessage = await interaction.reply(updateMessageData); } else { progressMessage = (await interaction.followUp(updateMessageData)) as Discord.Message; } const PAGE_SIZE = 100; const UPDATE_RATE = 1000; // In number of messages processed let lastUpdate = 0; let messagesCount = 0; let firstMessageDate: number | undefined; // eslint-disable-next-line no-restricted-syntax for (const channel of channels) { let oldestMessageID: string | undefined; let keepGoing = true; L.debug({ channelId: channel.id, messagesCount }, `Training from channel`); const channelCreateDate = channel.createdTimestamp; const channelEta = makeEta({ autostart: true, min: 0, max: 1, historyTimeConstant: 30 }); while (keepGoing) { let allBatchMessages = new Discord.Collection>(); let channelBatchMessages: Discord.Collection>; try { // eslint-disable-next-line no-await-in-loop channelBatchMessages = await channel.messages.fetch({ before: oldestMessageID, limit: PAGE_SIZE, }); } catch (err) { L.error(err); L.error( `Error retreiving messages before ${oldestMessageID} in channel ${channel.name}. This is probably a permissions issue.` ); break; // Give up on this channel } // Gather any thread messages if present in this message batch const threadChannels = channelBatchMessages .filter((m) => m.hasThread) .map((m) => m.thread) .filter((c): c is Discord.ThreadChannel => c !== null); if (threadChannels.length > 0) { L.debug(`Found ${threadChannels.length} threads. Reading into them.`); // eslint-disable-next-line no-restricted-syntax for (const threadChannel of threadChannels) { let oldestThreadMessageID: string | undefined; let keepGoingThread = true; L.debug({ channelId: threadChannel.id }, `Training from thread`); while (keepGoingThread) { let threadBatchMessages: Discord.Collection>; try { // eslint-disable-next-line no-await-in-loop threadBatchMessages = await threadChannel.messages.fetch({ before: oldestThreadMessageID, limit: PAGE_SIZE, }); } catch (err) { L.error(err); L.error( `Error retreiving thread messages before ${oldestThreadMessageID} in thread ${threadChannel.name}. This is probably a permissions issue.` ); break; // Give up on this thread } L.trace( { threadMessagesCount: threadBatchMessages.size }, `Found some thread messages` ); const lastThreadMessage = threadBatchMessages.last(); allBatchMessages = allBatchMessages.concat(threadBatchMessages); // Add the thread messages to this message batch to be included in later processing if (!lastThreadMessage?.id || threadBatchMessages.size < PAGE_SIZE) { keepGoingThread = false; } else { oldestThreadMessageID = lastThreadMessage.id; } } } } allBatchMessages = allBatchMessages.concat(channelBatchMessages); // Filter and data map messages to be ready for addition to the corpus const humanAuthoredMessages = allBatchMessages .filter((m) => isHumanAuthoredMessage(m)) .map(messageToData); L.trace({ oldestMessageID }, `Saving ${humanAuthoredMessages.length} messages`); // eslint-disable-next-line no-await-in-loop await markov.addData(humanAuthoredMessages); L.trace('Finished saving messages'); messagesCount += humanAuthoredMessages.length; const lastMessage = channelBatchMessages.last(); // Update tracking metrics if (!lastMessage?.id || channelBatchMessages.size < PAGE_SIZE) { keepGoing = false; const channelIdListItem = ` • <#${channel.id}>`; if (completedChannelsField.value === NO_COMPLETED_CHANNELS_TEXT) completedChannelsField.value = channelIdListItem; else { completedChannelsField.value += `\n${channelIdListItem}`; } } else { oldestMessageID = lastMessage.id; } currentChannelField.value = `<#${channel.id}>`; if (!firstMessageDate) firstMessageDate = channelBatchMessages.first()?.createdTimestamp; const oldestMessageDate = lastMessage?.createdTimestamp; if (firstMessageDate && oldestMessageDate) { const channelAge = firstMessageDate - channelCreateDate; const lastMessageAge = firstMessageDate - oldestMessageDate; const pctComplete = lastMessageAge / channelAge; currentChannelPercent.value = `${(pctComplete * 100).toFixed(2)}%`; channelEta.report(pctComplete); const estimateSeconds = channelEta.estimate(); if (Number.isFinite(estimateSeconds)) currentChannelEta.value = formatDistanceToNow(addSeconds(new Date(), estimateSeconds), { includeSeconds: true, }); } if (messagesCount > lastUpdate + UPDATE_RATE) { lastUpdate = messagesCount; L.debug( { messagesCount, pctComplete: currentChannelPercent.value }, 'Sending metrics update' ); // eslint-disable-next-line no-await-in-loop await progressMessage.edit({ ...updateMessageData, embeds: [new Discord.MessageEmbed(embedOptions)], }); } } } L.info({ channelIds }, `Trained from ${messagesCount} past human authored messages.`); return `Trained from ${messagesCount} past human authored messages.`; } interface GenerateResponse { message?: Discord.MessageOptions; debug?: Discord.MessageOptions; error?: Discord.MessageOptions; } interface GenerateOptions { tts?: boolean; debug?: boolean; startSeed?: string; } /** * General Markov-chain response function * @param interaction The message that invoked the action, used for channel info. * @param debug Sends debug info as a message if true. * @param tts If the message should be sent as TTS. Defaults to the TTS setting of the * invoking message. */ async function generateResponse( interaction: Discord.Message | Discord.CommandInteraction, options?: GenerateOptions ): Promise { L.debug({ options }, 'Responding...'); const { tts = false, debug = false, startSeed } = options || {}; if (!interaction.guildId) { L.warn('Received an interaction without a guildId'); return { error: { content: INVALID_GUILD_MESSAGE } }; } if (!isAllowedUser(interaction.member)) { L.info('Member does not have permissions to generate a response'); return { error: { content: INVALID_PERMISSIONS_MESSAGE } }; } const markov = await getMarkovByGuildId(interaction.guildId); try { markovGenerateOptions.startSeed = startSeed; const response = await markov.generate(markovGenerateOptions); L.info({ string: response.string }, 'Generated response text'); L.debug({ response }, 'Generated response object'); const messageOpts: Discord.MessageOptions = { tts, allowedMentions: { repliedUser: false, parse: [] }, }; const attachmentUrls = response.refs .filter((ref) => ref.custom && 'attachments' in ref.custom) .flatMap((ref) => (ref.custom as MarkovDataCustom).attachments); if (attachmentUrls.length > 0) { const randomRefAttachment = getRandomElement(attachmentUrls); messageOpts.files = [randomRefAttachment]; } else { const randomMessage = await MarkovInputData.createQueryBuilder< MarkovInputData >('input') .leftJoinAndSelect('input.markov', 'markov') .where({ markov: markov.db }) .orderBy('RANDOM()') .limit(1) .getOne(); const randomMessageAttachmentUrls = randomMessage?.custom?.attachments; if (randomMessageAttachmentUrls?.length) { messageOpts.files = [{ attachment: getRandomElement(randomMessageAttachmentUrls) }]; } } messageOpts.content = response.string; const responseMessages: GenerateResponse = { message: messageOpts, }; if (debug) { responseMessages.debug = { content: `\`\`\`\n${JSON.stringify(response, null, 2)}\n\`\`\``, allowedMentions: { repliedUser: false, parse: [] }, }; } return responseMessages; } catch (err) { L.error(err); return { error: { content: `\n\`\`\`\nERROR: ${err}\n\`\`\``, allowedMentions: { repliedUser: false, parse: [] }, }, }; } } async function listValidChannels(interaction: Discord.CommandInteraction): Promise { if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE; const channels = await getValidChannels(interaction.guild); const channelText = channels.reduce((list, channel) => { return `${list}\n • <#${channel.id}>`; }, ''); return `This bot is currently listening and learning from ${channels.length} channel(s).${channelText}`; } function getChannelsFromInteraction( interaction: Discord.CommandInteraction ): Discord.TextChannel[] { const channels = Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).map((index) => interaction.options.getChannel(`channel-${index + 1}`, index === 0) ); const textChannels = channels.filter( (c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel ); return textChannels; } function helpMessage(): Discord.MessageOptions { const avatarURL = client.user.avatarURL() || undefined; const embed = new Discord.MessageEmbed() .setAuthor(client.user.username || packageJson().name, avatarURL) .setThumbnail(avatarURL as string) .setDescription( `A Markov chain chatbot that speaks based on learned messages from previous chat input.` ) .addField( `${config.messageCommandPrefix} or /${messageCommand.name}`, `Generates a sentence to say based on the chat database. Send your message as TTS to recieve it as TTS.` ) .addField( `/${listenChannelCommand.name}`, `Add, remove, list, or modify the list of channels the bot listens to.` ) .addField( `${config.messageCommandPrefix} train or /${trainCommand.name}`, `Fetches the maximum amount of previous messages in the listened to text channels. This takes some time.` ) .addField( `${config.messageCommandPrefix} invite or /${inviteCommand.name}`, `Post this bot's invite URL.` ) .addField( `${config.messageCommandPrefix} debug or /${messageCommand.name} debug: True`, `Runs the ${config.messageCommandPrefix} command and follows it up with debug info.` ) .addField( `${config.messageCommandPrefix} tts or /${messageCommand.name} tts: True`, `Runs the ${config.messageCommandPrefix} command and reads it with text-to-speech.` ) .setFooter( `${packageJson().name} ${getVersion()} by ${(packageJson().author as PackageJsonPerson).name}` ); return { embeds: [embed], }; } function generateInviteUrl(): string { return client.generateInvite({ scopes: ['bot', 'applications.commands'], permissions: [ 'VIEW_CHANNEL', 'SEND_MESSAGES', 'SEND_TTS_MESSAGES', 'ATTACH_FILES', 'READ_MESSAGE_HISTORY', ], }); } function inviteMessage(): Discord.MessageOptions { const avatarURL = client.user.avatarURL() || undefined; const inviteUrl = generateInviteUrl(); const embed = new Discord.MessageEmbed() .setAuthor(`Invite ${client.user?.username}`, avatarURL) .setThumbnail(avatarURL as string) .addField('Invite', `[Invite ${client.user.username} to your server](${inviteUrl})`); return { embeds: [embed] }; } async function handleResponseMessage( generatedResponse: GenerateResponse, message: Discord.Message ): Promise { if (generatedResponse.message) await message.reply(generatedResponse.message); if (generatedResponse.debug) await message.reply(generatedResponse.debug); if (generatedResponse.error) await message.reply(generatedResponse.error); } async function handleUnprivileged( interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction, deleteReply = true ): Promise { if (deleteReply) await interaction.deleteReply(); await interaction.followUp({ content: INVALID_PERMISSIONS_MESSAGE, ephemeral: true }); } async function handleNoGuild( interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction, deleteReply = true ): Promise { if (deleteReply) await interaction.deleteReply(); await interaction.followUp({ content: INVALID_GUILD_MESSAGE, ephemeral: true }); } client.on('ready', async (readyClient) => { L.info({ inviteUrl: generateInviteUrl() }, 'Bot logged in'); await deployCommands(readyClient.user.id); const guildsToSave = readyClient.guilds.valueOf().map((guild) => Guild.create({ id: guild.id })); // Remove the duplicate commands if (!config.devGuildId) { await Promise.all(readyClient.guilds.valueOf().map(async (guild) => guild.commands.set([]))); } await Guild.upsert(guildsToSave, ['id']); }); client.on('guildCreate', async (guild) => { L.info({ guildId: guild.id }, 'Adding new guild'); await Guild.upsert(Guild.create({ id: guild.id }), ['id']); }); client.on('debug', (m) => L.trace(m)); client.on('warn', (m) => L.warn(m)); client.on('error', (m) => L.error(m)); client.on('messageCreate', async (message) => { if ( !( message.guild && (message.channel instanceof Discord.TextChannel || message.channel instanceof Discord.ThreadChannel) ) ) return; const command = validateMessage(message); if (command !== null) L.info({ command }, 'Recieved message command'); if (command === 'help') { await message.channel.send(helpMessage()); } if (command === 'invite') { await message.channel.send(inviteMessage()); } if (command === 'train') { const response = await saveGuildMessageHistory(message); await message.reply(response); } if (command === 'respond') { L.debug('Responding to legacy command'); const generatedResponse = await generateResponse(message); await handleResponseMessage(generatedResponse, message); } if (command === 'tts') { L.debug('Responding to legacy command tts'); const generatedResponse = await generateResponse(message, { tts: true }); await handleResponseMessage(generatedResponse, message); } if (command === 'debug') { L.debug('Responding to legacy command debug'); const generatedResponse = await generateResponse(message, { debug: true }); await handleResponseMessage(generatedResponse, message); } if (command === null) { if (isHumanAuthoredMessage(message)) { if (client.user && message.mentions.has(client.user)) { L.debug('Responding to mention'); // <@!278354154563567636> how are you doing? const startSeed = message.content.replace(/<@!\d+>/g, '').trim(); const generatedResponse = await generateResponse(message, { startSeed }); await handleResponseMessage(generatedResponse, message); } if (await isValidChannel(message.channel)) { L.debug('Listening'); const markov = await getMarkovByGuildId(message.channel.guildId); await markov.addData([messageToData(message)]); } } } }); client.on('messageDelete', async (message) => { if (!isHumanAuthoredMessage(message)) return; if (!(await isValidChannel(message.channel))) return; if (!message.guildId) return; L.debug(`Deleting message ${message.id}`); const markov = await getMarkovByGuildId(message.guildId); await markov.removeTags([message.id]); }); client.on('messageUpdate', async (oldMessage, newMessage) => { if (!isHumanAuthoredMessage(oldMessage)) return; if (!(await isValidChannel(oldMessage.channel))) return; if (!(oldMessage.guildId && newMessage.content)) return; L.debug(`Editing message ${oldMessage.id}`); const markov = await getMarkovByGuildId(oldMessage.guildId); await markov.removeTags([oldMessage.id]); await markov.addData([newMessage.content]); }); client.on('threadDelete', async (thread) => { if (!(await isValidChannel(thread))) return; if (!thread.guildId) return; L.debug(`Deleting thread messages ${thread.id}`); const markov = await getMarkovByGuildId(thread.guildId); await markov.removeTags([thread.id]); }); // eslint-disable-next-line consistent-return client.on('interactionCreate', async (interaction) => { if (interaction.isCommand()) { L.info({ command: interaction.commandName }, 'Recieved slash command'); if (interaction.commandName === helpCommand.name) { await interaction.reply(helpMessage()); } else if (interaction.commandName === inviteCommand.name) { await interaction.reply(inviteMessage()); } else if (interaction.commandName === messageCommand.name) { await interaction.deferReply(); const tts = interaction.options.getBoolean('tts') || false; const debug = interaction.options.getBoolean('debug') || false; const startSeed = interaction.options.getString('seed')?.trim() || undefined; const generatedResponse = await generateResponse(interaction, { tts, debug, startSeed }); if (generatedResponse.message) await interaction.editReply(generatedResponse.message); else await interaction.deleteReply(); if (generatedResponse.debug) await interaction.followUp(generatedResponse.debug); if (generatedResponse.error) { await interaction.followUp({ ...generatedResponse.error, ephemeral: true }); } } else if (interaction.commandName === listenChannelCommand.name) { await interaction.deferReply(); const subCommand = interaction.options.getSubcommand(true) as 'add' | 'remove' | 'list'; if (subCommand === 'list') { const reply = await listValidChannels(interaction); await interaction.editReply(reply); } else if (subCommand === 'add') { if (!isModerator(interaction.member)) { return handleUnprivileged(interaction); } if (!interaction.guildId) { return handleNoGuild(interaction); } const channels = getChannelsFromInteraction(interaction); await addValidChannels(channels, interaction.guildId); await interaction.editReply( `Added ${channels.length} text channels to the list. Use \`/train\` to update the past known messages.` ); } else if (subCommand === 'remove') { if (!isModerator(interaction.member)) { return handleUnprivileged(interaction); } if (!interaction.guildId) { return handleNoGuild(interaction); } const channels = getChannelsFromInteraction(interaction); await removeValidChannels(channels, interaction.guildId); await interaction.editReply( `Removed ${channels.length} text channels from the list. Use \`/train\` to remove these channels from the past known messages.` ); } else if (subCommand === 'modify') { if (!interaction.guild) { return handleNoGuild(interaction); } if (!isModerator(interaction.member)) { await handleUnprivileged(interaction); } await interaction.deleteReply(); const dbTextChannels = await getTextChannels(interaction.guild); const row = new Discord.MessageActionRow().addComponents( new Discord.MessageSelectMenu() .setCustomId('listen-modify-select') .setPlaceholder('Nothing selected') .setMinValues(0) .setMaxValues(dbTextChannels.length) .addOptions( dbTextChannels.map((c) => ({ label: `#${c.name}` || c.id, value: c.id, default: c.listen || false, })) ) ); await interaction.followUp({ content: 'Select which channels you would like to the bot to actively listen to', components: [row], ephemeral: true, }); } } else if (interaction.commandName === trainCommand.name) { await interaction.deferReply(); const reply = (await interaction.fetchReply()) as Discord.Message; // Must fetch the reply ASAP const responseMessage = await saveGuildMessageHistory(interaction); // Send a message in reply to the reply to avoid the 15 minute webhook token timeout await reply.reply({ content: responseMessage }); } } else if (interaction.isSelectMenu()) { if (interaction.customId === 'listen-modify-select') { await interaction.deferUpdate(); const { guild } = interaction; if (!isModerator(interaction.member)) { return handleUnprivileged(interaction, false); } if (!guild) { return handleNoGuild(interaction, false); } const allChannels = (interaction.component as APISelectMenuComponent).options?.map((o) => o.value) || []; const selectedChannelIds = interaction.values; const textChannels = ( await Promise.all( allChannels.map(async (c) => { return guild.channels.fetch(c); }) ) ).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel); const unselectedChannels = textChannels.filter((t) => !selectedChannelIds.includes(t.id)); const selectedChannels = textChannels.filter((t) => selectedChannelIds.includes(t.id)); await addValidChannels(selectedChannels, guild.id); await removeValidChannels(unselectedChannels, guild.id); await interaction.followUp({ content: 'Updated actively listened to channels list.', ephemeral: true, }); } } }); /** * Loads the config settings from disk */ async function main(): Promise { const connection = await Markov.extendConnectionOptions(); await createConnection(connection); await client.login(config.token); } main();