Merge pull request #24 from claabs/develop

Version 2.0.0
This commit is contained in:
Charlie Laabs
2022-01-10 20:53:42 -06:00
committed by GitHub
33 changed files with 11216 additions and 1910 deletions

View File

@@ -1,7 +1,5 @@
# Ignore everything config
**/* dist
node_modules
# Allow files and directories img
!*.js docs
!*.json
!*.ts

View File

@@ -6,7 +6,6 @@ module.exports = {
extends: [ extends: [
'airbnb-base', 'airbnb-base',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:prettier/recommended', 'plugin:prettier/recommended',
], ],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
@@ -15,6 +14,12 @@ module.exports = {
sourceType: 'module' sourceType: 'module'
}, },
plugins: ['@typescript-eslint'], plugins: ['@typescript-eslint'],
rules: {
'import/extensions': 0,
'import/prefer-default-export': 0,
'no-shadow': 'off',
'@typescript-eslint/no-shadow': ['error'],
},
settings: { settings: {
'import/extensions': ['.js', '.ts',], 'import/extensions': ['.js', '.ts',],
'import/parsers': { 'import/parsers': {
@@ -25,5 +30,6 @@ module.exports = {
extensions: ['.js', '.ts',] extensions: ['.js', '.ts',]
} }
} }
} },
ignorePatterns: ['dist/**', 'node_modules/**', '.eslintrc.js', 'src/migration/**']
} }

View File

@@ -1,21 +1,27 @@
name: Build and push image name: MultiArchDockerBuild
on: on:
push: push:
branches: branches:
- master - master
- develop
jobs: jobs:
build_and_push_image: build_multi_arch_image:
name: Build Docker image and push to registries name: Build multi-arch Docker image.
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- 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@v1
with:
install: true
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
@@ -30,15 +36,32 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push master
id: docker_build_push if: ${{ github.ref == 'refs/heads/master' }}
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
target: deploy
push: true push: true
tags: | tags: |
charlocharlie/markov-discord:latest charlocharlie/markov-discord:latest
ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }} platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
build-args: |
COMMIT_SHA=${{ github.sha }}
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
- name: Image digest - name: Build and push dev
run: echo ${{ steps.docker_build_push.outputs.digest }} if: ${{ github.ref == 'refs/heads/develop' }}
uses: docker/build-push-action@v2
with:
target: deploy
push: true
tags: |
charlocharlie/markov-discord:dev
ghcr.io/claabs/markov-discord:dev
platforms: linux/amd64
build-args: |
COMMIT_SHA=${{ github.sha }}
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }}

33
.github/workflows/typedoc.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Publish Typedoc to Github Pages
on:
push:
branches:
- master
paths:
- Readme.md
- src/config/classes.ts
- .github/workflows/typedoc.yml
- package.json
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js for use with actions
uses: actions/setup-node@v2
with:
node-version: 'lts/*'
cache: 'npm'
- name: NPM install
run: npm ci
# Runs a single command using the runners shell
- name: Build and lint
run: npm run docs
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs

3
.gitignore vendored
View File

@@ -4,6 +4,7 @@ logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
docs
# Runtime data # Runtime data
pids pids
@@ -65,3 +66,5 @@ config.json
# error output file # error output file
error.json error.json
markovDB.json markovDB.json
/config/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
16

View File

@@ -1,5 +1,4 @@
module.exports = { module.exports = {
printWidth: 100, printWidth: 100,
singleQuote: true, singleQuote: true,
trailingComma: 'es5'
} }

5
.vscode/launch.json vendored
View File

@@ -13,8 +13,9 @@
"ts-node/register" "ts-node/register"
], ],
"args": [ "args": [
"${workspaceFolder}/index.ts" "${workspaceFolder}/src/index.ts"
] ],
"outputCapture": "std",
} }
] ]
} }

14
.vscode/settings.json vendored
View File

@@ -13,6 +13,16 @@
"[typescript]": { "[typescript]": {
"editor.formatOnSave": false, "editor.formatOnSave": false,
}, },
"eslint.enable": true, "[json]": {
"typescript.tsdk": "node_modules\\typescript\\lib", "files.insertFinalNewline": true
},
"typescript.tsdk": "node_modules/typescript/lib",
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"name": "Local SQLite",
"database": "./config/db/db.sqlite3"
}
],
} }

101
CHANGELOG.md Normal file
View File

@@ -0,0 +1,101 @@
# Changelog
All notable changes to this project will be documented in this file.
## Versions
### 2.0.0
#### Breaking Changes
* Config option `prefix` renamed to `messageCommandPrefix`
* Config option `game` renamed to `activity`
* Config option `role` renamed to `userRoleIds`. Changed from string to array of strings.
* Docker internal volume path moved from `/usr/src/markbot/config` to `/usr/app/config`
* Database changed from JSON files to a SQLite database. You'll need to re-train the bot to use it again.
* The bot must be explicitly granted permission to listen to a list of channels before using it. Configure it with `/listen`.
* Docker user changed from `root` to `node`. You may need to update your mounted volume's permissions.
* pm2 has been removed from the Docker container. Make sure to add `--restart=unless-stopped` to your Docker run config to ensure the same resiliency.
#### New Features
* Data is stored in a relational database to reduce memory and disk read/write usage, as well as to decrease latency
* The bot can be restricted to only learn/listen from a strict list of channels
* Bot responses can be seeded by a short phrase
* Discord slash command support
* Discord thread support
* Many new config options available at <https://claabs.github.io/markov-discord/classes/AppConfig.html>
* Owner IDs
* Log level
* Slash command name
* Config file supports [JSON5](https://json5.org/) (comments, trailing commas, etc). It also may use the `.json5` file extension if you prefer.
* Generated responses will now never ping a user or role, only just highlight their name
### 0.7.3
* Fix crash when fetched messages is empty
* Update docs
* Update dependencies
### 0.7.2
* Fix @everyone replacement
### 0.7.1
* Readme updates
* Config loading fix
* Fix min score
* Add generator options to config
* Document Node 12 update
### 0.7.0
* Convert project to Typescript
* Optimize Docker build (smaller image)
* Load corpus from filesystem to reduce memory load
### 0.6.2
* Fix MarkovDB not loading on boot
### 0.6.1
* Fix bot crashing on scheduled regen
### 0.6.0
* Added Docker deploy functionality.
* Moved config and database to `./config` directory. Existing configs will be migrated.
* Config-less support via bot token located in an environment variable.
* Update dependencies.
* Change corpus regen time to 4 AM.
### 0.5.0
* Fixed bug where `!mark help` didn't work.
* Only admins can train.
* The bot responds when mentioned.
* The bot cannot mention @everyone.
* Added version number to help.
* Added `!mark tts` for a quieter TTS response.
* Readme overhaul.
* Simpler config loading.
### 0.4.0
* Huge refactor.
* Added `!mark debug` which sends debug info alongside the message.
* Converted the fetchMessages function to async/await (updating the requirement to Node.js 8).
* Updated module versions.
* Added faster unique-array-by-property function
* Added linting and linted the project.
### 0.3.0
* Added TTS support and random message attachments.
* Deleted messages no longer persist in the database longer than 24 hours.
### 0.2.0
* Updated training algorithm and data structure.

View File

@@ -1,49 +1,55 @@
######## ########
# BASE # BASE
######## ########
FROM keymetrics/pm2:12-alpine as base FROM node:16-alpine3.15 as base
WORKDIR /usr/src/markbot
COPY package*.json ./ WORKDIR /usr/app
# Install build tools for erlpack, then install prod deps only, then remove build tools
RUN apk add --no-cache make gcc g++ python && \ RUN apk add --no-cache tini
npm ci --only=production && \
apk del make gcc g++ python
######## ########
# BUILD # BUILD
######## ########
FROM base as build FROM base as build
# Copy all *.json, *.js, *.ts COPY package*.json ./
COPY . . # Install build tools for erlpack, then install prod deps only
# Prod deps already installed, add dev deps RUN apk add --no-cache make gcc g++ python3 \
RUN npm i && npm ci --only=production
# Copy all jsons
COPY package*.json tsconfig.json ./
# Add dev deps
RUN npm ci
# Copy source code
COPY src src
RUN npm run build RUN npm run build
######## ########
# DEPLOY # DEPLOY
######## ########
FROM keymetrics/pm2:12-alpine as deploy FROM base as deploy
WORKDIR /usr/src/markbot
ENV NPM_CONFIG_LOGLEVEL warn USER node
# Steal node_modules from base image # Steal node_modules from base image
COPY --from=base /usr/src/markbot/node_modules ./node_modules/ COPY --from=build /usr/app/node_modules node_modules
# Steal compiled code from build image # Steal compiled code from build image
COPY --from=build /usr/src/markbot/dist ./ COPY --from=build /usr/app/dist dist
# Copy package.json for version number # Copy package.json for version number
COPY package*.json ./ COPY package*.json ormconfig.js ./
# Copy PM2 config # RUN mkdir config
COPY ecosystem.config.js .
RUN mkdir config ARG COMMIT_SHA=""
# RUN ls -al ENV NODE_ENV=production \
COMMIT_SHA=${COMMIT_SHA}
CMD [ "pm2-runtime", "start", "ecosystem.config.js" ] ENTRYPOINT ["/sbin/tini", "--"]
CMD [ "node", "/usr/app/dist/index.js" ]

219
README.md
View File

@@ -4,214 +4,65 @@ A Markov chain bot using markov-strings.
## Usage ## Usage
1. Configure what channels you want the bot to listen/learn from:
* User: `/listen modify`
* Bot: ![Select which channels your would like the bot to actively listen to](img/listen.png)
1. Train the bot in a lengthy text channel: 1. Train the bot in a lengthy text channel:
* User: `!mark train` * User: `/train`
* Markbot: `@User, Finished training from past 76394 messages.` * Bot: ![Parsing past messages from 5 channel(s).](img/train.png)
1. Ask the bot to say something: 1. Ask the bot to say something:
* User: `!mark` * User: `/mark`
* Markbot: `This Shopko has a Linux release` * Bot: ![worms are not baby snakes, by the way](img/respond.png)
## Setup ## Setup
First, create a [Discord bot application](https://discordapp.com/developers/applications/). This bot stores your Discord server's entire message history, so a public instance to invite to your server is not available due to obvious data privacy concerns. Instead, you can host it yourself.
1. Create a [Discord bot application](https://discordapp.com/developers/applications/)
1. Under the "Bot" section, enable the "Message Content Intent", and copy the token for later.
1. Setup and configure the bot using one of the below methods:
### Docker
Running this bot in Docker is the easiest way to ensure it runs as expected and can easily recieve updates.
1. [Install Docker for your OS](https://docs.docker.com/get-docker/)
1. Open a command prompt and run:
```sh
docker run --restart unless-stopped --rm -ti -v /my/host/dir:/usr/app/config ghcr.io/claabs/markov-discord:latest
```
Where `/my/host/dir` is a accessible path on your system. `--restart=unless-stopped` is recommended in case an unexpected error crashes the bot.
1. The Docker container will create a default config file in your mounted volume (`/my/host/dir`). Open it and add your bot token. You may change any other values to your liking as well. Details for each configuration item can be found here: <https://claabs.github.io/markov-discord/classes/AppConfig.html>
1. Run the container again and use the invite link printed to the logs.
### Windows ### Windows
#### Windows Requirements 1. Install [Node.js 16 or newer](https://nodejs.org/en/download/).
* [Node.js 12.0+ (Current)](https://nodejs.org/en/download/)
* Installing with build tools is recommended
#### Windows Setup
1. Install Node.js 12.0 or newer.
1. Download this repository using git in a command prompt 1. Download this repository using git in a command prompt
```cmd ```cmd
git clone https://github.com/charlocharlie/markov-discord.git git clone https://github.com/claabs/markov-discord.git
``` ```
or by just downloading and extracting the [project zip](https://github.com/charlocharlie/markov-discord/archive/master.zip) from GitHub. or by just downloading and extracting the [project zip](https://github.com/claabs/markov-discord/archive/master.zip) from GitHub.
1. Open a command prompt in the `markov-discord` folder. 1. Open a command prompt in the `markov-discord` folder.
```sh ```sh
# Install Windows build tools (if you didn't install build tools with Node)
npm install --global windows-build-tools
# NPM install non-development packages # NPM install non-development packages
npm install npm ci
# Build the Typescript # Build the Typescript
npm run build npm run build
# Initialize the config
npm start
``` ```
1. Create a file called `config.json` in the project directory with the contents: 1. The program will create a `config/config.json` in the project folder. Open it and add your bot token. You may change any other values to your liking as well. Details for each configuration item can be found here: <https://claabs.github.io/markov-discord/classes/AppConfig.html>
```json
{
"prefix":"!mark",
"game":"\"!mark help\" for help",
"token":"k5NzE2NDg1MTIwMjc0ODQ0Nj.DSnXwg.ttNotARealToken5p3WfDoUxhiH"
}
```
Feel free to change the command prefix, game display. Add your bot token.
1. Run the bot: 1. Run the bot:
```sh ```sh
npm start npm start
``` ```
### Debian Linux And use the invite link printed to the logs.
#### Debian Requirements
* Node.js 12.0+
* Python 2.7 (for erlpack)
* C++ build tools (for erlpack)
#### Download
```sh
# Clone this repository
git clone https://github.com/charlocharlie/markov-discord.git
cd markov-discord
```
#### Configure
Create a file called `config.json` in the project directory with the contents:
```json
{
"prefix":"!mark",
"game":"\"!mark help\" for help",
"token":"k5NzE2NDg1MTIwMjc0ODQ0Nj.DSnXwg.ttNotARealToken5p3WfDoUxhiH",
"role": "Bot users"
}
```
Feel free to change the command prefix, game display. Add your bot token. Role is optional, if it is set, only the users who have that role can use text generation.
#### Install and Run
```sh
# Install Node.js if you haven't already
wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
nvm install node
# NPM install non-development packages
npm install
# If you run into build errors, install the following packages:
sudo apt-get install python -y
sudo apt-get install build-essential -y
# Build the Typescript
npm run build
# Start the program
npm start
```
### Docker
#### Setup with Docker Hub image
1. Install Docker for your OS.
1. Open a command prompts and run:
```sh
docker pull charlocharlie/markov-discord
docker run --rm -d charlocharlie/markov-discord:latest
```
#### Setup with source
1. Install Docker for your OS.
1. Download this repository using git in a command prompt
```sh
git clone https://github.com/charlocharlie/markov-discord.git
```
or by just downloading and extracting the [project zip](https://github.com/charlocharlie/markov-discord/archive/master.zip) from GitHub.
1. Open a command prompt in the markov-discord folder and run this one-liner:
```sh
docker run --rm -e TOKEN=YOUR.BOT.TOKEN -v config:/usr/src/markbot/config -it $(docker build -q .)
# Be patient as the build output is suppressed
```
## Changelog
### 0.7.3
* Fix crash when fetched messages is empty
* Update docs
* Update dependencies
### 0.7.2
* Fix @everyone replacement
### 0.7.1
* Readme updates
* Config loading fix
* Fix min score
* Add generator options to config
* Document Node 12 update
### 0.7.0
* Convert project to Typescript
* Optimize Docker build (smaller image)
* Load corpus from filesystem to reduce memory load
### 0.6.2
* Fix MarkovDB not loading on boot
### 0.6.1
* Fix bot crashing on scheduled regen
### 0.6.0
* Added Docker deploy functionality.
* Moved config and database to `./config` directory. Existing configs will be migrated.
* Config-less support via bot token located in an environment variable.
* Update dependencies.
* Change corpus regen time to 4 AM.
### 0.5.0
* Fixed bug where `!mark help` didn't work.
* Only admins can train.
* The bot responds when mentioned.
* The bot cannot mention @everyone.
* Added version number to help.
* Added `!mark tts` for a quieter TTS response.
* Readme overhaul.
* Simpler config loading.
### 0.4.0
* Huge refactor.
* Added `!mark debug` which sends debug info alongside the message.
* Converted the fetchMessages function to async/await (updating the requirement to Node.js 8).
* Updated module versions.
* Added faster unique-array-by-property function
* Added linting and linted the project.
### 0.3.0
* Added TTS support and random message attachments.
* Deleted messages no longer persist in the database longer than 24 hours.
### 0.2.0
* Updated training algorithm and data structure.
## Thanks
Thanks to [BotMaker-for-Discord](https://github.com/CorySanin/BotMaker-for-Discord) which I used as a reference when during development.

4
config/.gitignore vendored
View File

@@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@@ -1,12 +0,0 @@
module.exports = {
apps: [{
name: 'markbot',
script: './index.js',
env: {
NODE_ENV: 'development',
},
env_production: {
NODE_ENV: 'production',
},
}],
};

BIN
img/listen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
img/respond.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
img/train.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

431
index.ts
View File

@@ -1,431 +0,0 @@
/* eslint-disable no-console */
import 'source-map-support/register';
import * as Discord from 'discord.js';
// https://discord.js.org/#/docs/main/stable/general/welcome
import * as fs from 'fs';
import Markov, {
MarkovGenerateOptions,
MarkovResult,
MarkovConstructorOptions,
MarkovImportExport,
} from 'markov-strings';
import * as schedule from 'node-schedule';
interface MessageRecord {
id: string;
string: string;
attachment?: string;
}
interface MarkbotMarkovResult extends MarkovResult {
refs: Array<MessageRecord>;
}
interface MessagesDB {
messages: MessageRecord[];
}
interface MarkbotConfig {
stateSize?: number;
minScore?: number;
maxTries?: number;
prefix?: string;
game?: string;
token?: string;
role?: string;
}
const version: string = JSON.parse(fs.readFileSync('./package.json', 'utf8')).version || '0.0.0';
const client = new Discord.Client();
// const ZEROWIDTH_SPACE = String.fromCharCode(parseInt('200B', 16));
// const MAXMESSAGELENGTH = 2000;
const PAGE_SIZE = 100;
// let guilds = [];
// let connected = -1;
let GAME = '!mark help';
let ROLE: string | null;
let PREFIX = '!mark';
let STATE_SIZE = 2; // Value of 1 to 3, based on corpus quality
let MAX_TRIES = 1000;
let MIN_SCORE = 10;
const inviteCmd = 'invite';
const errors: string[] = [];
let fileObj: MessagesDB = {
messages: [],
};
let markovDB: MessageRecord[] = [];
let messageCache: MessageRecord[] = [];
let deletionCache: string[] = [];
let markovOpts: MarkovConstructorOptions = {
stateSize: STATE_SIZE,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function uniqueBy<Record extends { [key: string]: any }>(
arr: Record[],
propertyName: keyof Record
): Record[] {
const unique: Record[] = [];
const found: { [key: string]: boolean } = {};
for (let i = 0; i < arr.length; i += 1) {
if (arr[i][propertyName]) {
const value = arr[i][propertyName];
if (!found[value]) {
found[value] = true;
unique.push(arr[i]);
}
}
}
return unique;
}
/**
* Regenerates the corpus and saves all cached changes to disk
*/
function regenMarkov(): void {
console.log('Regenerating Markov corpus...');
try {
fileObj = JSON.parse(fs.readFileSync('config/markovDB.json', 'utf8'));
} catch (err) {
console.log('No markovDB.json, starting with initial values');
fileObj = {
messages: [
{
id: '0',
string: '',
},
],
};
}
// console.log("MessageCache", messageCache)
markovDB = fileObj.messages;
markovDB = uniqueBy<MessageRecord>(markovDB.concat(messageCache), 'id');
deletionCache.forEach(id => {
const removeIndex = markovDB.map(item => item.id).indexOf(id);
// console.log('Remove Index:', removeIndex)
markovDB.splice(removeIndex, 1);
});
deletionCache = [];
const markov = new Markov(markovOpts);
fileObj.messages = markovDB;
// console.log("WRITING THE FOLLOWING DATA:")
// console.log(fileObj)
fs.writeFileSync('config/markovDB.json', JSON.stringify(fileObj), 'utf-8');
fileObj.messages = [];
messageCache = [];
markov.addData(markovDB);
fs.writeFileSync('config/markov.json', JSON.stringify(markov.export()));
console.log('Done regenerating Markov corpus.');
}
/**
* Loads the config settings from disk
*/
function loadConfig(): void {
// Move config if in legacy location
if (fs.existsSync('./config.json')) {
console.log('Copying config.json to new location in ./config');
fs.renameSync('./config.json', './config/config.json');
}
if (fs.existsSync('./markovDB.json')) {
console.log('Copying markovDB.json to new location in ./config');
fs.renameSync('./markovDB.json', './config/markovDB.json');
}
let token = 'missing';
try {
const cfg: MarkbotConfig = JSON.parse(fs.readFileSync('./config/config.json', 'utf8'));
PREFIX = cfg.prefix || '!mark';
GAME = cfg.game || '!mark help';
token = cfg.token || process.env.TOKEN || token;
STATE_SIZE = cfg.stateSize || STATE_SIZE;
MIN_SCORE = cfg.minScore || MIN_SCORE;
MAX_TRIES = cfg.maxTries || MAX_TRIES;
ROLE = cfg.role || null;
} catch (e) {
console.warn('Failed to read config.json.');
token = process.env.TOKEN || token;
}
try {
client.login(token);
} catch (e) {
console.error('Failed to login with token:', token);
}
markovOpts = {
stateSize: STATE_SIZE,
};
}
/**
* Checks if the author of a message as moderator-like permissions.
* @param {GuildMember} member Sender of the message
* @return {Boolean} True if the sender is a moderator.
*/
function isModerator(member: Discord.GuildMember): boolean {
return (
member.hasPermission('ADMINISTRATOR') ||
member.hasPermission('MANAGE_CHANNELS') ||
member.hasPermission('KICK_MEMBERS') ||
member.hasPermission('MOVE_MEMBERS') ||
member.id === '82684276755136512' // charlocharlie#8095
);
}
/**
* 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): string | null {
const messageText = message.content.toLowerCase();
let command = null;
const thisPrefix = messageText.substring(0, PREFIX.length);
if (thisPrefix === PREFIX) {
const split = messageText.split(' ');
if (split[0] === PREFIX && split.length === 1) {
command = 'respond';
} else if (split[1] === 'train') {
command = 'train';
} else if (split[1] === 'help') {
command = 'help';
} else if (split[1] === 'regen') {
command = 'regen';
} else if (split[1] === 'invite') {
command = 'invite';
} else if (split[1] === 'debug') {
command = 'debug';
} else if (split[1] === 'tts') {
command = 'tts';
}
}
return command;
}
/**
* Function to recursively get all messages in a text channel's history. Ends
* by regnerating the corpus.
* @param {Message} message Message initiating the command, used for getting
* channel data
*/
async function fetchMessages(message: Discord.Message): Promise<void> {
let historyCache: MessageRecord[] = [];
let keepGoing = true;
let oldestMessageID: string | undefined;
while (keepGoing) {
const messages: Discord.Collection<
string,
Discord.Message
// eslint-disable-next-line no-await-in-loop
> = await message.channel.messages.fetch({
before: oldestMessageID,
limit: PAGE_SIZE,
});
const nonBotMessageFormatted = messages
.filter(elem => !elem.author.bot)
.map(elem => {
const dbObj: MessageRecord = {
string: elem.content,
id: elem.id,
};
if (elem.attachments.size > 0) {
dbObj.attachment = elem.attachments.values().next().value.url;
}
return dbObj;
});
historyCache = historyCache.concat(nonBotMessageFormatted);
const lastMessage = messages.last();
if (!lastMessage || messages.size < PAGE_SIZE) {
keepGoing = false;
} else {
oldestMessageID = lastMessage.id;
}
}
console.log(`Trained from ${historyCache.length} past human authored messages.`);
messageCache = messageCache.concat(historyCache);
regenMarkov();
message.reply(`Finished training from past ${historyCache.length} messages.`);
}
/**
* General Markov-chain response function
* @param {Message} message The message that invoked the action, used for channel info.
* @param {Boolean} debug Sends debug info as a message if true.
* @param {Boolean} tts If the message should be sent as TTS. Defaults to the TTS setting of the
* invoking message.
*/
function generateResponse(message: Discord.Message, debug = false, tts = message.tts): void {
console.log('Responding...');
const options: MarkovGenerateOptions = {
filter: (result): boolean => {
return result.score >= MIN_SCORE;
},
maxTries: MAX_TRIES,
};
const fsMarkov = new Markov();
const markovFile = JSON.parse(
fs.readFileSync('config/markov.json', 'utf-8')
) as MarkovImportExport;
fsMarkov.import(markovFile);
try {
const myResult = fsMarkov.generate(options) as MarkbotMarkovResult;
console.log('Generated Result:', myResult);
const messageOpts: Discord.MessageOptions = { tts };
const attachmentRefs = myResult.refs
.filter(ref => Object.prototype.hasOwnProperty.call(ref, 'attachment'))
.map(ref => ref.attachment as string);
if (attachmentRefs.length > 0) {
const randomRefAttachment = attachmentRefs[Math.floor(Math.random() * attachmentRefs.length)];
messageOpts.files = [randomRefAttachment];
} else {
const randomMessage = markovDB[Math.floor(Math.random() * markovDB.length)];
if (randomMessage.attachment) {
messageOpts.files = [{ attachment: randomMessage.attachment }];
}
}
myResult.string = myResult.string.replace(/@everyone/g, '@everyοne'); // Replace @everyone with a homoglyph 'o'
message.channel.send(myResult.string, messageOpts);
if (debug) message.channel.send(`\`\`\`\n${JSON.stringify(myResult, null, 2)}\n\`\`\``);
} catch (err) {
console.log(err);
if (debug) message.channel.send(`\n\`\`\`\nERROR: ${err}\n\`\`\``);
if (err.message.includes('Cannot build sentence with current corpus')) {
console.log('Not enough chat data for a response.');
}
}
}
client.on('ready', () => {
console.log('Markbot by Charlie Laabs');
if (client.user) client.user.setActivity(GAME);
regenMarkov();
});
client.on('error', err => {
const errText = `ERROR: ${err.name} - ${err.message}`;
console.log(errText);
errors.push(errText);
fs.writeFile('./config/error.json', JSON.stringify(errors), fsErr => {
if (fsErr) {
console.log(`error writing to error file: ${fsErr.message}`);
}
});
});
client.on('message', message => {
if (message.guild) {
const command = validateMessage(message);
if (command === 'help') {
const avatarURL = client.user?.avatarURL() || undefined;
const richem = new Discord.MessageEmbed()
.setAuthor(client.user?.username, avatarURL)
.setThumbnail(avatarURL as string)
.setDescription('A Markov chain chatbot that speaks based on previous chat input.')
.addField(
'!mark',
'Generates a sentence to say based on the chat database. Send your ' +
'message as TTS to recieve it as TTS.'
)
.addField(
'!mark train',
'Fetches the maximum amount of previous messages in the current ' +
'text channel, adds it to the database, and regenerates the corpus. Takes some time.'
)
.addField(
'!mark regen',
'Manually regenerates the corpus to add recent chat info. Run ' +
'this before shutting down to avoid any data loss. This automatically runs at midnight.'
)
.addField(
'!mark invite',
"Don't invite this bot to other servers. The database is shared " +
'between all servers and text channels.'
)
.addField('!mark debug', 'Runs the !mark command and follows it up with debug info.')
.setFooter(`Markov Discord v${version} by Charlie Laabs`);
message.channel.send(richem).catch(() => {
message.author.send(richem);
});
}
if (command === 'train') {
if (message.member && isModerator(message.member)) {
console.log('Training...');
fileObj = {
messages: [],
};
fs.writeFileSync('config/markovDB.json', JSON.stringify(fileObj), 'utf-8');
fetchMessages(message);
}
}
if (command === 'respond') {
let send = true;
if (ROLE != null) {
let roles = message.member?.roles.cache.map(role => role.name);
send = roles?.includes(ROLE) || false;
}
if (send) {
generateResponse(message);
}
}
if (command === 'tts') {
generateResponse(message, false, true);
}
if (command === 'debug') {
generateResponse(message, true);
}
if (command === 'regen') {
regenMarkov();
}
if (command === null) {
console.log('Listening...');
if (!message.author.bot) {
const dbObj: MessageRecord = {
string: message.content,
id: message.id,
};
if (message.attachments.size > 0) {
dbObj.attachment = message.attachments.values().next().value.url;
}
messageCache.push(dbObj);
if (client.user && message.mentions.has(client.user)) {
generateResponse(message);
}
}
}
if (command === inviteCmd) {
const avatarURL = client.user?.avatarURL() || undefined;
const richem = new Discord.MessageEmbed()
.setAuthor(`Invite ${client.user?.username}`, avatarURL)
.setThumbnail(avatarURL as string)
.addField(
'Invite',
`[Invite ${client.user?.username} to your server](https://discordapp.com/oauth2/authorize?client_id=${client.user?.id}&scope=bot)`
);
message.channel.send(richem).catch(() => {
message.author.send(richem);
});
}
}
});
client.on('messageDelete', message => {
// console.log('Adding message ' + message.id + ' to deletion cache.')
deletionCache.push(message.id);
console.log('deletionCache:', deletionCache);
});
loadConfig();
schedule.scheduleJob('0 4 * * *', () => regenMarkov());

39
ormconfig.js Normal file
View File

@@ -0,0 +1,39 @@
const devConfig = {
type: 'better-sqlite3',
database: process.env.CONFIG_DIR
? `${process.env.CONFIG_DIR}/db/db.sqlite3`
: 'config/db/db.sqlite3',
synchronize: true,
migrationsRun: false,
// logging: 'all',
enableWAL: true,
entities: ['src/entity/**/*.ts'],
migrations: ['src/migration/**/*.ts'],
subscribers: ['src/subscriber/**/*.ts'],
cli: {
entitiesDir: 'src/entity',
migrationsDir: 'src/migration',
},
};
const prodConfig = {
type: 'better-sqlite3',
database: process.env.CONFIG_DIR
? `${process.env.CONFIG_DIR}/db/db.sqlite3`
: 'config/db/db.sqlite3',
synchronize: false,
logging: false,
enableWAL: true,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'],
migrationsRun: true,
subscribers: ['dist/subscriber/**/*.js'],
cli: {
entitiesDir: 'dist/entity',
migrationsDir: 'dist/migration',
},
};
const finalConfig = process.env.NODE_ENV !== 'production' ? devConfig : prodConfig;
module.exports = finalConfig;

11709
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,19 @@
{ {
"name": "markbot", "name": "markov-discord",
"version": "0.7.3", "version": "2.0.0",
"description": "A conversational Markov chain bot for Discord", "description": "A conversational Markov chain bot for Discord",
"main": "index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"start": "node dist/index.js", "start": "NODE_ENV=production pm2 start --no-daemon dist/index.js",
"start:ts": "ts-node index.ts", "start:ts": "ts-node src/index.ts",
"build": "tsc", "build": "rimraf dist && tsc",
"lint": "tsc --noEmit && eslint **/*.ts", "lint": "tsc --noEmit && eslint **/*.ts *.js",
"docker-up": "docker run --rm -e TOKEN=abc123 -it $(docker build -q .)" "docker:build": "docker build . -t charlocharlie/markov-discord:latest --target deploy",
"docker:run": "docker run --rm -ti -v $(pwd)/config:/usr/app/config charlocharlie/markov-discord:latest",
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
"docs": "typedoc --out docs src/config/classes.ts"
}, },
"repository": "https://github.com/charlocharlie/markov-discord.git", "repository": "https://github.com/claabs/markov-discord.git",
"keywords": [ "keywords": [
"discord", "discord",
"markov", "markov",
@@ -22,36 +25,55 @@
"markov-chain-bot", "markov-chain-bot",
"docker" "docker"
], ],
"author": "Charlie Laabs <charlielaabs@gmail.com>", "author": {
"name": "Charlie Laabs",
"url": "https://github.com/claabs"
},
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bufferutil": "^4.0.1", "@discordjs/builders": "^0.11.0",
"discord.js": "^12.3.1", "@discordjs/rest": "^0.2.0-canary.0",
"erlpack": "^0.1.3", "@types/fs-extra": "^9.0.13",
"markov-strings": "^3.0.1", "better-sqlite3": "^7.4.6",
"node-schedule": "^1.3.2", "bufferutil": "^4.0.6",
"source-map-support": "^0.5.16", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"date-fns": "^2.28.0",
"discord-api-types": "^0.26.1",
"discord.js": "^13.5.1",
"dotenv": "^10.0.0",
"erlpack": "github:discord/erlpack",
"fs-extra": "^10.0.0",
"json5": "^2.2.0",
"markov-strings-db": "^4.1.4",
"pino": "^7.6.2",
"pino-pretty": "^7.3.0",
"reflect-metadata": "^0.1.13",
"simple-eta": "^3.0.2",
"source-map-support": "^0.5.21",
"typeorm": "^0.2.41",
"utf-8-validate": "^5.0.8",
"zlib-sync": "^0.1.7" "zlib-sync": "^0.1.7"
}, },
"engines": {
"node": ">=12.0.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^12.12.21", "@types/node": "^16.11.19",
"@types/node-schedule": "^1.3.0", "@types/validator": "^13.7.1",
"@types/ws": "^7.2.7", "@typescript-eslint/eslint-plugin": "^5.9.0",
"@typescript-eslint/eslint-plugin": "^2.13.0", "@typescript-eslint/parser": "^5.9.0",
"@typescript-eslint/parser": "^2.13.0", "eslint": "^8.6.0",
"eslint": "^6.8.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-base": "^14.0.0", "eslint-config-prettier": "^8.3.0",
"eslint-config-prettier": "^6.7.0", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-import": "^2.19.1", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-prettier": "^3.1.2", "pm2": "^5.1.2",
"prettier": "^1.19.1", "prettier": "^2.5.1",
"ts-node": "^8.5.4", "rimraf": "^3.0.2",
"typescript": "^3.7.4" "ts-node": "^10.4.0",
"typedoc": "^0.22.10",
"types-package-json": "^2.0.39",
"typescript": "^4.5.4"
}, },
"eslintIgnore": [ "engines": {
"**/*.js" "node": "16"
] }
} }

