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

@@ -12,25 +12,25 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v3
with: with:
install: true install: true
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v3
with: with:
username: charlocharlie username: charlocharlie
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -38,7 +38,7 @@ jobs:
- name: Build and push master - name: Build and push master
if: ${{ github.ref == 'refs/heads/master' }} if: ${{ github.ref == 'refs/heads/master' }}
uses: docker/build-push-action@v2 uses: docker/build-push-action@v6
with: with:
target: deploy target: deploy
push: true push: true
@@ -53,7 +53,7 @@ jobs:
- name: Build and push dev - name: Build and push dev
if: ${{ github.ref == 'refs/heads/develop' }} if: ${{ github.ref == 'refs/heads/develop' }}
uses: docker/build-push-action@v2 uses: docker/build-push-action@v6
with: with:
target: deploy target: deploy
push: true push: true

View File

@@ -11,10 +11,10 @@ jobs:
dockerHubDescription: dockerHubDescription:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Docker Hub Description - name: Docker Hub Description
uses: peter-evans/dockerhub-description@v2 uses: peter-evans/dockerhub-description@v4
with: with:
username: charlocharlie username: charlocharlie
password: ${{ secrets.DOCKERHUB_PASSWORD }} password: ${{ secrets.DOCKERHUB_PASSWORD }}

View File

@@ -12,11 +12,11 @@ jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Setup Node.js for use with actions - name: Setup Node.js for use with actions
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: 'lts/*' node-version-file: '.nvmrc'
cache: 'npm' cache: 'npm'
- name: NPM install - name: NPM install
@@ -27,7 +27,7 @@ jobs:
run: npm run docs run: npm run docs
- name: Deploy - name: Deploy
uses: peaceiris/actions-gh-pages@v3 uses: peaceiris/actions-gh-pages@v4
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs publish_dir: ./docs

2
.nvmrc
View File

@@ -1 +1 @@
16 20

View File

@@ -4,7 +4,7 @@
"typescript" "typescript"
], ],
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": "explicit"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"[javascript]": { "[javascript]": {

View File

@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
## Versions ## Versions
### 2.3.0
* Update to Node 20 and Discord.js 14. Update a million dependencies
* Fix empty attachment bug (#61)
### 2.2.0 ### 2.2.0
* Add a `clean` option flag to the `/train` command to allow retraining without overwriting * Add a `clean` option flag to the `/train` command to allow retraining without overwriting

View File

@@ -1,7 +1,7 @@
######## ########
# BASE # BASE
######## ########
FROM node:16-alpine3.15 as base FROM node:20-alpine3.20 as base
WORKDIR /usr/app WORKDIR /usr/app
@@ -16,7 +16,7 @@ FROM base as prodDeps
COPY package*.json ./ COPY package*.json ./
# Install build tools for erlpack, then install prod deps only # Install build tools for erlpack, then install prod deps only
RUN apk add --no-cache make gcc g++ python3 \ RUN apk add --no-cache make gcc g++ python3 \
&& npm ci --only=production && npm ci --omit=dev
######## ########
# BUILD # BUILD
@@ -26,7 +26,7 @@ FROM base as build
COPY package*.json ./ COPY package*.json ./
# Install build tools for erlpack, then install prod deps only # Install build tools for erlpack, then install prod deps only
RUN apk add --no-cache make gcc g++ python3 \ RUN apk add --no-cache make gcc g++ python3 \
&& npm ci --only=production && npm ci --omit=dev
# Copy all jsons # Copy all jsons
COPY package*.json tsconfig.json ./ COPY package*.json tsconfig.json ./

6010
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "markov-discord", "name": "markov-discord",
"version": "2.2.0", "version": "2.3.0",
"description": "A conversational Markov chain bot for Discord", "description": "A conversational Markov chain bot for Discord",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
@@ -31,50 +31,46 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@discordjs/builders": "^0.13.0", "better-sqlite3": "^8.7.0",
"@discordjs/rest": "^0.4.1", "bufferutil": "^4.0.8",
"@types/fs-extra": "^9.0.13",
"better-sqlite3": "^7.5.3",
"bufferutil": "^4.0.6",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.1",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"discord-api-types": "^0.33.0", "discord.js": "^14.15.3",
"discord.js": "^13.7.0", "dotenv": "^16.4.5",
"dotenv": "^16.0.1", "fs-extra": "^11.2.0",
"erlpack": "github:discord/erlpack", "json5": "^2.2.3",
"fs-extra": "^10.1.0",
"json5": "^2.2.2",
"markov-strings-db": "^4.2.0", "markov-strings-db": "^4.2.0",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.7",
"pino": "^7.11.0", "pino": "^7.11.0",
"pino-pretty": "^7.6.1", "pino-pretty": "^7.6.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.2.2",
"simple-eta": "^3.0.2", "simple-eta": "^3.0.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"typeorm": "^0.3.14", "typeorm": "^0.3.20",
"utf-8-validate": "^5.0.9", "utf-8-validate": "^6.0.4",
"zlib-sync": "^0.1.7" "zlib-sync": "^0.1.9"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.36", "@types/fs-extra": "^11.0.4",
"@types/validator": "^13.7.2", "@types/node": "^20.14.11",
"@typescript-eslint/eslint-plugin": "^5.25.0", "@types/validator": "^13.12.0",
"@typescript-eslint/parser": "^5.25.0", "@typescript-eslint/eslint-plugin": "^7.16.1",
"eslint": "^8.16.0", "@typescript-eslint/parser": "^7.16.1",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^5.2.1",
"pm2": "^5.3.1", "pm2": "^5.4.2",
"prettier": "^2.6.2", "prettier": "^3.3.3",
"rimraf": "^3.0.2", "rimraf": "^6.0.1",
"ts-node": "^10.7.0", "ts-node": "^10.9.2",
"typedoc": "^0.22.15", "typedoc": "^0.26.4",
"types-package-json": "^2.0.39", "types-package-json": "^2.0.39",
"typescript": "^4.6.4" "typescript": "5.4"
}, },
"engines": { "engines": {
"node": "16" "node": ">=20"
} }
} }

