Update to Node 20 and Discord.js 14.

Fix empty attachment bug (#61).
This commit is contained in:
charlocharlie
2024-07-20 21:43:20 -05:00
parent 2694169da1
commit 8327775302
12 changed files with 3818 additions and 2536 deletions

View File

@@ -1,6 +1,10 @@
import { SlashCommandBuilder, SlashCommandChannelOption } from '@discordjs/builders';
import { REST } from '@discordjs/rest';
import { ChannelType, Routes } from 'discord-api-types/v10';
import {
SlashCommandChannelOption,
SlashCommandBuilder,
ChannelType,
Routes,
REST,
} from 'discord.js';
import { config } from './config';
import { packageJson } from './util';
@@ -18,21 +22,21 @@ export const messageCommand = new SlashCommandBuilder()
.setName(config.slashCommandName)
.setDescription('Generate a message from learned past messages')
.addBooleanOption((tts) =>
tts.setName('tts').setDescription('Read the message via text-to-speech.').setRequired(false)
tts.setName('tts').setDescription('Read the message via text-to-speech.').setRequired(false),
)
.addBooleanOption((debug) =>
debug
.setName('debug')
.setDescription('Follow up the generated message with the detailed sources that inspired it.')
.setRequired(false)
.setRequired(false),
)
.addStringOption((seed) =>
seed
.setName('seed')
.setDescription(
`A ${config.stateSize}-word phrase to attempt to start a generated sentence with.`
`A ${config.stateSize}-word phrase to attempt to start a generated sentence with.`,
)
.setRequired(false)
.setRequired(false),
);
/**
@@ -52,10 +56,10 @@ export const listenChannelCommand = new SlashCommandBuilder()
sub
.setName('add')
.setDescription(
`Add channels to learn from. Doesn't add the channel's past messages; re-train to do that.`
`Add channels to learn from. Doesn't add the channel's past messages; re-train to do that.`,
);
Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).forEach((index) =>
sub.addChannelOption((opt) => channelOptionsGenerator(opt, index))
sub.addChannelOption((opt) => channelOptionsGenerator(opt, index)),
);
return sub;
})
@@ -63,42 +67,42 @@ export const listenChannelCommand = new SlashCommandBuilder()
sub
.setName('remove')
.setDescription(
`Remove channels from being learned from. Doesn't remove the channel's data; re-train to do that.`
`Remove channels from being learned from. Doesn't remove the channel's data; re-train to do that.`,
);
Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).forEach((index) =>
sub.addChannelOption((opt) => channelOptionsGenerator(opt, index))
sub.addChannelOption((opt) => channelOptionsGenerator(opt, index)),
);
return sub;
})
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription(`List the channels the bot is currently actively listening to.`)
.setDescription(`List the channels the bot is currently actively listening to.`),
)
.addSubcommand((sub) =>
sub
.setName('modify')
.setDescription(`Add or remove channels via select menu UI (first 25 text channels only)`)
.setDescription(`Add or remove channels via select menu UI (first 25 text channels only)`),
);
export const trainCommand = new SlashCommandBuilder()
.setName('train')
.setDescription(
'Train from past messages from the configured listened channels. This takes a while.'
'Train from past messages from the configured listened channels. This takes a while.',
)
.addBooleanOption((clean) =>
clean
.setName('clean')
.setDescription(
'Whether the database should be emptied before training. Default is true (recommended).'
'Whether the database should be emptied before training. Default is true (recommended).',
)
.setRequired(false)
.setRequired(false),
)
.addAttachmentOption((json) =>
json
.setName('json')
.setDescription('Train from a provided JSON file rather than channel history.')
.setRequired(false)
.setRequired(false),
);
const commands = [

View File

@@ -12,8 +12,6 @@ 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/v9';
import fetch from 'node-fetch';
import L from './logger';
import { Channel } from './entity/Channel';
import { Guild } from './entity/Guild';
@@ -40,21 +38,30 @@ interface SelectMenuChannel {
name?: string;
}
interface IRefreshUrlsRes {
refreshed_urls: Array<{
original: string;
refreshed: string;
}>;
}
/**
* Reply options that can be used in both MessageOptions and InteractionReplyOptions
*/
type AgnosticReplyOptions = Omit<Discord.MessageOptions, 'reply' | 'stickers' | 'flags'>;
type AgnosticReplyOptions = Omit<Discord.MessageCreateOptions, 'reply' | 'stickers' | 'flags'>;
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 rest = new Discord.REST({ version: '10' }).setToken(config.token);
const client = new Discord.Client<true>({
failIfNotExists: false,
intents: [Discord.Intents.FLAGS.GUILD_MESSAGES, Discord.Intents.FLAGS.GUILDS],
intents: [Discord.GatewayIntentBits.GuildMessages, Discord.GatewayIntentBits.Guilds],
presence: {
activities: [
{
type: 'PLAYING',
type: Discord.ActivityType.Playing,
name: config.activity,
url: packageJson().homepage,
},
@@ -75,6 +82,14 @@ const markovGenerateOptions: MarkovGenerateOptions<MarkovDataCustom> = {
maxTries: config.maxTries,
};
async function refreshCdnUrl(url: string): Promise<string> {
// Thank you https://github.com/ShufflePerson/Discord_CDN
const resp = (await rest.post(`/attachments/refresh-urls`, {
body: { attachment_urls: [url] },
})) as IRefreshUrlsRes;
return resp.refreshed_urls[0].refreshed;
}
async function getMarkovByGuildId(guildId: string): Promise<Markov> {
const markov = new Markov({ id: guildId, options: { ...markovOpts, id: guildId } });
L.trace({ guildId }, 'Setting up markov instance');
@@ -117,7 +132,7 @@ async function getValidChannels(guild: Discord.Guild): Promise<Discord.TextChann
L.error({ erroredChannel: dbc, channelId }, 'Error fetching channel');
throw err;
}
})
}),
)
).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel);
return channels;
@@ -127,7 +142,7 @@ 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
(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) => ({
@@ -153,7 +168,7 @@ async function addValidChannels(channels: Discord.TextChannel[], guildId: string
async function removeValidChannels(
channels: Discord.TextChannel[],
guildId: string
guildId: string,
): Promise<void> {
L.trace(`Removing ${channels.length} channels from valid list`);
const dbChannels = channels.map((c) => {
@@ -168,12 +183,14 @@ async function removeValidChannels(
* @return {Boolean} True if the sender is a moderator.
*
*/
function isModerator(member: Discord.GuildMember | APIInteractionGuildMember | null): boolean {
function isModerator(
member: Discord.GuildMember | Discord.APIInteractionGuildMember | null,
): boolean {
const MODERATOR_PERMISSIONS: Discord.PermissionResolvable[] = [
'ADMINISTRATOR',
'MANAGE_CHANNELS',
'KICK_MEMBERS',
'MOVE_MEMBERS',
'Administrator',
'ManageChannels',
'KickMembers',
'MoveMembers',
];
if (!member) return false;
if (member instanceof Discord.GuildMember) {
@@ -193,7 +210,9 @@ function isModerator(member: Discord.GuildMember | APIInteractionGuildMember | n
* @return {Boolean} True if the sender is a moderator.
*
*/
function isAllowedUser(member: Discord.GuildMember | APIInteractionGuildMember | null): boolean {
function isAllowedUser(
member: Discord.GuildMember | Discord.APIInteractionGuildMember | null,
): boolean {
if (!config.userRoleIds.length) return true;
if (!member) return false;
if (member instanceof Discord.GuildMember) {
@@ -255,7 +274,7 @@ function messageToData(message: Discord.Message): AddDataProps {
*/
async function saveGuildMessageHistory(
interaction: Discord.Message | Discord.CommandInteraction,
clean = true
clean = true,
): Promise<string> {
if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE;
if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE;
@@ -280,31 +299,31 @@ async function saveGuildMessageHistory(
const messageContent = `Parsing past messages from ${channels.length} channel(s).`;
const NO_COMPLETED_CHANNELS_TEXT = 'None';
const completedChannelsField: Discord.EmbedFieldData = {
const completedChannelsField: Discord.APIEmbedField = {
name: 'Completed Channels',
value: NO_COMPLETED_CHANNELS_TEXT,
inline: true,
};
const currentChannelField: Discord.EmbedFieldData = {
const currentChannelField: Discord.APIEmbedField = {
name: 'Current Channel',
value: `<#${channels[0].id}>`,
inline: true,
};
const currentChannelPercent: Discord.EmbedFieldData = {
const currentChannelPercent: Discord.APIEmbedField = {
name: 'Channel Progress',
value: '0%',
inline: true,
};
const currentChannelEta: Discord.EmbedFieldData = {
const currentChannelEta: Discord.APIEmbedField = {
name: 'Channel Time Remaining',
value: 'Pending...',
inline: true,
};
const embedOptions: Discord.MessageEmbedOptions = {
const embedOptions: Discord.EmbedData = {
title: 'Training Progress',
fields: [completedChannelsField, currentChannelField, currentChannelPercent, currentChannelEta],
};
const embed = new Discord.MessageEmbed(embedOptions);
const embed = new Discord.EmbedBuilder(embedOptions);
let progressMessage: Discord.Message;
const updateMessageData = { content: messageContent, embeds: [embed] };
if (interaction instanceof Discord.Message) {
@@ -338,7 +357,7 @@ async function saveGuildMessageHistory(
} catch (err) {
L.error(err);
L.error(
`Error retreiving messages before ${oldestMessageID} in channel ${channel.name}. This is probably a permissions issue.`
`Error retreiving messages before ${oldestMessageID} in channel ${channel.name}. This is probably a permissions issue.`,
);
break; // Give up on this channel
}
@@ -347,7 +366,7 @@ async function saveGuildMessageHistory(
const threadChannels = channelBatchMessages
.filter((m) => m.hasThread)
.map((m) => m.thread)
.filter((c): c is Discord.ThreadChannel => c !== null);
.filter((c): c is Discord.AnyThreadChannel => c !== null);
if (threadChannels.length > 0) {
L.debug(`Found ${threadChannels.length} threads. Reading into them.`);
// eslint-disable-next-line no-restricted-syntax
@@ -367,13 +386,13 @@ async function saveGuildMessageHistory(
} catch (err) {
L.error(err);
L.error(
`Error retreiving thread messages before ${oldestThreadMessageID} in thread ${threadChannel.name}. This is probably a permissions issue.`
`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`
`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
@@ -431,12 +450,12 @@ async function saveGuildMessageHistory(
lastUpdate = messagesCount;
L.debug(
{ messagesCount, pctComplete: currentChannelPercent.value },
'Sending metrics update'
'Sending metrics update',
);
// eslint-disable-next-line no-await-in-loop
await progressMessage.edit({
...updateMessageData,
embeds: [new Discord.MessageEmbed(embedOptions)],
embeds: [new Discord.EmbedBuilder(embedOptions)],
});
}
}
@@ -455,9 +474,9 @@ interface JSONImport {
* Train from an attached JSON file
*/
async function trainFromAttachmentJson(
attachment: Discord.MessageAttachment,
attachmentUrl: string,
interaction: Discord.CommandInteraction,
clean = true
clean = true,
): Promise<string> {
if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE;
if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE;
@@ -466,8 +485,7 @@ async function trainFromAttachmentJson(
let trainingData: AddDataProps[];
try {
const importAttachmentUrl = attachment.attachment.toString();
const getResp = await fetch(importAttachmentUrl);
const getResp = await fetch(attachmentUrl);
if (!getResp.ok) throw new Error(getResp.statusText);
const importData = (await getResp.json()) as JSONImport[];
@@ -480,7 +498,7 @@ async function trainFromAttachmentJson(
}
if (datum.attachments?.every((a) => typeof a !== 'string')) {
throw new Error(
`Entry at index ${index} must have all "attachments" each with a type of string`
`Entry at index ${index} must have all "attachments" each with a type of string`,
);
}
let custom: MarkovDataCustom | undefined;
@@ -530,7 +548,7 @@ interface GenerateOptions {
*/
async function generateResponse(
interaction: Discord.Message | Discord.CommandInteraction,
options?: GenerateOptions
options?: GenerateOptions,
): Promise<GenerateResponse> {
L.debug({ options }, 'Responding...');
const { tts = false, debug = false, startSeed } = options || {};
@@ -558,7 +576,8 @@ async function generateResponse(
.flatMap((ref) => (ref.custom as MarkovDataCustom).attachments);
if (attachmentUrls.length > 0) {
const randomRefAttachment = getRandomElement(attachmentUrls);
messageOpts.files = [randomRefAttachment];
const refreshedUrl = await refreshCdnUrl(randomRefAttachment);
messageOpts.files = [refreshedUrl];
} else {
const randomMessage = await MarkovInputData.createQueryBuilder<
MarkovInputData<MarkovDataCustom>
@@ -570,7 +589,9 @@ async function generateResponse(
.getOne();
const randomMessageAttachmentUrls = randomMessage?.custom?.attachments;
if (randomMessageAttachmentUrls?.length) {
messageOpts.files = [{ attachment: getRandomElement(randomMessageAttachmentUrls) }];
const attachmentUrl = getRandomElement(randomMessageAttachmentUrls);
const refreshedUrl = await refreshCdnUrl(attachmentUrl);
messageOpts.files = [{ attachment: refreshedUrl }];
}
}
messageOpts.content = response.string;
@@ -606,52 +627,59 @@ async function listValidChannels(interaction: Discord.CommandInteraction): Promi
}
function getChannelsFromInteraction(
interaction: Discord.CommandInteraction
interaction: Discord.ChatInputCommandInteraction,
): Discord.TextChannel[] {
const channels = Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).map((index) =>
interaction.options.getChannel(`channel-${index + 1}`, index === 0)
interaction.options.getChannel(`channel-${index + 1}`, index === 0),
);
const textChannels = channels.filter(
(c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel
(c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel,
);
return textChannels;
}
function helpMessage(): AgnosticReplyOptions {
const avatarURL = client.user.avatarURL() || undefined;
const embed = new Discord.MessageEmbed()
const embed = new Discord.EmbedBuilder()
.setAuthor({
name: client.user.username || packageJson().name,
iconURL: 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.`
`A Markov chain chatbot that speaks based on learned messages from previous chat input.`,
)
.addFields([
{
name: `${config.messageCommandPrefix} or /${messageCommand.name}`,
value: `Generates a sentence to say based on the chat database. Send your message as TTS to recieve it as TTS.`,
},
{
name: `/${listenChannelCommand.name}`,
value: `Add, remove, list, or modify the list of channels the bot listens to.`,
},
{
name: `${config.messageCommandPrefix} train or /${trainCommand.name}`,
value: `Fetches the maximum amount of previous messages in the listened to text channels. This takes some time.`,
},
{
name: `${config.messageCommandPrefix} invite or /${inviteCommand.name}`,
value: `Post this bot's invite URL.`,
},
{
name: `${config.messageCommandPrefix} debug or /${messageCommand.name} debug: True`,
value: `Runs the ${config.messageCommandPrefix} command and follows it up with debug info.`,
},
{
name: `${config.messageCommandPrefix} tts or /${messageCommand.name} tts: True`,
value: `Runs the ${config.messageCommandPrefix} command and reads it with text-to-speech.`,
},
])
.setFooter({
text: `${packageJson().name} ${getVersion()} by ${
(packageJson().author as PackageJsonPerson).name
@@ -664,13 +692,13 @@ function helpMessage(): AgnosticReplyOptions {
function generateInviteUrl(): string {
return client.generateInvite({
scopes: ['bot', 'applications.commands'],
scopes: [Discord.OAuth2Scopes.Bot, Discord.OAuth2Scopes.ApplicationsCommands],
permissions: [
'VIEW_CHANNEL',
'SEND_MESSAGES',
'SEND_TTS_MESSAGES',
'ATTACH_FILES',
'READ_MESSAGE_HISTORY',
'ViewChannel',
'SendMessages',
'SendTTSMessages',
'AttachFiles',
'ReadMessageHistory',
],
});
}
@@ -678,16 +706,18 @@ function generateInviteUrl(): string {
function inviteMessage(): AgnosticReplyOptions {
const avatarURL = client.user.avatarURL() || undefined;
const inviteUrl = generateInviteUrl();
const embed = new Discord.MessageEmbed()
const embed = new Discord.EmbedBuilder()
.setAuthor({ name: `Invite ${client.user?.username}`, iconURL: avatarURL })
.setThumbnail(avatarURL as string)
.addField('Invite', `[Invite ${client.user.username} to your server](${inviteUrl})`);
.addFields([
{ name: 'Invite', value: `[Invite ${client.user.username} to your server](${inviteUrl})` },
]);
return { embeds: [embed] };
}
async function handleResponseMessage(
generatedResponse: GenerateResponse,
message: Discord.Message
message: Discord.Message,
): Promise<void> {
if (generatedResponse.message) await message.reply(generatedResponse.message);
if (generatedResponse.debug) await message.reply(generatedResponse.debug);
@@ -696,7 +726,7 @@ async function handleResponseMessage(
async function handleUnprivileged(
interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction,
deleteReply = true
deleteReply = true,
): Promise<void> {
if (deleteReply) await interaction.deleteReply();
await interaction.followUp({ content: INVALID_PERMISSIONS_MESSAGE, ephemeral: true });
@@ -704,7 +734,7 @@ async function handleUnprivileged(
async function handleNoGuild(
interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction,
deleteReply = true
deleteReply = true,
): Promise<void> {
if (deleteReply) await interaction.deleteReply();
await interaction.followUp({ content: INVALID_GUILD_MESSAGE, ephemeral: true });
@@ -820,7 +850,7 @@ client.on('threadDelete', async (thread) => {
// eslint-disable-next-line consistent-return
client.on('interactionCreate', async (interaction) => {
if (interaction.isCommand()) {
if (interaction.isChatInputCommand()) {
L.info({ command: interaction.commandName }, 'Recieved slash command');
if (interaction.commandName === helpCommand.name) {
@@ -869,7 +899,7 @@ client.on('interactionCreate', async (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.`
`Added ${channels.length} text channels to the list. Use \`/train\` to update the past known messages.`,
);
} else if (subCommand === 'remove') {
if (!isModerator(interaction.member)) {
@@ -881,7 +911,7 @@ client.on('interactionCreate', async (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.`
`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) {
@@ -892,8 +922,8 @@ client.on('interactionCreate', async (interaction) => {
}
await interaction.deleteReply();
const dbTextChannels = await getTextChannels(interaction.guild);
const row = new Discord.MessageActionRow().addComponents(
new Discord.MessageSelectMenu()
const row = new Discord.ActionRowBuilder<Discord.StringSelectMenuBuilder>().addComponents(
new Discord.StringSelectMenuBuilder()
.setCustomId('listen-modify-select')
.setPlaceholder('Nothing selected')
.setMinValues(0)
@@ -903,8 +933,8 @@ client.on('interactionCreate', async (interaction) => {
label: `#${c.name}` || c.id,
value: c.id,
default: c.listen || false,
}))
)
})),
),
);
await interaction.followUp({
@@ -919,7 +949,7 @@ client.on('interactionCreate', async (interaction) => {
const trainingJSON = interaction.options.getAttachment('json');
if (trainingJSON) {
const responseMessage = await trainFromAttachmentJson(trainingJSON, interaction, clean);
const responseMessage = await trainFromAttachmentJson(trainingJSON.url, interaction, clean);
await interaction.followUp(responseMessage);
} else {
const reply = (await interaction.fetchReply()) as Discord.Message; // Must fetch the reply ASAP
@@ -928,7 +958,7 @@ client.on('interactionCreate', async (interaction) => {
await reply.reply({ content: responseMessage });
}
}
} else if (interaction.isSelectMenu()) {
} else if (interaction.isStringSelectMenu()) {
if (interaction.customId === 'listen-modify-select') {
await interaction.deferUpdate();
const { guild } = interaction;
@@ -940,14 +970,15 @@ client.on('interactionCreate', async (interaction) => {
}
const allChannels =
(interaction.component as APISelectMenuComponent).options?.map((o) => o.value) || [];
(interaction.component as Discord.StringSelectMenuComponent).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));

View File

@@ -15,7 +15,7 @@ const logger = pino(
},
PinoPretty({
translateTime: `SYS:standard`,
})
}),
);
export default logger;