166
src/config/classes.ts Normal file
View File

@@ -0,0 +1,166 @@
/* eslint-disable @typescript-eslint/no-empty-function, no-useless-constructor, max-classes-per-file */
import 'reflect-metadata';
import { Type } from 'class-transformer';
import {
IsString,
IsOptional,
IsEnum,
IsArray,
IsInt,
IsDefined,
IsNotEmpty,
} from 'class-validator';
export enum LogLevel {
SILENT = 'silent',
ERROR = 'error',
WARN = 'warn',
INFO = 'info',
DEBUG = 'debug',
TRACE = 'trace',
}
/**
* The config file supports [JSON5](https://json5.org/) syntax. It supports both `.json` and `.json5` extensions if you prefer one over the other.
* @example ```jsonc
* {
* "token": "k5NzE2NDg1MTIwMjc0ODQ0Nj.DSnXwg.ttNotARealToken5p3WfDoUxhiH",
* "commandPrefix": "!mark",
* "activity": "\"!mark help\" for help",
* "ownerIds": ["00000000000000000"],
* "logLevel": "info",
* }
* ```
*/
export class AppConfig {
/**
* Your Discord bot token
* @example k5NzE2NDg1MTIwMjc0ODQ0Nj.DSnXwg.ttNotARealToken5p3WfDoUxhiH
* @env TOKEN
*/
@IsDefined()
@IsString()
@IsNotEmpty()
token = process.env.TOKEN || '';
/**
* The command prefix used to trigger the bot commands (when not using slash commands)
* @example !bot
* @default !mark
* @env MESSAGE_COMMAND_PREFIX
*/
@IsOptional()
@IsString()
messageCommandPrefix = process.env.MESSAGE_COMMAND_PREFIX || '!mark';
/**
* The slash command name to generate a message from the bot. (e.g. `/mark`)
* @example message
* @default mark
* @env SLASH_COMMAND_NAME
*/
@IsOptional()
@IsString()
slashCommandName = process.env.SLASH_COMMAND_NAME || 'mark';
/**
* The activity status shown under the bot's name in the user list
* @example "!mark help" for help
* @default !mark help
* @env ACTIVITY
*/
@IsOptional()
@IsString()
activity = process.env.ACTIVITY || '!mark help';
/**
* A list of Discord user IDs that have owner permissions for the bot
* @example ["82684276755136512"]
* @default []
* @env OWNER_IDS (comma separated)
*/
@IsArray()
@IsString({ each: true })
@Type(() => String)
@IsOptional()
ownerIds = process.env.OWNER_IDS ? process.env.OWNER_IDS.split(',').map((id) => id.trim()) : [];
/**
* If provided, the standard "generate response" command will only work for a user in this list of role IDs.
* Moderators and owners configured in `ownerIds` do not bypass this check, so make sure to add them to a valid role as well.
* @example ["734548250895319070"]
* @default []
* @env USER_ROLE_IDS (comma separated)
*/
@IsArray()
@IsString({ each: true })
@Type(() => String)
@IsOptional()
userRoleIds = process.env.USER_ROLE_IDS
? process.env.USER_ROLE_IDS.split(',').map((id) => id.trim())
: [];
/**
* TZ name from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
* @example America/Chicago
* @default UTC
* @env TZ
*/
@IsOptional()
@IsString()
timezone = process.env.TZ || 'UTC';
/**
* Log level in lower case. Can be [silent, error, warn, info, debug, trace]
* @example debug
* @default info
* @env LOG_LEVEL
*/
@IsOptional()
@IsEnum(LogLevel)
logLevel = process.env.LOG_LEVEL || LogLevel.INFO;
/**
* The stateSize is the number of words for each "link" of the generated sentence.
* 1 will output gibberish sentences without much sense.
* 2 is a sensible default for most cases.
* 3 and more can create good sentences if you have a corpus that allows it.
* @example 3
* @default 2
* @env STATE_SIZE
*/
@IsOptional()
@IsInt()
stateSize = process.env.STATE_SIZE ? parseInt(process.env.STATE_SIZE, 10) : 2;
/**
* The number of tries the sentence generator will try before giving up
* @example 2000
* @default 1000
* @env MAX_TRIES
*/
@IsOptional()
@IsInt()
maxTries = process.env.MAX_TRIES ? parseInt(process.env.MAX_TRIES, 10) : 1000;
/**
* The minimum score required when generating a sentence.
* A relative "score" based on the number of possible permutations.
* Higher is "better", but the actual value depends on your corpus.
* @example 15
* @default 10
* @env MIN_SCORE
*/
@IsOptional()
@IsInt()
minScore = process.env.MIN_SCORE ? parseInt(process.env.MIN_SCORE, 10) : 10;
/**
* This guild ID should be declared if you want its commands to update immediately during development
* @example 1234567890
* @env DEV_GUILD_ID
*/
@IsOptional()
@IsString()
devGuildId = process.env.DEV_GUILD_ID;
}

