mirror of
https://github.com/pacnpal/markov-discord.git
synced 2025-12-20 03:01:04 -05:00
@@ -1,7 +1,5 @@
|
||||
# Ignore everything
|
||||
**/*
|
||||
|
||||
# Allow files and directories
|
||||
!*.js
|
||||
!*.json
|
||||
!*.ts
|
||||
config
|
||||
dist
|
||||
node_modules
|
||||
img
|
||||
docs
|
||||
10
.eslintrc.js
10
.eslintrc.js
@@ -6,7 +6,6 @@ module.exports = {
|
||||
extends: [
|
||||
'airbnb-base',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier/@typescript-eslint',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
@@ -15,6 +14,12 @@ module.exports = {
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
'import/extensions': 0,
|
||||
'import/prefer-default-export': 0,
|
||||
'no-shadow': 'off',
|
||||
'@typescript-eslint/no-shadow': ['error'],
|
||||
},
|
||||
settings: {
|
||||
'import/extensions': ['.js', '.ts',],
|
||||
'import/parsers': {
|
||||
@@ -25,5 +30,6 @@ module.exports = {
|
||||
extensions: ['.js', '.ts',]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ignorePatterns: ['dist/**', 'node_modules/**', '.eslintrc.js', 'src/migration/**']
|
||||
}
|
||||
39
.github/workflows/build-and-push-image.yml
vendored
39
.github/workflows/build-and-push-image.yml
vendored
@@ -1,21 +1,27 @@
|
||||
name: Build and push image
|
||||
name: MultiArchDockerBuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build_and_push_image:
|
||||
name: Build Docker image and push to registries
|
||||
build_multi_arch_image:
|
||||
name: Build multi-arch Docker image.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
@@ -30,15 +36,32 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build_push
|
||||
- name: Build and push master
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
target: deploy
|
||||
push: true
|
||||
tags: |
|
||||
charlocharlie/markov-discord: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
|
||||
run: echo ${{ steps.docker_build_push.outputs.digest }}
|
||||
- name: Build and push dev
|
||||
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
33
.github/workflows/typedoc.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -4,6 +4,7 @@ logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
docs
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
@@ -65,3 +66,5 @@ config.json
|
||||
# error output file
|
||||
error.json
|
||||
markovDB.json
|
||||
|
||||
/config/
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5'
|
||||
}
|
||||
5
.vscode/launch.json
vendored
5
.vscode/launch.json
vendored
@@ -13,8 +13,9 @@
|
||||
"ts-node/register"
|
||||
],
|
||||
"args": [
|
||||
"${workspaceFolder}/index.ts"
|
||||
]
|
||||
"${workspaceFolder}/src/index.ts"
|
||||
],
|
||||
"outputCapture": "std",
|
||||
}
|
||||
]
|
||||
}
|
||||
14
.vscode/settings.json
vendored
14
.vscode/settings.json
vendored
@@ -13,6 +13,16 @@
|
||||
"[typescript]": {
|
||||
"editor.formatOnSave": false,
|
||||
},
|
||||
"eslint.enable": true,
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"[json]": {
|
||||
"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
101
CHANGELOG.md
Normal 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.
|
||||
50
Dockerfile
50
Dockerfile
@@ -1,49 +1,55 @@
|
||||
########
|
||||
# BASE
|
||||
########
|
||||
FROM keymetrics/pm2:12-alpine as base
|
||||
WORKDIR /usr/src/markbot
|
||||
FROM node:16-alpine3.15 as base
|
||||
|
||||
COPY package*.json ./
|
||||
# Install build tools for erlpack, then install prod deps only, then remove build tools
|
||||
RUN apk add --no-cache make gcc g++ python && \
|
||||
npm ci --only=production && \
|
||||
apk del make gcc g++ python
|
||||
WORKDIR /usr/app
|
||||
|
||||
RUN apk add --no-cache tini
|
||||
|
||||
########
|
||||
# BUILD
|
||||
########
|
||||
FROM base as build
|
||||
|
||||
# Copy all *.json, *.js, *.ts
|
||||
COPY . .
|
||||
# Prod deps already installed, add dev deps
|
||||
RUN npm i
|
||||
COPY package*.json ./
|
||||
# Install build tools for erlpack, then install prod deps only
|
||||
RUN apk add --no-cache make gcc g++ python3 \
|
||||
&& 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
|
||||
|
||||
########
|
||||
# DEPLOY
|
||||
########
|
||||
FROM keymetrics/pm2:12-alpine as deploy
|
||||
WORKDIR /usr/src/markbot
|
||||
FROM base as deploy
|
||||
|
||||
ENV NPM_CONFIG_LOGLEVEL warn
|
||||
USER node
|
||||
|
||||
# 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
|
||||
COPY --from=build /usr/src/markbot/dist ./
|
||||
COPY --from=build /usr/app/dist dist
|
||||
|
||||
# Copy package.json for version number
|
||||
COPY package*.json ./
|
||||
COPY package*.json ormconfig.js ./
|
||||
|
||||
# Copy PM2 config
|
||||
COPY ecosystem.config.js .
|
||||
# RUN mkdir config
|
||||
|
||||
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
219
README.md
@@ -4,214 +4,65 @@ A Markov chain bot using markov-strings.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Configure what channels you want the bot to listen/learn from:
|
||||
* User: `/listen modify`
|
||||
* Bot: 
|
||||
1. Train the bot in a lengthy text channel:
|
||||
* User: `!mark train`
|
||||
* Markbot: `@User, Finished training from past 76394 messages.`
|
||||
* User: `/train`
|
||||
* Bot: 
|
||||
1. Ask the bot to say something:
|
||||
* User: `!mark`
|
||||
* Markbot: `This Shopko has a Linux release`
|
||||
* User: `/mark`
|
||||
* Bot: 
|
||||
|
||||
## 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 Requirements
|
||||
|
||||
* [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. Install [Node.js 16 or newer](https://nodejs.org/en/download/).
|
||||
1. Download this repository using git in a command prompt
|
||||
|
||||
```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.
|
||||
|
||||
```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
|
||||
npm ci
|
||||
# Build the Typescript
|
||||
npm run build
|
||||
# Initialize the config
|
||||
npm start
|
||||
```
|
||||
|
||||
1. 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"
|
||||
}
|
||||
```
|
||||
|
||||
Feel free to change the command prefix, game display. Add your bot token.
|
||||
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>
|
||||
1. Run the bot:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
|
||||
### Debian Linux
|
||||
|
||||
#### 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.
|
||||
And use the invite link printed to the logs.
|
||||
|
||||
4
config/.gitignore
vendored
4
config/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
# Ignore everything in this directory
|
||||
*
|
||||
# Except this file
|
||||
!.gitignore
|
||||
@@ -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
BIN
img/listen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
img/respond.png
Normal file
BIN
img/respond.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
img/train.png
Normal file
BIN
img/train.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
431
index.ts
431
index.ts
@@ -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
39
ormconfig.js
Normal 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
11709
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
92
package.json
92
package.json
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "markbot",
|
||||
"version": "0.7.3",
|
||||
"name": "markov-discord",
|
||||
"version": "2.0.0",
|
||||
"description": "A conversational Markov chain bot for Discord",
|
||||
"main": "index.js",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"start:ts": "ts-node index.ts",
|
||||
"build": "tsc",
|
||||
"lint": "tsc --noEmit && eslint **/*.ts",
|
||||
"docker-up": "docker run --rm -e TOKEN=abc123 -it $(docker build -q .)"
|
||||
"start": "NODE_ENV=production pm2 start --no-daemon dist/index.js",
|
||||
"start:ts": "ts-node src/index.ts",
|
||||
"build": "rimraf dist && tsc",
|
||||
"lint": "tsc --noEmit && eslint **/*.ts *.js",
|
||||
"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": [
|
||||
"discord",
|
||||
"markov",
|
||||
@@ -22,36 +25,55 @@
|
||||
"markov-chain-bot",
|
||||
"docker"
|
||||
],
|
||||
"author": "Charlie Laabs <charlielaabs@gmail.com>",
|
||||
"author": {
|
||||
"name": "Charlie Laabs",
|
||||
"url": "https://github.com/claabs"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"discord.js": "^12.3.1",
|
||||
"erlpack": "^0.1.3",
|
||||
"markov-strings": "^3.0.1",
|
||||
"node-schedule": "^1.3.2",
|
||||
"source-map-support": "^0.5.16",
|
||||
"@discordjs/builders": "^0.11.0",
|
||||
"@discordjs/rest": "^0.2.0-canary.0",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"better-sqlite3": "^7.4.6",
|
||||
"bufferutil": "^4.0.6",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^12.12.21",
|
||||
"@types/node-schedule": "^1.3.0",
|
||||
"@types/ws": "^7.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^2.13.0",
|
||||
"@typescript-eslint/parser": "^2.13.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-airbnb-base": "^14.0.0",
|
||||
"eslint-config-prettier": "^6.7.0",
|
||||
"eslint-plugin-import": "^2.19.1",
|
||||
"eslint-plugin-prettier": "^3.1.2",
|
||||
"prettier": "^1.19.1",
|
||||
"ts-node": "^8.5.4",
|
||||
"typescript": "^3.7.4"
|
||||
"@types/node": "^16.11.19",
|
||||
"@types/validator": "^13.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.9.0",
|
||||
"@typescript-eslint/parser": "^5.9.0",
|
||||
"eslint": "^8.6.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"pm2": "^5.1.2",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-node": "^10.4.0",
|
||||
"typedoc": "^0.22.10",
|
||||
"types-package-json": "^2.0.39",
|
||||
"typescript": "^4.5.4"
|
||||
},
|
||||
"eslintIgnore": [
|
||||
"**/*.js"
|
||||
]
|
||||
"engines": {
|
||||
"node": "16"
|
||||
}
|
||||
}
|
||||
|
||||
166
src/config/classes.ts
Normal file
166
src/config/classes.ts
Normal 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
2
src/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './classes';
|
||||
export * from './setup';
|
||||
80
src/config/setup.ts
Normal file
80
src/config/setup.ts
Normal 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
107
src/deploy-commands.ts
Normal 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
17
src/entity/Channel.ts
Normal 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
12
src/entity/Guild.ts
Normal 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
863
src/index.ts
Normal 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
23
src/logger.ts
Normal 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;
|
||||
24
src/migration/1640838214672-CreateTables.ts
Normal file
24
src/migration/1640838214672-CreateTables.ts
Normal 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
0
src/subscriber/.gitkeep
Normal file
21
src/util.ts
Normal file
21
src/util.ts
Normal 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)];
|
||||
};
|
||||
@@ -4,17 +4,19 @@
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
"outDir": "./dist", /* Redirect output structure to the directory. */
|
||||
"removeComments": true, /* Do not emit comments to output. */
|
||||
"esModuleInterop": true,
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
], /* List of folders to include type definitions from. */
|
||||
"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": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
|
||||
Reference in New Issue
Block a user