Files
markov-discord/src/index.ts
2022-01-09 18:40:25 -06:00

864 lines
32 KiB
TypeScript

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<true>({
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<MarkovDataCustom> = {
filter: (result): boolean => {
return result.score >= config.minScore;
},
maxTries: config.maxTries,
};
async function getMarkovByGuildId(guildId: string): Promise<Markov> {
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<boolean> {
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<Discord.TextChannel[]> {
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<SelectMenuChannel[]> {
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<void> {
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<void> {
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<string> {
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<string, Discord.Message<boolean>>();
let channelBatchMessages: Discord.Collection<string, Discord.Message<boolean>>;
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<string, Discord.Message<boolean>>;
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<GenerateResponse> {
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<MarkovDataCustom>(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<MarkovDataCustom>
>('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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
const connection = await Markov.extendConnectionOptions();
await createConnection(connection);
await client.login(config.token);
}
main();