2
src/config/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './classes';
export * from './setup';

80
src/config/setup.ts Normal file
View File

@@ -0,0 +1,80 @@
import 'reflect-metadata';
import { config as dotenv } from 'dotenv';
import json5 from 'json5';
import path from 'path';
import fs from 'fs-extra';
import { validateSync } from 'class-validator';
import { instanceToPlain, plainToInstance } from 'class-transformer';
import pino from 'pino';
import { AppConfig } from './classes';
dotenv();
// Declare pino logger as importing would cause dependency cycle
const L = pino({
transport: {
target: 'pino-pretty',
options: {
translateTime: `SYS:standard`,
},
},
formatters: {
level: (label) => {
return { level: label };
},
},
level: process.env.LOG_LEVEL || 'info',
base: undefined,
});
// TODO: Add YAML parser
const EXTENSIONS = ['.json', '.json5']; // Allow .json or .json5 extension
const removeFileExtension = (filename: string): string => {
const ext = path.extname(filename);
if (EXTENSIONS.includes(ext)) {
return path.basename(filename, ext);
}
return path.basename(filename);
};
export const CONFIG_DIR = process.env.CONFIG_DIR || 'config';
export const CONFIG_FILE_NAME = process.env.CONFIG_FILE_NAME
? removeFileExtension(process.env.CONFIG_FILE_NAME)
: 'config';
const configPaths = EXTENSIONS.map((ext) => path.resolve(CONFIG_DIR, `${CONFIG_FILE_NAME}${ext}`));
const configPath = configPaths.find((p) => fs.existsSync(p));
// eslint-disable-next-line import/no-mutable-exports
let config: AppConfig;
if (!configPath) {
L.warn('No config file detected');
const newConfigPath = path.resolve(CONFIG_DIR, `${CONFIG_FILE_NAME}.json`);
config = new AppConfig();
try {
L.debug({ newConfigPath }, 'Creating new config file');
fs.writeJSONSync(newConfigPath, instanceToPlain(config), { spaces: 2 });
L.info({ newConfigPath }, 'Wrote new default config file');
} catch (err) {
L.debug(err);
L.info('Not allowed to create new config. Continuing...');
}
} else {
L.debug({ configPath });
const parsedConfig = json5.parse(fs.readFileSync(configPath, 'utf8'));
config = plainToInstance(AppConfig, parsedConfig);
}
const errors = validateSync(config, {
validationError: {
target: false,
},
});
if (errors.length > 0) {
L.error({ errors }, 'Validation error(s)');
throw new Error('Invalid config');
}
L.debug({ config: instanceToPlain(config) });
export { config };