View File

@@ -1,6 +1,10 @@
import { SlashCommandBuilder, SlashCommandChannelOption } from '@discordjs/builders'; import {
import { REST } from '@discordjs/rest'; SlashCommandChannelOption,
import { ChannelType, Routes } from 'discord-api-types/v10'; SlashCommandBuilder,
ChannelType,
Routes,
REST,
} from 'discord.js';
import { config } from './config'; import { config } from './config';
import { packageJson } from './util'; import { packageJson } from './util';
@@ -18,21 +22,21 @@ export const messageCommand = new SlashCommandBuilder()
.setName(config.slashCommandName) .setName(config.slashCommandName)
.setDescription('Generate a message from learned past messages') .setDescription('Generate a message from learned past messages')
.addBooleanOption((tts) => .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) => .addBooleanOption((debug) =>
debug debug
.setName('debug') .setName('debug')
.setDescription('Follow up the generated message with the detailed sources that inspired it.') .setDescription('Follow up the generated message with the detailed sources that inspired it.')
.setRequired(false) .setRequired(false),
) )
.addStringOption((seed) => .addStringOption((seed) =>
seed seed
.setName('seed') .setName('seed')
.setDescription( .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 sub
.setName('add') .setName('add')
.setDescription( .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) => Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).forEach((index) =>
sub.addChannelOption((opt) => channelOptionsGenerator(opt, index)) sub.addChannelOption((opt) => channelOptionsGenerator(opt, index)),
); );
return sub; return sub;
}) })
@@ -63,42 +67,42 @@ export const listenChannelCommand = new SlashCommandBuilder()
sub sub
.setName('remove') .setName('remove')
.setDescription( .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) => Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).forEach((index) =>
sub.addChannelOption((opt) => channelOptionsGenerator(opt, index)) sub.addChannelOption((opt) => channelOptionsGenerator(opt, index)),
); );
return sub; return sub;
}) })
.addSubcommand((sub) => .addSubcommand((sub) =>
sub sub
.setName('list') .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) => .addSubcommand((sub) =>
sub sub
.setName('modify') .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() export const trainCommand = new SlashCommandBuilder()
.setName('train') .setName('train')
.setDescription( .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) => .addBooleanOption((clean) =>
clean clean
.setName('clean') .setName('clean')
.setDescription( .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) => .addAttachmentOption((json) =>
json json
.setName('json') .setName('json')
.setDescription('Train from a provided JSON file rather than channel history.') .setDescription('Train from a provided JSON file rather than channel history.')
.setRequired(false) .setRequired(false),
); );
const commands = [ const commands = [

View File

@@ -12,8 +12,6 @@ import type { PackageJsonPerson } from 'types-package-json';
import makeEta from 'simple-eta'; import makeEta from 'simple-eta';
import formatDistanceToNow from 'date-fns/formatDistanceToNow'; import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import addSeconds from 'date-fns/addSeconds'; 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 L from './logger';
import { Channel } from './entity/Channel'; import { Channel } from './entity/Channel';
import { Guild } from './entity/Guild'; import { Guild } from './entity/Guild';
@@ -40,21 +38,30 @@ interface SelectMenuChannel {
name?: string; name?: string;
} }
interface IRefreshUrlsRes {
refreshed_urls: Array<{
original: string;
refreshed: string;
}>;
}
/** /**
* Reply options that can be used in both MessageOptions and InteractionReplyOptions * 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_PERMISSIONS_MESSAGE = 'You do not have the permissions for this action.';
const INVALID_GUILD_MESSAGE = 'This action must be performed within a server.'; 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>({ const client = new Discord.Client<true>({
failIfNotExists: false, failIfNotExists: false,
intents: [Discord.Intents.FLAGS.GUILD_MESSAGES, Discord.Intents.FLAGS.GUILDS], intents: [Discord.GatewayIntentBits.GuildMessages, Discord.GatewayIntentBits.Guilds],
presence: { presence: {
activities: [ activities: [
{ {
type: 'PLAYING', type: Discord.ActivityType.Playing,
name: config.activity, name: config.activity,
url: packageJson().homepage, url: packageJson().homepage,
}, },
@@ -75,6 +82,14 @@ const markovGenerateOptions: MarkovGenerateOptions<MarkovDataCustom> = {
maxTries: config.maxTries, 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> { async function getMarkovByGuildId(guildId: string): Promise<Markov> {
const markov = new Markov({ id: guildId, options: { ...markovOpts, id: guildId } }); const markov = new Markov({ id: guildId, options: { ...markovOpts, id: guildId } });
L.trace({ guildId }, 'Setting up markov instance'); 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'); L.error({ erroredChannel: dbc, channelId }, 'Error fetching channel');
throw err; throw err;
} }
}) }),
) )
).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel); ).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel);
return channels; return channels;
@@ -127,7 +142,7 @@ async function getTextChannels(guild: Discord.Guild): Promise<SelectMenuChannel[
L.trace('Getting text channels for select menu'); L.trace('Getting text channels for select menu');
const MAX_SELECT_OPTIONS = 25; const MAX_SELECT_OPTIONS = 25;
const textChannels = guild.channels.cache.filter( 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 foundDbChannels = await Channel.findByIds(Array.from(textChannels.keys()));
const foundDbChannelsWithName: SelectMenuChannel[] = foundDbChannels.map((c) => ({ const foundDbChannelsWithName: SelectMenuChannel[] = foundDbChannels.map((c) => ({
@@ -153,7 +168,7 @@ async function addValidChannels(channels: Discord.TextChannel[], guildId: string
async function removeValidChannels( async function removeValidChannels(
channels: Discord.TextChannel[], channels: Discord.TextChannel[],
guildId: string guildId: string,
): Promise<void> { ): Promise<void> {
L.trace(`Removing ${channels.length} channels from valid list`); L.trace(`Removing ${channels.length} channels from valid list`);
const dbChannels = channels.map((c) => { const dbChannels = channels.map((c) => {
@@ -168,12 +183,14 @@ async function removeValidChannels(
* @return {Boolean} True if the sender is a moderator. * @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[] = [ const MODERATOR_PERMISSIONS: Discord.PermissionResolvable[] = [
'ADMINISTRATOR', 'Administrator',
'MANAGE_CHANNELS', 'ManageChannels',
'KICK_MEMBERS', 'KickMembers',
'MOVE_MEMBERS', 'MoveMembers',
]; ];
if (!member) return false; if (!member) return false;
if (member instanceof Discord.GuildMember) { 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. * @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 (!config.userRoleIds.length) return true;
if (!member) return false; if (!member) return false;
if (member instanceof Discord.GuildMember) { if (member instanceof Discord.GuildMember) {
@@ -255,7 +274,7 @@ function messageToData(message: Discord.Message): AddDataProps {
*/ */
async function saveGuildMessageHistory( async function saveGuildMessageHistory(
interaction: Discord.Message | Discord.CommandInteraction, interaction: Discord.Message | Discord.CommandInteraction,
clean = true clean = true,
): Promise<string> { ): Promise<string> {
if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE; if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE;
if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_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 messageContent = `Parsing past messages from ${channels.length} channel(s).`;
const NO_COMPLETED_CHANNELS_TEXT = 'None'; const NO_COMPLETED_CHANNELS_TEXT = 'None';
const completedChannelsField: Discord.EmbedFieldData = { const completedChannelsField: Discord.APIEmbedField = {
name: 'Completed Channels', name: 'Completed Channels',
value: NO_COMPLETED_CHANNELS_TEXT, value: NO_COMPLETED_CHANNELS_TEXT,
inline: true, inline: true,
}; };
const currentChannelField: Discord.EmbedFieldData = { const currentChannelField: Discord.APIEmbedField = {
name: 'Current Channel', name: 'Current Channel',
value: `<#${channels[0].id}>`, value: `<#${channels[0].id}>`,
inline: true, inline: true,
}; };
const currentChannelPercent: Discord.EmbedFieldData = { const currentChannelPercent: Discord.APIEmbedField = {
name: 'Channel Progress', name: 'Channel Progress',
value: '0%', value: '0%',
inline: true, inline: true,
}; };
const currentChannelEta: Discord.EmbedFieldData = { const currentChannelEta: Discord.APIEmbedField = {
name: 'Channel Time Remaining', name: 'Channel Time Remaining',
value: 'Pending...', value: 'Pending...',
inline: true, inline: true,
}; };
const embedOptions: Discord.MessageEmbedOptions = { const embedOptions: Discord.EmbedData = {
title: 'Training Progress', title: 'Training Progress',
fields: [completedChannelsField, currentChannelField, currentChannelPercent, currentChannelEta], fields: [completedChannelsField, currentChannelField, currentChannelPercent, currentChannelEta],
}; };
const embed = new Discord.MessageEmbed(embedOptions); const embed = new Discord.EmbedBuilder(embedOptions);
let progressMessage: Discord.Message; let progressMessage: Discord.Message;
const updateMessageData = { content: messageContent, embeds: [embed] }; const updateMessageData = { content: messageContent, embeds: [embed] };
if (interaction instanceof Discord.Message) { if (interaction instanceof Discord.Message) {
@@ -338,7 +357,7 @@ async function saveGuildMessageHistory(
} catch (err) { } catch (err) {
L.error(err); L.error(err);
L.error( 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 break; // Give up on this channel
} }
@@ -347,7 +366,7 @@ async function saveGuildMessageHistory(
const threadChannels = channelBatchMessages const threadChannels = channelBatchMessages
.filter((m) => m.hasThread) .filter((m) => m.hasThread)
.map((m) => m.thread) .map((m) => m.thread)
.filter((c): c is Discord.ThreadChannel => c !== null); .filter((c): c is Discord.AnyThreadChannel => c !== null);
if (threadChannels.length > 0) { if (threadChannels.length > 0) {
L.debug(`Found ${threadChannels.length} threads. Reading into them.`); L.debug(`Found ${threadChannels.length} threads. Reading into them.`);
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
@@ -367,13 +386,13 @@ async function saveGuildMessageHistory(
} catch (err) { } catch (err) {
L.error(err); L.error(err);
L.error( 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 break; // Give up on this thread
} }
L.trace( L.trace(
{ threadMessagesCount: threadBatchMessages.size }, { threadMessagesCount: threadBatchMessages.size },
`Found some thread messages` `Found some thread messages`,
); );
const lastThreadMessage = threadBatchMessages.last(); const lastThreadMessage = threadBatchMessages.last();
allBatchMessages = allBatchMessages.concat(threadBatchMessages); // Add the thread messages to this message batch to be included in later processing 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; lastUpdate = messagesCount;
L.debug( L.debug(
{ messagesCount, pctComplete: currentChannelPercent.value }, { messagesCount, pctComplete: currentChannelPercent.value },
'Sending metrics update' 'Sending metrics update',
); );
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await progressMessage.edit({ await progressMessage.edit({
...updateMessageData, ...updateMessageData,
embeds: [new Discord.MessageEmbed(embedOptions)], embeds: [new Discord.EmbedBuilder(embedOptions)],
}); });
} }
} }
@@ -455,9 +474,9 @@ interface JSONImport {
* Train from an attached JSON file * Train from an attached JSON file
*/ */
async function trainFromAttachmentJson( async function trainFromAttachmentJson(
attachment: Discord.MessageAttachment, attachmentUrl: string,
interaction: Discord.CommandInteraction, interaction: Discord.CommandInteraction,
clean = true clean = true,
): Promise<string> { ): Promise<string> {
if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE; if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE;
if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE; if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE;
@@ -466,8 +485,7 @@ async function trainFromAttachmentJson(
let trainingData: AddDataProps[]; let trainingData: AddDataProps[];
try { try {
const importAttachmentUrl = attachment.attachment.toString(); const getResp = await fetch(attachmentUrl);
const getResp = await fetch(importAttachmentUrl);
if (!getResp.ok) throw new Error(getResp.statusText); if (!getResp.ok) throw new Error(getResp.statusText);
const importData = (await getResp.json()) as JSONImport[]; const importData = (await getResp.json()) as JSONImport[];
@@ -480,7 +498,7 @@ async function trainFromAttachmentJson(
} }
if (datum.attachments?.every((a) => typeof a !== 'string')) { if (datum.attachments?.every((a) => typeof a !== 'string')) {
throw new Error( 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; let custom: MarkovDataCustom | undefined;
@@ -530,7 +548,7 @@ interface GenerateOptions {
*/ */
async function generateResponse( async function generateResponse(
interaction: Discord.Message | Discord.CommandInteraction, interaction: Discord.Message | Discord.CommandInteraction,
options?: GenerateOptions options?: GenerateOptions,
): Promise<GenerateResponse> { ): Promise<GenerateResponse> {
L.debug({ options }, 'Responding...'); L.debug({ options }, 'Responding...');
const { tts = false, debug = false, startSeed } = options || {}; const { tts = false, debug = false, startSeed } = options || {};
@@ -558,7 +576,8 @@ async function generateResponse(
.flatMap((ref) => (ref.custom as MarkovDataCustom).attachments); .flatMap((ref) => (ref.custom as MarkovDataCustom).attachments);
if (attachmentUrls.length > 0) { if (attachmentUrls.length > 0) {
const randomRefAttachment = getRandomElement(attachmentUrls); const randomRefAttachment = getRandomElement(attachmentUrls);
messageOpts.files = [randomRefAttachment]; const refreshedUrl = await refreshCdnUrl(randomRefAttachment);
messageOpts.files = [refreshedUrl];
} else { } else {
const randomMessage = await MarkovInputData.createQueryBuilder< const randomMessage = await MarkovInputData.createQueryBuilder<
MarkovInputData<MarkovDataCustom> MarkovInputData<MarkovDataCustom>
@@ -570,7 +589,9 @@ async function generateResponse(
.getOne(); .getOne();
const randomMessageAttachmentUrls = randomMessage?.custom?.attachments; const randomMessageAttachmentUrls = randomMessage?.custom?.attachments;
if (randomMessageAttachmentUrls?.length) { 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; messageOpts.content = response.string;
@@ -606,52 +627,59 @@ async function listValidChannels(interaction: Discord.CommandInteraction): Promi
} }
function getChannelsFromInteraction( function getChannelsFromInteraction(
interaction: Discord.CommandInteraction interaction: Discord.ChatInputCommandInteraction,
): Discord.TextChannel[] { ): Discord.TextChannel[] {
const channels = Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).map((index) => 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( 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; return textChannels;
} }
function helpMessage(): AgnosticReplyOptions { function helpMessage(): AgnosticReplyOptions {
const avatarURL = client.user.avatarURL() || undefined; const avatarURL = client.user.avatarURL() || undefined;
const embed = new Discord.MessageEmbed() const embed = new Discord.EmbedBuilder()
.setAuthor({ .setAuthor({
name: client.user.username || packageJson().name, name: client.user.username || packageJson().name,
iconURL: avatarURL, iconURL: avatarURL,
}) })
.setThumbnail(avatarURL as string) .setThumbnail(avatarURL as string)
.setDescription( .setDescription(
`A Markov chain chatbot that speaks based on learned messages from previous chat input.` `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.`
) )
.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({ .setFooter({
text: `${packageJson().name} ${getVersion()} by ${ text: `${packageJson().name} ${getVersion()} by ${
(packageJson().author as PackageJsonPerson).name (packageJson().author as PackageJsonPerson).name
@@ -664,13 +692,13 @@ function helpMessage(): AgnosticReplyOptions {
function generateInviteUrl(): string { function generateInviteUrl(): string {
return client.generateInvite({ return client.generateInvite({
scopes: ['bot', 'applications.commands'], scopes: [Discord.OAuth2Scopes.Bot, Discord.OAuth2Scopes.ApplicationsCommands],
permissions: [ permissions: [
'VIEW_CHANNEL', 'ViewChannel',
'SEND_MESSAGES', 'SendMessages',
'SEND_TTS_MESSAGES', 'SendTTSMessages',
'ATTACH_FILES', 'AttachFiles',
'READ_MESSAGE_HISTORY', 'ReadMessageHistory',
], ],
}); });
} }
@@ -678,16 +706,18 @@ function generateInviteUrl(): string {
function inviteMessage(): AgnosticReplyOptions { function inviteMessage(): AgnosticReplyOptions {
const avatarURL = client.user.avatarURL() || undefined; const avatarURL = client.user.avatarURL() || undefined;
const inviteUrl = generateInviteUrl(); const inviteUrl = generateInviteUrl();
const embed = new Discord.MessageEmbed() const embed = new Discord.EmbedBuilder()
.setAuthor({ name: `Invite ${client.user?.username}`, iconURL: avatarURL }) .setAuthor({ name: `Invite ${client.user?.username}`, iconURL: avatarURL })
.setThumbnail(avatarURL as string) .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] }; return { embeds: [embed] };
} }
async function handleResponseMessage( async function handleResponseMessage(
generatedResponse: GenerateResponse, generatedResponse: GenerateResponse,
message: Discord.Message message: Discord.Message,
): Promise<void> { ): Promise<void> {
if (generatedResponse.message) await message.reply(generatedResponse.message); if (generatedResponse.message) await message.reply(generatedResponse.message);
if (generatedResponse.debug) await message.reply(generatedResponse.debug); if (generatedResponse.debug) await message.reply(generatedResponse.debug);
@@ -696,7 +726,7 @@ async function handleResponseMessage(
async function handleUnprivileged( async function handleUnprivileged(
interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction, interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction,
deleteReply = true deleteReply = true,
): Promise<void> { ): Promise<void> {
if (deleteReply) await interaction.deleteReply(); if (deleteReply) await interaction.deleteReply();
await interaction.followUp({ content: INVALID_PERMISSIONS_MESSAGE, ephemeral: true }); await interaction.followUp({ content: INVALID_PERMISSIONS_MESSAGE, ephemeral: true });
@@ -704,7 +734,7 @@ async function handleUnprivileged(
async function handleNoGuild( async function handleNoGuild(
interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction, interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction,
deleteReply = true deleteReply = true,
): Promise<void> { ): Promise<void> {
if (deleteReply) await interaction.deleteReply(); if (deleteReply) await interaction.deleteReply();
await interaction.followUp({ content: INVALID_GUILD_MESSAGE, ephemeral: true }); await interaction.followUp({ content: INVALID_GUILD_MESSAGE, ephemeral: true });
@@ -820,7 +850,7 @@ client.on('threadDelete', async (thread) => {
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
client.on('interactionCreate', async (interaction) => { client.on('interactionCreate', async (interaction) => {
if (interaction.isCommand()) { if (interaction.isChatInputCommand()) {
L.info({ command: interaction.commandName }, 'Recieved slash command'); L.info({ command: interaction.commandName }, 'Recieved slash command');
if (interaction.commandName === helpCommand.name) { if (interaction.commandName === helpCommand.name) {
@@ -869,7 +899,7 @@ client.on('interactionCreate', async (interaction) => {
const channels = getChannelsFromInteraction(interaction); const channels = getChannelsFromInteraction(interaction);
await addValidChannels(channels, interaction.guildId); await addValidChannels(channels, interaction.guildId);
await interaction.editReply( 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') { } else if (subCommand === 'remove') {
if (!isModerator(interaction.member)) { if (!isModerator(interaction.member)) {
@@ -881,7 +911,7 @@ client.on('interactionCreate', async (interaction) => {
const channels = getChannelsFromInteraction(interaction); const channels = getChannelsFromInteraction(interaction);
await removeValidChannels(channels, interaction.guildId); await removeValidChannels(channels, interaction.guildId);
await interaction.editReply( 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') { } else if (subCommand === 'modify') {
if (!interaction.guild) { if (!interaction.guild) {
@@ -892,8 +922,8 @@ client.on('interactionCreate', async (interaction) => {
} }
await interaction.deleteReply(); await interaction.deleteReply();
const dbTextChannels = await getTextChannels(interaction.guild); const dbTextChannels = await getTextChannels(interaction.guild);
const row = new Discord.MessageActionRow().addComponents( const row = new Discord.ActionRowBuilder<Discord.StringSelectMenuBuilder>().addComponents(
new Discord.MessageSelectMenu() new Discord.StringSelectMenuBuilder()
.setCustomId('listen-modify-select') .setCustomId('listen-modify-select')
.setPlaceholder('Nothing selected') .setPlaceholder('Nothing selected')
.setMinValues(0) .setMinValues(0)
@@ -903,8 +933,8 @@ client.on('interactionCreate', async (interaction) => {
label: `#${c.name}` || c.id, label: `#${c.name}` || c.id,
value: c.id, value: c.id,
default: c.listen || false, default: c.listen || false,
})) })),
) ),
); );
await interaction.followUp({ await interaction.followUp({
@@ -919,7 +949,7 @@ client.on('interactionCreate', async (interaction) => {
const trainingJSON = interaction.options.getAttachment('json'); const trainingJSON = interaction.options.getAttachment('json');
if (trainingJSON) { if (trainingJSON) {
const responseMessage = await trainFromAttachmentJson(trainingJSON, interaction, clean); const responseMessage = await trainFromAttachmentJson(trainingJSON.url, interaction, clean);
await interaction.followUp(responseMessage); await interaction.followUp(responseMessage);
} else { } else {
const reply = (await interaction.fetchReply()) as Discord.Message; // Must fetch the reply ASAP 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 }); await reply.reply({ content: responseMessage });
} }
} }
} else if (interaction.isSelectMenu()) { } else if (interaction.isStringSelectMenu()) {
if (interaction.customId === 'listen-modify-select') { if (interaction.customId === 'listen-modify-select') {
await interaction.deferUpdate(); await interaction.deferUpdate();
const { guild } = interaction; const { guild } = interaction;
@@ -940,14 +970,15 @@ client.on('interactionCreate', async (interaction) => {
} }
const allChannels = 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 selectedChannelIds = interaction.values;
const textChannels = ( const textChannels = (
await Promise.all( await Promise.all(
allChannels.map(async (c) => { allChannels.map(async (c) => {
return guild.channels.fetch(c); return guild.channels.fetch(c);
}) }),
) )
).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel); ).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel);
const unselectedChannels = textChannels.filter((t) => !selectedChannelIds.includes(t.id)); const unselectedChannels = textChannels.filter((t) => !selectedChannelIds.includes(t.id));

View File

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