107
src/deploy-commands.ts Normal file
View File

@@ -0,0 +1,107 @@
import { SlashCommandBuilder, SlashCommandChannelOption } from '@discordjs/builders';
import { REST } from '@discordjs/rest';
import { ChannelType, Routes } from 'discord-api-types/v9';
import { config } from './config';
import { packageJson } from './util';
export const CHANNEL_OPTIONS_MAX = 25;
export const helpCommand = new SlashCommandBuilder()
.setName('help')
.setDescription(`How to use ${packageJson().name}`);
export const inviteCommand = new SlashCommandBuilder()
.setName('invite')
.setDescription('Get the invite link for this bot.');
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)
)
.addBooleanOption((debug) =>
debug
.setName('debug')
.setDescription('Follow up the generated message with the detailed sources that inspired it.')
.setRequired(false)
)
.addStringOption((seed) =>
seed
.setName('seed')
.setDescription(
`A ${config.stateSize}-word phrase to attempt to start a generated sentence with.`
)
.setRequired(false)
);
/**
* Helps generate a list of parameters for channel options
*/
const channelOptionsGenerator = (builder: SlashCommandChannelOption, index: number) =>
builder
.setName(`channel-${index + 1}`)
.setDescription('A text channel')
.setRequired(index === 0)
.addChannelType(ChannelType.GuildText);
export const listenChannelCommand = new SlashCommandBuilder()
.setName('listen')
.setDescription('Change what channels the bot actively listens to and learns from.')
.addSubcommand((sub) => {
sub
.setName('add')
.setDescription(
`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))
);
return sub;
})
.addSubcommand((sub) => {
sub
.setName('remove')
.setDescription(
`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))
);
return sub;
})
.addSubcommand((sub) =>
sub
.setName('list')
.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)`)
);
export const trainCommand = new SlashCommandBuilder()
.setName('train')
.setDescription(
'Train from past messages from the configured listened channels. This takes a while.'
);
const commands = [
helpCommand.toJSON(),
inviteCommand.toJSON(),
messageCommand.toJSON(),
listenChannelCommand.toJSON(),
trainCommand.toJSON(),
];
export async function deployCommands(clientId: string) {
const rest = new REST({ version: '9' }).setToken(config.token);
if (config.devGuildId) {
await rest.put(Routes.applicationGuildCommands(clientId, config.devGuildId), {
body: commands,
});
} else {
await rest.put(Routes.applicationCommands(clientId), { body: commands });
}
}

17
src/entity/Channel.ts Normal file
View File

@@ -0,0 +1,17 @@
/* eslint-disable import/no-cycle */
import { PrimaryColumn, Entity, ManyToOne, BaseEntity, Column } from 'typeorm';
import { Guild } from './Guild';
@Entity()
export class Channel extends BaseEntity {
@PrimaryColumn({ type: 'text' })
id: string;
@Column({
default: false,
})
listen: boolean;
@ManyToOne(() => Guild, (guild) => guild.channels)
guild: Guild;
}

12
src/entity/Guild.ts Normal file
View File

@@ -0,0 +1,12 @@
/* eslint-disable import/no-cycle */
import { BaseEntity, Entity, OneToMany, PrimaryColumn } from 'typeorm';
import { Channel } from './Channel';
@Entity()
export class Guild extends BaseEntity {
@PrimaryColumn({ type: 'text' })
id: string;
@OneToMany(() => Channel, (channel) => channel.guild, { onDelete: 'CASCADE', cascade: true })
channels: Channel[];
}

863
src/index.ts Normal file
View File

@@ -0,0 +1,863 @@
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();

23
src/logger.ts Normal file
View File

@@ -0,0 +1,23 @@
import pino from 'pino';
import dotenv from 'dotenv';
import { config } from './config';
dotenv.config();
const logger = pino({
transport: {
target: 'pino-pretty',
options: {
translateTime: `SYS:standard`,
},
},
formatters: {
level: (label) => {
return { level: label };
},
},
level: config.logLevel,
base: undefined,
});
export default logger;

View File

@@ -0,0 +1,24 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class CreateTables1640838214672 implements MigrationInterface {
name = 'CreateTables1640838214672'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "guild" ("id" text PRIMARY KEY NOT NULL)`);
await queryRunner.query(`CREATE TABLE "channel" ("id" text PRIMARY KEY NOT NULL, "listen" boolean NOT NULL DEFAULT (0), "guildId" text)`);
await queryRunner.query(`CREATE TABLE "temporary_channel" ("id" text PRIMARY KEY NOT NULL, "listen" boolean NOT NULL DEFAULT (0), "guildId" text, CONSTRAINT "FK_58d968d578e6279e2cc884db403" FOREIGN KEY ("guildId") REFERENCES "guild" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_channel"("id", "listen", "guildId") SELECT "id", "listen", "guildId" FROM "channel"`);
await queryRunner.query(`DROP TABLE "channel"`);
await queryRunner.query(`ALTER TABLE "temporary_channel" RENAME TO "channel"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "channel" RENAME TO "temporary_channel"`);
await queryRunner.query(`CREATE TABLE "channel" ("id" text PRIMARY KEY NOT NULL, "listen" boolean NOT NULL DEFAULT (0), "guildId" text)`);
await queryRunner.query(`INSERT INTO "channel"("id", "listen", "guildId") SELECT "id", "listen", "guildId" FROM "temporary_channel"`);
await queryRunner.query(`DROP TABLE "temporary_channel"`);
await queryRunner.query(`DROP TABLE "channel"`);
await queryRunner.query(`DROP TABLE "guild"`);
}
}

0
src/subscriber/.gitkeep Normal file
View File

21
src/util.ts Normal file
View File

@@ -0,0 +1,21 @@
import fs from 'fs-extra';
import path from 'path';
import type { PackageJson } from 'types-package-json';
let packageJsonCache: PackageJson | undefined;
export const packageJson = (): PackageJson => {
if (packageJsonCache) return packageJsonCache;
packageJsonCache = fs.readJSONSync(path.resolve(process.cwd(), `package.json`));
return packageJsonCache as PackageJson;
};
export const getVersion = (): string => {
const { COMMIT_SHA } = process.env;
let { version } = packageJson();
if (COMMIT_SHA) version = `${version}#${COMMIT_SHA.substring(0, 8)}`;
return version;
};
export const getRandomElement = <T>(array: T[]): T => {
return array[Math.floor(Math.random() * array.length)];
};

View File

@@ -4,17 +4,19 @@
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"outDir": "./dist", /* Redirect output structure to the directory. */ "outDir": "./dist", /* Redirect output structure to the directory. */
"removeComments": true, /* Do not emit comments to output. */ "removeComments": true, /* Do not emit comments to output. */
"esModuleInterop": true,
"strict": true, /* Enable all strict type-checking options. */ "strict": true, /* Enable all strict type-checking options. */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"typeRoots": [ "typeRoots": [
"node_modules/@types" "node_modules/@types"
], /* List of folders to include type definitions from. */ ], /* List of folders to include type definitions from. */
"inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"useUnknownInCatchVariables": false,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"emitDecoratorMetadata": true,
}, },
"include": [
"**/*.ts"
],
"exclude": [ "exclude": [
"node_modules", "node_modules",
"dist" "dist"