├── .nvmrc ├── src ├── subscriber │ └── .gitkeep ├── config │ ├── index.ts │ ├── setup.ts │ └── classes.ts ├── entity │ ├── Guild.ts │ └── Channel.ts ├── logger.ts ├── util.ts ├── ormconfig.ts ├── migration │ └── 1640838214672-CreateTables.ts ├── deploy-commands.ts └── index.ts ├── .dockerignore ├── .gitattributes ├── img ├── train.png ├── listen.png ├── respond.png └── example-training.json ├── .prettierrc.js ├── .github ├── workflows │ ├── dockerhub-description.yml │ ├── typedoc.yml │ └── build-and-push-image.yml └── dependabot.yml ├── .vscode ├── launch.json └── settings.json ├── .eslintrc.js ├── tsconfig.json ├── .gitignore ├── Dockerfile ├── package.json ├── README.md └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /src/subscriber/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | config 2 | dist 3 | node_modules 4 | img 5 | docs -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto -------------------------------------------------------------------------------- /img/train.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/claabs/markov-discord/HEAD/img/train.png -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './classes'; 2 | export * from './setup'; 3 | -------------------------------------------------------------------------------- /img/listen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/claabs/markov-discord/HEAD/img/listen.png -------------------------------------------------------------------------------- /img/respond.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/claabs/markov-discord/HEAD/img/respond.png -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | singleQuote: true, 4 | } -------------------------------------------------------------------------------- /src/entity/Guild.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-cycle */ 2 | import { BaseEntity, Entity, OneToMany, PrimaryColumn } from 'typeorm'; 3 | import { Channel } from './Channel'; 4 | 5 | @Entity() 6 | export class Guild extends BaseEntity { 7 | @PrimaryColumn({ type: 'text' }) 8 | id: string; 9 | 10 | @OneToMany(() => Channel, (channel) => channel.guild, { onDelete: 'CASCADE', cascade: true }) 11 | channels: Channel[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/entity/Channel.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-cycle */ 2 | import { PrimaryColumn, Entity, ManyToOne, BaseEntity, Column } from 'typeorm'; 3 | import { Guild } from './Guild'; 4 | 5 | @Entity() 6 | export class Channel extends BaseEntity { 7 | @PrimaryColumn({ type: 'text' }) 8 | id: string; 9 | 10 | @Column({ 11 | default: false, 12 | }) 13 | listen: boolean; 14 | 15 | @ManyToOne(() => Guild, (guild) => guild.channels) 16 | guild: Guild; 17 | } 18 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import pino from 'pino'; 3 | import PinoPretty from 'pino-pretty'; 4 | import { config } from './config'; 5 | 6 | const logger = pino( 7 | { 8 | formatters: { 9 | level: (label) => { 10 | return { level: label }; 11 | }, 12 | }, 13 | level: config.logLevel, 14 | base: undefined, 15 | }, 16 | PinoPretty({ 17 | translateTime: `SYS:standard`, 18 | }), 19 | ); 20 | 21 | export default logger; 22 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub-description.yml: -------------------------------------------------------------------------------- 1 | name: Update Docker Hub Description 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - README.md 8 | - .github/workflows/dockerhub-description.yml 9 | jobs: 10 | dockerHubDescription: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | 15 | - name: Docker Hub Description 16 | uses: peter-evans/dockerhub-description@v5 17 | with: 18 | username: charlocharlie 19 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 20 | repository: charlocharlie/markov-discord 21 | readme-filepath: ./README.md 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "runtimeArgs": [ 12 | "-r", 13 | "ts-node/register" 14 | ], 15 | "args": [ 16 | "${workspaceFolder}/src/index.ts" 17 | ], 18 | "outputCapture": "std", 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "typescript" 5 | ], 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit" 8 | }, 9 | "editor.formatOnSave": true, 10 | "[javascript]": { 11 | "editor.formatOnSave": false, 12 | }, 13 | "[typescript]": { 14 | "editor.formatOnSave": false, 15 | }, 16 | "[json]": { 17 | "files.insertFinalNewline": true 18 | }, 19 | "typescript.tsdk": "node_modules/typescript/lib", 20 | "sqltools.connections": [ 21 | { 22 | "previewLimit": 50, 23 | "driver": "SQLite", 24 | "name": "Local SQLite", 25 | "database": "./config/db/db.sqlite3" 26 | } 27 | ], 28 | } -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import type { PackageJson } from 'types-package-json'; 4 | 5 | let packageJsonCache: PackageJson | undefined; 6 | export const packageJson = (): PackageJson => { 7 | if (packageJsonCache) return packageJsonCache; 8 | packageJsonCache = fs.readJSONSync(path.resolve(process.cwd(), `package.json`)); 9 | return packageJsonCache as PackageJson; 10 | }; 11 | 12 | export const getVersion = (): string => { 13 | const { COMMIT_SHA } = process.env; 14 | let { version } = packageJson(); 15 | if (COMMIT_SHA) version = `${version}#${COMMIT_SHA.substring(0, 8)}`; 16 | return version; 17 | }; 18 | 19 | export const getRandomElement = (array: T[]): T => { 20 | return array[Math.floor(Math.random() * array.length)]; 21 | }; 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | # Disable version updates for npm dependencies 13 | open-pull-requests-limit: 0 14 | - package-ecosystem: "github-actions" # See documentation for possible values 15 | directory: "/" # Location of package manifests 16 | schedule: 17 | interval: "weekly" 18 | -------------------------------------------------------------------------------- /.github/workflows/typedoc.yml: -------------------------------------------------------------------------------- 1 | name: Publish Typedoc to Github Pages 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - Readme.md 8 | - src/config/classes.ts 9 | - .github/workflows/typedoc.yml 10 | - package.json 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | - name: Setup Node.js for use with actions 17 | uses: actions/setup-node@v6 18 | with: 19 | node-version-file: ".nvmrc" 20 | cache: "npm" 21 | 22 | - name: NPM install 23 | run: npm ci 24 | 25 | # Runs a single command using the runners shell 26 | - name: Build and lint 27 | run: npm run docs 28 | 29 | - name: Deploy 30 | uses: peaceiris/actions-gh-pages@v4 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | publish_dir: ./docs 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'airbnb-base', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:prettier/recommended', 10 | ], 11 | parser: '@typescript-eslint/parser', 12 | parserOptions: { 13 | project: './tsconfig.json', 14 | sourceType: 'module' 15 | }, 16 | plugins: ['@typescript-eslint'], 17 | rules: { 18 | 'import/extensions': 0, 19 | 'import/prefer-default-export': 0, 20 | 'no-shadow': 'off', 21 | '@typescript-eslint/no-shadow': ['error'], 22 | }, 23 | settings: { 24 | 'import/extensions': ['.js', '.ts',], 25 | 'import/parsers': { 26 | '@typescript-eslint/parser': ['.ts'] 27 | }, 28 | 'import/resolver': { 29 | node: { 30 | extensions: ['.js', '.ts',] 31 | } 32 | } 33 | }, 34 | ignorePatterns: ['dist/**', 'node_modules/**', '.eslintrc.js', 'src/migration/**'] 35 | } -------------------------------------------------------------------------------- /img/example-training.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "message": "Lorem ipsum dolor sit amet" 4 | }, 5 | { 6 | "message": "Lorem ipsum duplicate start words", 7 | "attachments": [ 8 | "https://cdn.discordapp.com/attachments/000000000000000000/000000000000000000/1.mp3", 9 | "https://cdn.discordapp.com/attachments/000000000000000000/000000000000000000/2.png" 10 | ] 11 | }, 12 | { 13 | "message": "Consectetur adipiscing elit" 14 | }, 15 | { 16 | "message": "Quisque tempor, erat vel lacinia imperdiet" 17 | }, 18 | { 19 | "message": "Justo nisi fringilla dui" 20 | }, 21 | { 22 | "message": "Egestas bibendum eros nisi ut lacus" 23 | }, 24 | { 25 | "message": "fringilla dui avait annoncé une rupture avec le erat vel: il n'en est rien…" 26 | }, 27 | { 28 | "message": "Fusce tincidunt tempor, erat vel lacinia vel ex pharetra pretium lacinia imperdiet" 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /src/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { DataSourceOptions } from 'typeorm'; 2 | import { Channel } from './entity/Channel'; 3 | import { Guild } from './entity/Guild'; 4 | import { CreateTables1640838214672 } from './migration/1640838214672-CreateTables'; 5 | 6 | const ENTITIES = [Channel, Guild]; 7 | const MIGRATIONS = [CreateTables1640838214672]; 8 | // const SUBSCRIBERS = []; 9 | 10 | const devConfig: DataSourceOptions = { 11 | type: 'better-sqlite3', 12 | database: process.env.CONFIG_DIR 13 | ? `${process.env.CONFIG_DIR}/db/db.sqlite3` 14 | : 'config/db/db.sqlite3', 15 | synchronize: true, 16 | migrationsRun: false, 17 | // logging: 'all', 18 | entities: ENTITIES, 19 | migrations: MIGRATIONS, 20 | // subscribers: SUBSCRIBERS, 21 | }; 22 | 23 | const prodConfig: DataSourceOptions = { 24 | type: 'better-sqlite3', 25 | database: process.env.CONFIG_DIR 26 | ? `${process.env.CONFIG_DIR}/db/db.sqlite3` 27 | : 'config/db/db.sqlite3', 28 | synchronize: false, 29 | logging: false, 30 | entities: ENTITIES, 31 | migrations: MIGRATIONS, 32 | migrationsRun: true, 33 | // subscribers: SUBSCRIBERS, 34 | }; 35 | 36 | const finalConfig = process.env.NODE_ENV !== 'production' ? devConfig : prodConfig; 37 | 38 | export default finalConfig; 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://www.npmjs.com/package/@tsconfig/node16 3 | "compilerOptions": { 4 | "target": "es2021", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "outDir": "./dist", /* Redirect output structure to the directory. */ 7 | "removeComments": true, /* Do not emit comments to output. */ 8 | "esModuleInterop": true, 9 | "strict": true, /* Enable all strict type-checking options. */ 10 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 11 | "typeRoots": [ 12 | "node_modules/@types" 13 | ], /* List of folders to include type definitions from. */ 14 | "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 15 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 16 | "useUnknownInCatchVariables": false, 17 | "experimentalDecorators": true, 18 | "strictPropertyInitialization": false, 19 | "emitDecoratorMetadata": true, 20 | }, 21 | "exclude": [ 22 | "node_modules", 23 | "dist" 24 | ] 25 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | docs 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | # (or other binary data) 35 | build/Release 36 | dist/ 37 | .DS_Store 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # Typescript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # any config.json - don't want to overwrite someone's config 65 | config.json 66 | # error output file 67 | error.json 68 | markovDB.json 69 | 70 | /config/ 71 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ######## 2 | # BASE 3 | ######## 4 | FROM node:20-alpine3.20 as base 5 | 6 | WORKDIR /usr/app 7 | 8 | RUN apk add --no-cache tini 9 | 10 | ############ 11 | # PROD DEPS 12 | ############ 13 | 14 | FROM base as prodDeps 15 | 16 | COPY package*.json ./ 17 | # Install build tools for erlpack, then install prod deps only 18 | RUN apk add --no-cache make gcc g++ python3 \ 19 | && npm ci --omit=dev 20 | 21 | ######## 22 | # BUILD 23 | ######## 24 | FROM base as build 25 | 26 | COPY package*.json ./ 27 | # Install build tools for erlpack, then install prod deps only 28 | RUN apk add --no-cache make gcc g++ python3 \ 29 | && npm ci --omit=dev 30 | 31 | # Copy all jsons 32 | COPY package*.json tsconfig.json ./ 33 | 34 | # Add dev deps 35 | RUN npm ci 36 | 37 | # Copy source code 38 | COPY src src 39 | 40 | RUN npm run build 41 | 42 | ######## 43 | # DEPLOY 44 | ######## 45 | FROM base as deploy 46 | 47 | USER node 48 | 49 | # Steal node_modules from base image 50 | COPY --from=prodDeps /usr/app/node_modules node_modules 51 | 52 | # Steal compiled code from build image 53 | COPY --from=build /usr/app/dist dist 54 | 55 | # Copy package.json for version number 56 | COPY package.json ./ 57 | 58 | # RUN mkdir config 59 | 60 | ARG COMMIT_SHA="" 61 | 62 | ENV NODE_ENV=production \ 63 | COMMIT_SHA=${COMMIT_SHA} 64 | 65 | ENTRYPOINT ["/sbin/tini", "--"] 66 | CMD [ "node", "/usr/app/dist/index.js" ] -------------------------------------------------------------------------------- /src/migration/1640838214672-CreateTables.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class CreateTables1640838214672 implements MigrationInterface { 4 | name = 'CreateTables1640838214672' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`CREATE TABLE "guild" ("id" text PRIMARY KEY NOT NULL)`); 8 | await queryRunner.query(`CREATE TABLE "channel" ("id" text PRIMARY KEY NOT NULL, "listen" boolean NOT NULL DEFAULT (0), "guildId" text)`); 9 | 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)`); 10 | await queryRunner.query(`INSERT INTO "temporary_channel"("id", "listen", "guildId") SELECT "id", "listen", "guildId" FROM "channel"`); 11 | await queryRunner.query(`DROP TABLE "channel"`); 12 | await queryRunner.query(`ALTER TABLE "temporary_channel" RENAME TO "channel"`); 13 | } 14 | 15 | public async down(queryRunner: QueryRunner): Promise { 16 | await queryRunner.query(`ALTER TABLE "channel" RENAME TO "temporary_channel"`); 17 | await queryRunner.query(`CREATE TABLE "channel" ("id" text PRIMARY KEY NOT NULL, "listen" boolean NOT NULL DEFAULT (0), "guildId" text)`); 18 | await queryRunner.query(`INSERT INTO "channel"("id", "listen", "guildId") SELECT "id", "listen", "guildId" FROM "temporary_channel"`); 19 | await queryRunner.query(`DROP TABLE "temporary_channel"`); 20 | await queryRunner.query(`DROP TABLE "channel"`); 21 | await queryRunner.query(`DROP TABLE "guild"`); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push-image.yml: -------------------------------------------------------------------------------- 1 | name: MultiArchDockerBuild 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | 9 | jobs: 10 | build_multi_arch_image: 11 | name: Build multi-arch Docker image. 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v6 16 | 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | 20 | - name: Set up Docker Buildx 21 | id: buildx 22 | uses: docker/setup-buildx-action@v3 23 | with: 24 | install: true 25 | 26 | - name: Login to DockerHub 27 | uses: docker/login-action@v3 28 | with: 29 | username: charlocharlie 30 | password: ${{ secrets.DOCKERHUB_TOKEN }} 31 | 32 | - name: Login to GitHub Container Registry 33 | uses: docker/login-action@v3 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.repository_owner }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Build and push master 40 | if: ${{ github.ref == 'refs/heads/master' }} 41 | uses: docker/build-push-action@v6 42 | with: 43 | target: deploy 44 | push: true 45 | tags: | 46 | charlocharlie/markov-discord:latest 47 | ghcr.io/${{ github.repository }}:latest 48 | platforms: linux/amd64,linux/arm64 49 | build-args: | 50 | COMMIT_SHA=${{ github.sha }} 51 | cache-from: type=gha,scope=${{ github.workflow }} 52 | cache-to: type=gha,mode=max,scope=${{ github.workflow }} 53 | 54 | - name: Build and push dev 55 | if: ${{ github.ref == 'refs/heads/develop' }} 56 | uses: docker/build-push-action@v6 57 | with: 58 | target: deploy 59 | push: true 60 | tags: | 61 | charlocharlie/markov-discord:dev 62 | ghcr.io/claabs/markov-discord:dev 63 | platforms: linux/amd64 64 | build-args: | 65 | COMMIT_SHA=${{ github.sha }} 66 | cache-from: type=gha,scope=${{ github.workflow }} 67 | cache-to: type=gha,mode=max,scope=${{ github.workflow }} 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markov-discord", 3 | "version": "2.3.0", 4 | "description": "A conversational Markov chain bot for Discord", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "start": "NODE_ENV=production node dist/index.js", 8 | "start:ts": "ts-node src/index.ts", 9 | "build": "rimraf dist && tsc", 10 | "lint": "tsc --noEmit && eslint .", 11 | "docker:build": "docker build . -t charlocharlie/markov-discord:latest --target deploy", 12 | "docker:run": "docker run --rm -ti -v $(pwd)/config:/usr/app/config charlocharlie/markov-discord:latest", 13 | "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", 14 | "docs": "typedoc --out docs src/config/classes.ts" 15 | }, 16 | "repository": "https://github.com/claabs/markov-discord.git", 17 | "keywords": [ 18 | "discord", 19 | "markov", 20 | "chain", 21 | "markov-chain", 22 | "bot", 23 | "discord-js", 24 | "discord-bot", 25 | "markov-chain-bot", 26 | "docker" 27 | ], 28 | "author": { 29 | "name": "Charlie Laabs", 30 | "url": "https://github.com/claabs" 31 | }, 32 | "license": "MIT", 33 | "dependencies": { 34 | "better-sqlite3": "^11.10.0", 35 | "bufferutil": "^4.0.8", 36 | "class-transformer": "^0.5.1", 37 | "class-validator": "^0.14.1", 38 | "date-fns": "^2.28.0", 39 | "discord.js": "^14.21.0", 40 | "dotenv": "^16.4.5", 41 | "fs-extra": "^11.2.0", 42 | "json5": "^2.2.3", 43 | "markov-strings-db": "^4.3.0", 44 | "node-fetch": "^2.6.7", 45 | "pino": "^10.0.0", 46 | "pino-pretty": "^13.1.2", 47 | "reflect-metadata": "^0.2.2", 48 | "simple-eta": "^3.0.2", 49 | "source-map-support": "^0.5.21", 50 | "typeorm": "^0.3.27", 51 | "utf-8-validate": "^6.0.4", 52 | "zlib-sync": "^0.1.9" 53 | }, 54 | "devDependencies": { 55 | "@types/fs-extra": "^11.0.4", 56 | "@types/node": "^20.14.11", 57 | "@types/validator": "^13.12.0", 58 | "@typescript-eslint/eslint-plugin": "^7.16.1", 59 | "@typescript-eslint/parser": "^7.16.1", 60 | "eslint": "^8.57.0", 61 | "eslint-config-airbnb-base": "^15.0.0", 62 | "eslint-config-prettier": "^9.1.0", 63 | "eslint-plugin-import": "^2.29.1", 64 | "eslint-plugin-prettier": "^5.2.1", 65 | "prettier": "^3.3.3", 66 | "rimraf": "^6.0.1", 67 | "ts-node": "^10.9.2", 68 | "typedoc": "^0.26.4", 69 | "types-package-json": "^2.0.39", 70 | "typescript": "5.4" 71 | }, 72 | "engines": { 73 | "node": ">=20" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/config/setup.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import 'dotenv/config'; 3 | import json5 from 'json5'; 4 | import path from 'path'; 5 | import fs from 'fs-extra'; 6 | import { validateSync } from 'class-validator'; 7 | import { instanceToPlain, plainToInstance } from 'class-transformer'; 8 | import pino from 'pino'; 9 | import { AppConfig } from './classes'; 10 | 11 | // Declare pino logger as importing would cause dependency cycle 12 | const L = pino({ 13 | transport: { 14 | target: 'pino-pretty', 15 | options: { 16 | translateTime: `SYS:standard`, 17 | }, 18 | }, 19 | formatters: { 20 | level: (label) => { 21 | return { level: label }; 22 | }, 23 | }, 24 | level: process.env.LOG_LEVEL || 'info', 25 | base: undefined, 26 | }); 27 | 28 | // TODO: Add YAML parser 29 | const EXTENSIONS = ['.json', '.json5']; // Allow .json or .json5 extension 30 | 31 | const removeFileExtension = (filename: string): string => { 32 | const ext = path.extname(filename); 33 | if (EXTENSIONS.includes(ext)) { 34 | return path.basename(filename, ext); 35 | } 36 | return path.basename(filename); 37 | }; 38 | 39 | export const CONFIG_DIR = process.env.CONFIG_DIR || 'config'; 40 | export const CONFIG_FILE_NAME = process.env.CONFIG_FILE_NAME 41 | ? removeFileExtension(process.env.CONFIG_FILE_NAME) 42 | : 'config'; 43 | 44 | const configPaths = EXTENSIONS.map((ext) => path.resolve(CONFIG_DIR, `${CONFIG_FILE_NAME}${ext}`)); 45 | const configPath = configPaths.find((p) => fs.existsSync(p)); 46 | // eslint-disable-next-line import/no-mutable-exports 47 | let config: AppConfig; 48 | if (!configPath) { 49 | L.warn('No config file detected'); 50 | const newConfigPath = path.resolve(CONFIG_DIR, `${CONFIG_FILE_NAME}.json`); 51 | config = new AppConfig(); 52 | try { 53 | L.info({ newConfigPath }, 'Creating new config file'); 54 | fs.writeJSONSync(newConfigPath, instanceToPlain(config), { spaces: 2 }); 55 | L.info({ newConfigPath }, 'Wrote new default config file'); 56 | } catch (err) { 57 | L.info(err, 'Not allowed to create new config. Continuing...'); 58 | } 59 | } else { 60 | L.debug({ configPath }); 61 | const parsedConfig = json5.parse(fs.readFileSync(configPath, 'utf8')); 62 | config = plainToInstance(AppConfig, parsedConfig); 63 | } 64 | 65 | const errors = validateSync(config, { 66 | validationError: { 67 | target: false, 68 | }, 69 | }); 70 | if (errors.length > 0) { 71 | L.error({ errors }, 'Validation error(s)'); 72 | throw new Error('Invalid config'); 73 | } 74 | 75 | L.debug({ config: instanceToPlain(config) }); 76 | 77 | export { config }; 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MarkBot for Discord 2 | 3 | A Markov chain bot using markov-strings. 4 | 5 | ## Usage 6 | 7 | 1. Configure what channels you want the bot to listen/learn from: 8 | * User: `/listen modify` 9 | * Bot: ![Select which channels your would like the bot to actively listen to](img/listen.png) 10 | 1. Train the bot in a lengthy text channel: 11 | * User: `/train` 12 | * Bot: ![Parsing past messages from 5 channel(s).](img/train.png) 13 | 1. Ask the bot to say something: 14 | * User: `/mark` 15 | * Bot: ![worms are not baby snakes, by the way](img/respond.png) 16 | 17 | ### Training from a file 18 | 19 | Using the `json` option in the `/train` command, you can import a list of messages. 20 | An example JSON file can be seen [here](img/example-training.json). 21 | 22 | ## Setup 23 | 24 | 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. 25 | 26 | 1. Create a [Discord bot application](https://discordapp.com/developers/applications/) 27 | 1. Under the "Bot" section, enable the "Message Content Intent", and copy the token for later. 28 | 1. Setup and configure the bot using one of the below methods: 29 | 30 | ### Docker 31 | 32 | Running this bot in Docker is the easiest way to ensure it runs as expected and can easily recieve updates. 33 | 34 | 1. [Install Docker for your OS](https://docs.docker.com/get-docker/) 35 | 1. Open a command prompt and run: 36 | 37 | ```sh 38 | docker run --restart unless-stopped -d -v /my/host/dir:/usr/app/config ghcr.io/claabs/markov-discord:latest 39 | ``` 40 | 41 | Where `/my/host/dir` is a accessible path on your system. `--restart=unless-stopped` is recommended in case an unexpected error crashes the bot. 42 | 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: 43 | 1. Run the container again and use the invite link printed to the logs. 44 | 45 | ### Windows 46 | 47 | 1. Install [Node.js 16 or newer](https://nodejs.org/en/download/). 48 | 1. Download this repository using git in a command prompt 49 | 50 | ```cmd 51 | git clone https://github.com/claabs/markov-discord.git 52 | ``` 53 | 54 | or by just downloading and extracting the [project zip](https://github.com/claabs/markov-discord/archive/master.zip) from GitHub. 55 | 1. Open a command prompt in the `markov-discord` folder. 56 | 57 | ```sh 58 | # NPM install non-development packages 59 | npm ci 60 | # Build the Typescript 61 | npm run build 62 | # Initialize the config 63 | npm start 64 | ``` 65 | 66 | 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: 67 | 1. Run the bot: 68 | 69 | ```sh 70 | npm start 71 | ``` 72 | 73 | And use the invite link printed to the logs. 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## Versions 6 | 7 | ### 2.3.0 8 | 9 | * Update to Node 20 and Discord.js 14. Update a million dependencies 10 | * Fix empty attachment bug (#61) 11 | 12 | ### 2.2.0 13 | 14 | * Add a `clean` option flag to the `/train` command to allow retraining without overwriting 15 | * Add the ability to train from file of messages (#31) 16 | 17 | ### 2.1.1 18 | 19 | * Fix TTS not working for slash commands (with a somewhat janky solution) 20 | * Update dependencies (discord.js 13.7) 21 | 22 | ### 2.1.0 23 | 24 | * Update dependencies (typeorm 0.3) 25 | * Dockerfile optimization 26 | 27 | ### 2.0.1 28 | 29 | * Add a filter to ensure the bot doesn't just post exact previous messages. 30 | 31 | ### 2.0.0 32 | 33 | #### Breaking Changes 34 | 35 | * Config option `prefix` renamed to `messageCommandPrefix` 36 | * Config option `game` renamed to `activity` 37 | * Config option `role` renamed to `userRoleIds`. Changed from string to array of strings. 38 | * Docker internal volume path moved from `/usr/src/markbot/config` to `/usr/app/config` 39 | * Database changed from JSON files to a SQLite database. You'll need to re-train the bot to use it again. 40 | * The bot must be explicitly granted permission to listen to a list of channels before using it. Configure it with `/listen`. 41 | * Docker user changed from `root` to `node`. You may need to update your mounted volume's permissions. 42 | * 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. 43 | 44 | #### New Features 45 | 46 | * Data is stored in a relational database to reduce memory and disk read/write usage, as well as to decrease latency 47 | * The bot can be restricted to only learn/listen from a strict list of channels 48 | * Bot responses can be seeded by a short phrase 49 | * Discord slash command support 50 | * Discord thread support 51 | * Many new config options available at 52 | * Owner IDs 53 | * Log level 54 | * Slash command name 55 | * Config file supports [JSON5](https://json5.org/) (comments, trailing commas, etc). It also may use the `.json5` file extension if you prefer. 56 | * Generated responses will now never ping a user or role, only just highlight their name 57 | 58 | ### 0.7.3 59 | 60 | * Fix crash when fetched messages is empty 61 | * Update docs 62 | * Update dependencies 63 | 64 | ### 0.7.2 65 | 66 | * Fix @everyone replacement 67 | 68 | ### 0.7.1 69 | 70 | * Readme updates 71 | * Config loading fix 72 | * Fix min score 73 | * Add generator options to config 74 | * Document Node 12 update 75 | 76 | ### 0.7.0 77 | 78 | * Convert project to Typescript 79 | * Optimize Docker build (smaller image) 80 | * Load corpus from filesystem to reduce memory load 81 | 82 | ### 0.6.2 83 | 84 | * Fix MarkovDB not loading on boot 85 | 86 | ### 0.6.1 87 | 88 | * Fix bot crashing on scheduled regen 89 | 90 | ### 0.6.0 91 | 92 | * Added Docker deploy functionality. 93 | * Moved config and database to `./config` directory. Existing configs will be migrated. 94 | * Config-less support via bot token located in an environment variable. 95 | * Update dependencies. 96 | * Change corpus regen time to 4 AM. 97 | 98 | ### 0.5.0 99 | 100 | * Fixed bug where `!mark help` didn't work. 101 | * Only admins can train. 102 | * The bot responds when mentioned. 103 | * The bot cannot mention @everyone. 104 | * Added version number to help. 105 | * Added `!mark tts` for a quieter TTS response. 106 | * Readme overhaul. 107 | * Simpler config loading. 108 | 109 | ### 0.4.0 110 | 111 | * Huge refactor. 112 | * Added `!mark debug` which sends debug info alongside the message. 113 | * Converted the fetchMessages function to async/await (updating the requirement to Node.js 8). 114 | * Updated module versions. 115 | * Added faster unique-array-by-property function 116 | * Added linting and linted the project. 117 | 118 | ### 0.3.0 119 | 120 | * Added TTS support and random message attachments. 121 | * Deleted messages no longer persist in the database longer than 24 hours. 122 | 123 | ### 0.2.0 124 | 125 | * Updated training algorithm and data structure. 126 | -------------------------------------------------------------------------------- /src/deploy-commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SlashCommandChannelOption, 3 | SlashCommandBuilder, 4 | ChannelType, 5 | Routes, 6 | REST, 7 | } from 'discord.js'; 8 | import { config } from './config'; 9 | import { packageJson } from './util'; 10 | 11 | export const CHANNEL_OPTIONS_MAX = 25; 12 | 13 | export const helpCommand = new SlashCommandBuilder() 14 | .setName('help') 15 | .setDescription(`How to use ${packageJson().name}`); 16 | 17 | export const inviteCommand = new SlashCommandBuilder() 18 | .setName('invite') 19 | .setDescription('Get the invite link for this bot.'); 20 | 21 | export const messageCommand = new SlashCommandBuilder() 22 | .setName(config.slashCommandName) 23 | .setDescription('Generate a message from learned past messages') 24 | .addBooleanOption((tts) => 25 | tts.setName('tts').setDescription('Read the message via text-to-speech.').setRequired(false), 26 | ) 27 | .addBooleanOption((debug) => 28 | debug 29 | .setName('debug') 30 | .setDescription('Follow up the generated message with the detailed sources that inspired it.') 31 | .setRequired(false), 32 | ) 33 | .addStringOption((seed) => 34 | seed 35 | .setName('seed') 36 | .setDescription( 37 | `A ${config.stateSize}-word phrase to attempt to start a generated sentence with.`, 38 | ) 39 | .setRequired(false), 40 | ); 41 | 42 | /** 43 | * Helps generate a list of parameters for channel options 44 | */ 45 | const channelOptionsGenerator = (builder: SlashCommandChannelOption, index: number) => 46 | builder 47 | .setName(`channel-${index + 1}`) 48 | .setDescription('A text channel') 49 | .setRequired(index === 0) 50 | .addChannelTypes(ChannelType.GuildText); 51 | 52 | export const listenChannelCommand = new SlashCommandBuilder() 53 | .setName('listen') 54 | .setDescription('Change what channels the bot actively listens to and learns from.') 55 | .addSubcommand((sub) => { 56 | sub 57 | .setName('add') 58 | .setDescription( 59 | `Add channels to learn from. Doesn't add the channel's past messages; re-train to do that.`, 60 | ); 61 | Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).forEach((index) => 62 | sub.addChannelOption((opt) => channelOptionsGenerator(opt, index)), 63 | ); 64 | return sub; 65 | }) 66 | .addSubcommand((sub) => { 67 | sub 68 | .setName('remove') 69 | .setDescription( 70 | `Remove channels from being learned from. Doesn't remove the channel's data; re-train to do that.`, 71 | ); 72 | Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).forEach((index) => 73 | sub.addChannelOption((opt) => channelOptionsGenerator(opt, index)), 74 | ); 75 | return sub; 76 | }) 77 | .addSubcommand((sub) => 78 | sub 79 | .setName('list') 80 | .setDescription(`List the channels the bot is currently actively listening to.`), 81 | ) 82 | .addSubcommand((sub) => 83 | sub 84 | .setName('modify') 85 | .setDescription(`Add or remove channels via select menu UI (first 25 text channels only)`), 86 | ); 87 | 88 | export const trainCommand = new SlashCommandBuilder() 89 | .setName('train') 90 | .setDescription( 91 | 'Train from past messages from the configured listened channels. This takes a while.', 92 | ) 93 | .addBooleanOption((clean) => 94 | clean 95 | .setName('clean') 96 | .setDescription( 97 | 'Whether the database should be emptied before training. Default is true (recommended).', 98 | ) 99 | .setRequired(false), 100 | ) 101 | .addAttachmentOption((json) => 102 | json 103 | .setName('json') 104 | .setDescription('Train from a provided JSON file rather than channel history.') 105 | .setRequired(false), 106 | ); 107 | 108 | const commands = [ 109 | helpCommand.toJSON(), 110 | inviteCommand.toJSON(), 111 | messageCommand.toJSON(), 112 | listenChannelCommand.toJSON(), 113 | trainCommand.toJSON(), 114 | ]; 115 | 116 | export async function deployCommands(clientId: string) { 117 | const rest = new REST({ version: '10' }).setToken(config.token); 118 | if (config.devGuildId) { 119 | await rest.put(Routes.applicationGuildCommands(clientId, config.devGuildId), { 120 | body: commands, 121 | }); 122 | } else { 123 | await rest.put(Routes.applicationCommands(clientId), { body: commands }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/config/classes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function, no-useless-constructor, max-classes-per-file */ 2 | import 'reflect-metadata'; 3 | import { Type } from 'class-transformer'; 4 | import { 5 | IsString, 6 | IsOptional, 7 | IsEnum, 8 | IsArray, 9 | IsInt, 10 | IsDefined, 11 | IsNotEmpty, 12 | } from 'class-validator'; 13 | 14 | export enum LogLevel { 15 | SILENT = 'silent', 16 | ERROR = 'error', 17 | WARN = 'warn', 18 | INFO = 'info', 19 | DEBUG = 'debug', 20 | TRACE = 'trace', 21 | } 22 | 23 | /** 24 | * The config file supports [JSON5](https://json5.org/) syntax. It supports both `.json` and `.json5` extensions if you prefer one over the other. 25 | * @example ```jsonc 26 | * { 27 | * "token": "k5NzE2NDg1MTIwMjc0ODQ0Nj.DSnXwg.ttNotARealToken5p3WfDoUxhiH", 28 | * "commandPrefix": "!mark", 29 | * "activity": "\"!mark help\" for help", 30 | * "ownerIds": ["00000000000000000"], 31 | * "logLevel": "info", 32 | * } 33 | * ``` 34 | */ 35 | export class AppConfig { 36 | /** 37 | * Your Discord bot token 38 | * @example k5NzE2NDg1MTIwMjc0ODQ0Nj.DSnXwg.ttNotARealToken5p3WfDoUxhiH 39 | * @env TOKEN 40 | */ 41 | @IsDefined() 42 | @IsString() 43 | @IsNotEmpty() 44 | token = process.env.TOKEN || ''; 45 | 46 | /** 47 | * The command prefix used to trigger the bot commands (when not using slash commands) 48 | * @example !bot 49 | * @default !mark 50 | * @env MESSAGE_COMMAND_PREFIX 51 | */ 52 | @IsOptional() 53 | @IsString() 54 | messageCommandPrefix = process.env.MESSAGE_COMMAND_PREFIX || '!mark'; 55 | 56 | /** 57 | * The slash command name to generate a message from the bot. (e.g. `/mark`) 58 | * @example message 59 | * @default mark 60 | * @env SLASH_COMMAND_NAME 61 | */ 62 | @IsOptional() 63 | @IsString() 64 | slashCommandName = process.env.SLASH_COMMAND_NAME || 'mark'; 65 | 66 | /** 67 | * The activity status shown under the bot's name in the user list 68 | * @example "!mark help" for help 69 | * @default !mark help 70 | * @env ACTIVITY 71 | */ 72 | @IsOptional() 73 | @IsString() 74 | activity = process.env.ACTIVITY || '!mark help'; 75 | 76 | /** 77 | * A list of Discord user IDs that have owner permissions for the bot 78 | * @example ["82684276755136512"] 79 | * @default [] 80 | * @env OWNER_IDS (comma separated) 81 | */ 82 | @IsArray() 83 | @IsString({ each: true }) 84 | @Type(() => String) 85 | @IsOptional() 86 | ownerIds = process.env.OWNER_IDS ? process.env.OWNER_IDS.split(',').map((id) => id.trim()) : []; 87 | 88 | /** 89 | * If provided, the standard "generate response" command will only work for a user in this list of role IDs. 90 | * Moderators and owners configured in `ownerIds` do not bypass this check, so make sure to add them to a valid role as well. 91 | * @example ["734548250895319070"] 92 | * @default [] 93 | * @env USER_ROLE_IDS (comma separated) 94 | */ 95 | @IsArray() 96 | @IsString({ each: true }) 97 | @Type(() => String) 98 | @IsOptional() 99 | userRoleIds = process.env.USER_ROLE_IDS 100 | ? process.env.USER_ROLE_IDS.split(',').map((id) => id.trim()) 101 | : []; 102 | 103 | /** 104 | * TZ name from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List 105 | * @example America/Chicago 106 | * @default UTC 107 | * @env TZ 108 | */ 109 | @IsOptional() 110 | @IsString() 111 | timezone = process.env.TZ || 'UTC'; 112 | 113 | /** 114 | * Log level in lower case. Can be [silent, error, warn, info, debug, trace] 115 | * @example debug 116 | * @default info 117 | * @env LOG_LEVEL 118 | */ 119 | @IsOptional() 120 | @IsEnum(LogLevel) 121 | logLevel = process.env.LOG_LEVEL || LogLevel.INFO; 122 | 123 | /** 124 | * The stateSize is the number of words for each "link" of the generated sentence. 125 | * 1 will output gibberish sentences without much sense. 126 | * 2 is a sensible default for most cases. 127 | * 3 and more can create good sentences if you have a corpus that allows it. 128 | * @example 3 129 | * @default 2 130 | * @env STATE_SIZE 131 | */ 132 | @IsOptional() 133 | @IsInt() 134 | stateSize = process.env.STATE_SIZE ? parseInt(process.env.STATE_SIZE, 10) : 2; 135 | 136 | /** 137 | * The number of tries the sentence generator will try before giving up 138 | * @example 2000 139 | * @default 1000 140 | * @env MAX_TRIES 141 | */ 142 | @IsOptional() 143 | @IsInt() 144 | maxTries = process.env.MAX_TRIES ? parseInt(process.env.MAX_TRIES, 10) : 1000; 145 | 146 | /** 147 | * The minimum score required when generating a sentence. 148 | * A relative "score" based on the number of possible permutations. 149 | * Higher is "better", but the actual value depends on your corpus. 150 | * @example 15 151 | * @default 10 152 | * @env MIN_SCORE 153 | */ 154 | @IsOptional() 155 | @IsInt() 156 | minScore = process.env.MIN_SCORE ? parseInt(process.env.MIN_SCORE, 10) : 10; 157 | 158 | /** 159 | * This guild ID should be declared if you want its commands to update immediately during development 160 | * @example 1234567890 161 | * @env DEV_GUILD_ID 162 | */ 163 | @IsOptional() 164 | @IsString() 165 | devGuildId = process.env.DEV_GUILD_ID; 166 | } 167 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'source-map-support/register'; 2 | import 'reflect-metadata'; 3 | import * as Discord from 'discord.js'; 4 | import Markov, { 5 | MarkovGenerateOptions, 6 | MarkovConstructorOptions, 7 | AddDataProps, 8 | } from 'markov-strings-db'; 9 | import { DataSource } from 'typeorm'; 10 | import { MarkovInputData } from 'markov-strings-db/dist/src/entity/MarkovInputData'; 11 | import type { PackageJsonPerson } from 'types-package-json'; 12 | import makeEta from 'simple-eta'; 13 | import formatDistanceToNow from 'date-fns/formatDistanceToNow'; 14 | import addSeconds from 'date-fns/addSeconds'; 15 | import L from './logger'; 16 | import { Channel } from './entity/Channel'; 17 | import { Guild } from './entity/Guild'; 18 | import { config } from './config'; 19 | import { 20 | CHANNEL_OPTIONS_MAX, 21 | deployCommands, 22 | helpCommand, 23 | inviteCommand, 24 | listenChannelCommand, 25 | messageCommand, 26 | trainCommand, 27 | } from './deploy-commands'; 28 | import { getRandomElement, getVersion, packageJson } from './util'; 29 | import ormconfig from './ormconfig'; 30 | 31 | interface MarkovDataCustom { 32 | attachments: string[]; 33 | } 34 | 35 | interface SelectMenuChannel { 36 | id: string; 37 | listen?: boolean; 38 | name?: string; 39 | } 40 | 41 | interface IRefreshUrlsRes { 42 | refreshed_urls: Array<{ 43 | original: string; 44 | refreshed: string; 45 | }>; 46 | } 47 | 48 | /** 49 | * Reply options that can be used in both MessageOptions and InteractionReplyOptions 50 | */ 51 | type AgnosticReplyOptions = Omit; 52 | 53 | const INVALID_PERMISSIONS_MESSAGE = 'You do not have the permissions for this action.'; 54 | const INVALID_GUILD_MESSAGE = 'This action must be performed within a server.'; 55 | 56 | const rest = new Discord.REST({ version: '10' }).setToken(config.token); 57 | 58 | const client = new Discord.Client({ 59 | failIfNotExists: false, 60 | intents: [Discord.GatewayIntentBits.GuildMessages, Discord.GatewayIntentBits.Guilds], 61 | presence: { 62 | activities: [ 63 | { 64 | type: Discord.ActivityType.Playing, 65 | name: config.activity, 66 | url: packageJson().homepage, 67 | }, 68 | ], 69 | }, 70 | }); 71 | 72 | const markovOpts: MarkovConstructorOptions = { 73 | stateSize: config.stateSize, 74 | }; 75 | 76 | const markovGenerateOptions: MarkovGenerateOptions = { 77 | filter: (result): boolean => { 78 | return ( 79 | result.score >= config.minScore && !result.refs.some((ref) => ref.string === result.string) 80 | ); 81 | }, 82 | maxTries: config.maxTries, 83 | }; 84 | 85 | async function refreshCdnUrl(url: string): Promise { 86 | // Thank you https://github.com/ShufflePerson/Discord_CDN 87 | const resp = (await rest.post(`/attachments/refresh-urls`, { 88 | body: { attachment_urls: [url] }, 89 | })) as IRefreshUrlsRes; 90 | return resp.refreshed_urls[0].refreshed; 91 | } 92 | 93 | async function getMarkovByGuildId(guildId: string): Promise { 94 | const markov = new Markov({ id: guildId, options: { ...markovOpts, id: guildId } }); 95 | L.trace({ guildId }, 'Setting up markov instance'); 96 | await markov.setup(); // Connect the markov instance to the DB to assign it an ID 97 | return markov; 98 | } 99 | 100 | /** 101 | * Returns a thread channels parent guild channel ID, otherwise it just returns a channel ID 102 | */ 103 | function getGuildChannelId(channel: Discord.TextBasedChannel): string | null { 104 | if (channel.isThread()) { 105 | return channel.parentId; 106 | } 107 | return channel.id; 108 | } 109 | 110 | async function isValidChannel(channel: Discord.TextBasedChannel): Promise { 111 | const channelId = getGuildChannelId(channel); 112 | if (!channelId) return false; 113 | const dbChannel = await Channel.findOneBy({ id: channelId }); 114 | return dbChannel?.listen || false; 115 | } 116 | 117 | function isHumanAuthoredMessage(message: Discord.Message | Discord.PartialMessage): boolean { 118 | return !(message.author?.bot || message.system); 119 | } 120 | 121 | async function getValidChannels(guild: Discord.Guild): Promise { 122 | L.trace('Getting valid channels from database'); 123 | const dbChannels = await Channel.findBy({ guild: { id: guild.id }, listen: true }); 124 | L.trace({ dbChannels: dbChannels.map((c) => c.id) }, 'Valid channels from database'); 125 | const channels = ( 126 | await Promise.all( 127 | dbChannels.map(async (dbc) => { 128 | const channelId = dbc.id; 129 | try { 130 | return guild.channels.fetch(channelId); 131 | } catch (err) { 132 | L.error({ erroredChannel: dbc, channelId }, 'Error fetching channel'); 133 | throw err; 134 | } 135 | }), 136 | ) 137 | ).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel); 138 | return channels; 139 | } 140 | 141 | async function getTextChannels(guild: Discord.Guild): Promise { 142 | L.trace('Getting text channels for select menu'); 143 | const MAX_SELECT_OPTIONS = 25; 144 | const textChannels = guild.channels.cache.filter( 145 | (c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel, 146 | ); 147 | const foundDbChannels = await Channel.findByIds(Array.from(textChannels.keys())); 148 | const foundDbChannelsWithName: SelectMenuChannel[] = foundDbChannels.map((c) => ({ 149 | ...c, 150 | name: textChannels.find((t) => t.id === c.id)?.name, 151 | })); 152 | const notFoundDbChannels: SelectMenuChannel[] = textChannels 153 | .filter((c) => !foundDbChannels.find((d) => d.id === c.id)) 154 | .map((c) => ({ id: c.id, listen: false, name: textChannels.find((t) => t.id === c.id)?.name })); 155 | const limitedDbChannels = foundDbChannelsWithName 156 | .concat(notFoundDbChannels) 157 | .slice(0, MAX_SELECT_OPTIONS); 158 | return limitedDbChannels; 159 | } 160 | 161 | async function addValidChannels(channels: Discord.TextChannel[], guildId: string): Promise { 162 | L.trace(`Adding ${channels.length} channels to valid list`); 163 | const dbChannels = channels.map((c) => { 164 | return Channel.create({ id: c.id, guild: Guild.create({ id: guildId }), listen: true }); 165 | }); 166 | await Channel.save(dbChannels); 167 | } 168 | 169 | async function removeValidChannels( 170 | channels: Discord.TextChannel[], 171 | guildId: string, 172 | ): Promise { 173 | L.trace(`Removing ${channels.length} channels from valid list`); 174 | const dbChannels = channels.map((c) => { 175 | return Channel.create({ id: c.id, guild: Guild.create({ id: guildId }), listen: false }); 176 | }); 177 | await Channel.save(dbChannels); 178 | } 179 | 180 | /** 181 | * Checks if the author of a command has moderator-like permissions. 182 | * @param {GuildMember} member Sender of the message 183 | * @return {Boolean} True if the sender is a moderator. 184 | * 185 | */ 186 | function isModerator( 187 | member: Discord.GuildMember | Discord.APIInteractionGuildMember | null, 188 | ): boolean { 189 | const MODERATOR_PERMISSIONS: Discord.PermissionResolvable[] = [ 190 | 'Administrator', 191 | 'ManageChannels', 192 | 'KickMembers', 193 | 'MoveMembers', 194 | ]; 195 | if (!member) return false; 196 | if (member instanceof Discord.GuildMember) { 197 | return ( 198 | MODERATOR_PERMISSIONS.some((p) => member.permissions.has(p)) || 199 | config.ownerIds.includes(member.id) 200 | ); 201 | } 202 | // TODO: How to parse API permissions? 203 | L.debug({ permissions: member.permissions }); 204 | return true; 205 | } 206 | 207 | /** 208 | * Checks if the author of a command has a role in the `userRoleIds` config option (if present). 209 | * @param {GuildMember} member Sender of the message 210 | * @return {Boolean} True if the sender is a moderator. 211 | * 212 | */ 213 | function isAllowedUser( 214 | member: Discord.GuildMember | Discord.APIInteractionGuildMember | null, 215 | ): boolean { 216 | if (!config.userRoleIds.length) return true; 217 | if (!member) return false; 218 | if (member instanceof Discord.GuildMember) { 219 | return config.userRoleIds.some((p) => member.roles.cache.has(p)); 220 | } 221 | // TODO: How to parse API permissions? 222 | L.debug({ permissions: member.permissions }); 223 | return true; 224 | } 225 | 226 | type MessageCommands = 'respond' | 'train' | 'help' | 'invite' | 'debug' | 'tts' | null; 227 | 228 | /** 229 | * Reads a new message and checks if and which command it is. 230 | * @param {Message} message Message to be interpreted as a command 231 | * @return {String} Command string 232 | */ 233 | function validateMessage(message: Discord.Message): MessageCommands { 234 | const messageText = message.content.toLowerCase(); 235 | let command: MessageCommands = null; 236 | const thisPrefix = messageText.substring(0, config.messageCommandPrefix.length); 237 | if (thisPrefix === config.messageCommandPrefix) { 238 | const split = messageText.split(' '); 239 | if (split[0] === config.messageCommandPrefix && split.length === 1) { 240 | command = 'respond'; 241 | } else if (split[1] === 'train') { 242 | command = 'train'; 243 | } else if (split[1] === 'help') { 244 | command = 'help'; 245 | } else if (split[1] === 'invite') { 246 | command = 'invite'; 247 | } else if (split[1] === 'debug') { 248 | command = 'debug'; 249 | } else if (split[1] === 'tts') { 250 | command = 'tts'; 251 | } 252 | } 253 | return command; 254 | } 255 | 256 | function messageToData(message: Discord.Message): AddDataProps { 257 | const attachmentUrls = message.attachments.map((a) => a.url); 258 | let custom: MarkovDataCustom | undefined; 259 | if (attachmentUrls.length) custom = { attachments: attachmentUrls }; 260 | const tags: string[] = [message.id]; 261 | if (message.channel.isThread()) tags.push(message.channelId); // Add thread channel ID 262 | const channelId = getGuildChannelId(message.channel); 263 | if (channelId) tags.push(channelId); // Add guild channel ID 264 | if (message.guildId) tags.push(message.guildId); // Add guild ID 265 | return { 266 | string: message.content, 267 | custom, 268 | tags, 269 | }; 270 | } 271 | 272 | /** 273 | * Recursively gets all messages in a text channel's history. 274 | */ 275 | async function saveGuildMessageHistory( 276 | interaction: Discord.Message | Discord.CommandInteraction, 277 | clean = true, 278 | ): Promise { 279 | if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE; 280 | if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE; 281 | const markov = await getMarkovByGuildId(interaction.guildId); 282 | const channels = await getValidChannels(interaction.guild); 283 | 284 | if (!channels.length) { 285 | L.warn({ guildId: interaction.guildId }, 'No channels to train from'); 286 | return 'No channels configured to learn from. Set some with `/listen add`.'; 287 | } 288 | 289 | if (clean) { 290 | L.debug('Deleting old data'); 291 | await markov.delete(); 292 | } else { 293 | L.debug('Not deleting old data during training'); 294 | } 295 | 296 | const channelIds = channels.map((c) => c.id); 297 | L.debug({ channelIds }, `Training from text channels`); 298 | 299 | const messageContent = `Parsing past messages from ${channels.length} channel(s).`; 300 | 301 | const NO_COMPLETED_CHANNELS_TEXT = 'None'; 302 | const completedChannelsField: Discord.APIEmbedField = { 303 | name: 'Completed Channels', 304 | value: NO_COMPLETED_CHANNELS_TEXT, 305 | inline: true, 306 | }; 307 | const currentChannelField: Discord.APIEmbedField = { 308 | name: 'Current Channel', 309 | value: `<#${channels[0].id}>`, 310 | inline: true, 311 | }; 312 | const currentChannelPercent: Discord.APIEmbedField = { 313 | name: 'Channel Progress', 314 | value: '0%', 315 | inline: true, 316 | }; 317 | const currentChannelEta: Discord.APIEmbedField = { 318 | name: 'Channel Time Remaining', 319 | value: 'Pending...', 320 | inline: true, 321 | }; 322 | const embedOptions: Discord.EmbedData = { 323 | title: 'Training Progress', 324 | fields: [completedChannelsField, currentChannelField, currentChannelPercent, currentChannelEta], 325 | }; 326 | const embed = new Discord.EmbedBuilder(embedOptions); 327 | let progressMessage: Discord.Message; 328 | const updateMessageData = { content: messageContent, embeds: [embed] }; 329 | if (interaction instanceof Discord.Message) { 330 | progressMessage = await interaction.reply(updateMessageData); 331 | } else { 332 | progressMessage = (await interaction.followUp(updateMessageData)) as Discord.Message; 333 | } 334 | 335 | const PAGE_SIZE = 100; 336 | const UPDATE_RATE = 1000; // In number of messages processed 337 | let lastUpdate = 0; 338 | let messagesCount = 0; 339 | let firstMessageDate: number | undefined; 340 | // eslint-disable-next-line no-restricted-syntax 341 | for (const channel of channels) { 342 | let oldestMessageID: string | undefined; 343 | let keepGoing = true; 344 | L.debug({ channelId: channel.id, messagesCount }, `Training from channel`); 345 | const channelCreateDate = channel.createdTimestamp; 346 | const channelEta = makeEta({ autostart: true, min: 0, max: 1, historyTimeConstant: 30 }); 347 | 348 | while (keepGoing) { 349 | let allBatchMessages = new Discord.Collection>(); 350 | let channelBatchMessages: Discord.Collection>; 351 | try { 352 | // eslint-disable-next-line no-await-in-loop 353 | channelBatchMessages = await channel.messages.fetch({ 354 | before: oldestMessageID, 355 | limit: PAGE_SIZE, 356 | }); 357 | } catch (err) { 358 | L.error(err); 359 | L.error( 360 | `Error retreiving messages before ${oldestMessageID} in channel ${channel.name}. This is probably a permissions issue.`, 361 | ); 362 | break; // Give up on this channel 363 | } 364 | 365 | // Gather any thread messages if present in this message batch 366 | const threadChannels = channelBatchMessages 367 | .filter((m) => m.hasThread) 368 | .map((m) => m.thread) 369 | .filter((c): c is Discord.AnyThreadChannel => c !== null); 370 | if (threadChannels.length > 0) { 371 | L.debug(`Found ${threadChannels.length} threads. Reading into them.`); 372 | // eslint-disable-next-line no-restricted-syntax 373 | for (const threadChannel of threadChannels) { 374 | let oldestThreadMessageID: string | undefined; 375 | let keepGoingThread = true; 376 | L.debug({ channelId: threadChannel.id }, `Training from thread`); 377 | 378 | while (keepGoingThread) { 379 | let threadBatchMessages: Discord.Collection>; 380 | try { 381 | // eslint-disable-next-line no-await-in-loop 382 | threadBatchMessages = await threadChannel.messages.fetch({ 383 | before: oldestThreadMessageID, 384 | limit: PAGE_SIZE, 385 | }); 386 | } catch (err) { 387 | L.error(err); 388 | L.error( 389 | `Error retreiving thread messages before ${oldestThreadMessageID} in thread ${threadChannel.name}. This is probably a permissions issue.`, 390 | ); 391 | break; // Give up on this thread 392 | } 393 | L.trace( 394 | { threadMessagesCount: threadBatchMessages.size }, 395 | `Found some thread messages`, 396 | ); 397 | const lastThreadMessage = threadBatchMessages.last(); 398 | allBatchMessages = allBatchMessages.concat(threadBatchMessages); // Add the thread messages to this message batch to be included in later processing 399 | if (!lastThreadMessage?.id || threadBatchMessages.size < PAGE_SIZE) { 400 | keepGoingThread = false; 401 | } else { 402 | oldestThreadMessageID = lastThreadMessage.id; 403 | } 404 | } 405 | } 406 | } 407 | 408 | allBatchMessages = allBatchMessages.concat(channelBatchMessages); 409 | 410 | // Filter and data map messages to be ready for addition to the corpus 411 | const humanAuthoredMessages = allBatchMessages 412 | .filter((m) => isHumanAuthoredMessage(m)) 413 | .map(messageToData); 414 | L.trace({ oldestMessageID }, `Saving ${humanAuthoredMessages.length} messages`); 415 | // eslint-disable-next-line no-await-in-loop 416 | await markov.addData(humanAuthoredMessages); 417 | L.trace('Finished saving messages'); 418 | messagesCount += humanAuthoredMessages.length; 419 | const lastMessage = channelBatchMessages.last(); 420 | 421 | // Update tracking metrics 422 | if (!lastMessage?.id || channelBatchMessages.size < PAGE_SIZE) { 423 | keepGoing = false; 424 | const channelIdListItem = ` • <#${channel.id}>`; 425 | if (completedChannelsField.value === NO_COMPLETED_CHANNELS_TEXT) 426 | completedChannelsField.value = channelIdListItem; 427 | else { 428 | completedChannelsField.value += `\n${channelIdListItem}`; 429 | } 430 | } else { 431 | oldestMessageID = lastMessage.id; 432 | } 433 | currentChannelField.value = `<#${channel.id}>`; 434 | if (!firstMessageDate) firstMessageDate = channelBatchMessages.first()?.createdTimestamp; 435 | const oldestMessageDate = lastMessage?.createdTimestamp; 436 | if (firstMessageDate && oldestMessageDate) { 437 | const channelAge = firstMessageDate - channelCreateDate; 438 | const lastMessageAge = firstMessageDate - oldestMessageDate; 439 | const pctComplete = lastMessageAge / channelAge; 440 | currentChannelPercent.value = `${(pctComplete * 100).toFixed(2)}%`; 441 | channelEta.report(pctComplete); 442 | const estimateSeconds = channelEta.estimate(); 443 | if (Number.isFinite(estimateSeconds)) 444 | currentChannelEta.value = formatDistanceToNow(addSeconds(new Date(), estimateSeconds), { 445 | includeSeconds: true, 446 | }); 447 | } 448 | 449 | if (messagesCount > lastUpdate + UPDATE_RATE) { 450 | lastUpdate = messagesCount; 451 | L.debug( 452 | { messagesCount, pctComplete: currentChannelPercent.value }, 453 | 'Sending metrics update', 454 | ); 455 | // eslint-disable-next-line no-await-in-loop 456 | await progressMessage.edit({ 457 | ...updateMessageData, 458 | embeds: [new Discord.EmbedBuilder(embedOptions)], 459 | }); 460 | } 461 | } 462 | } 463 | 464 | L.info({ channelIds }, `Trained from ${messagesCount} past human authored messages.`); 465 | return `Trained from ${messagesCount} past human authored messages.`; 466 | } 467 | 468 | interface JSONImport { 469 | message: string; 470 | attachments?: string[]; 471 | } 472 | 473 | /** 474 | * Train from an attached JSON file 475 | */ 476 | async function trainFromAttachmentJson( 477 | attachmentUrl: string, 478 | interaction: Discord.CommandInteraction, 479 | clean = true, 480 | ): Promise { 481 | if (!isModerator(interaction.member)) return INVALID_PERMISSIONS_MESSAGE; 482 | if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE; 483 | const { guildId } = interaction; 484 | const markov = await getMarkovByGuildId(guildId); 485 | 486 | let trainingData: AddDataProps[]; 487 | try { 488 | const getResp = await fetch(attachmentUrl); 489 | if (!getResp.ok) throw new Error(getResp.statusText); 490 | const importData = (await getResp.json()) as JSONImport[]; 491 | 492 | trainingData = importData.map((datum, index) => { 493 | if (!datum.message) { 494 | throw new Error(`Entry at index ${index} must have a "message"`); 495 | } 496 | if (typeof datum.message !== 'string') { 497 | throw new Error(`Entry at index ${index} must have a "message" with a type of string`); 498 | } 499 | if (datum.attachments?.every((a) => typeof a !== 'string')) { 500 | throw new Error( 501 | `Entry at index ${index} must have all "attachments" each with a type of string`, 502 | ); 503 | } 504 | let custom: MarkovDataCustom | undefined; 505 | if (datum.attachments?.length) custom = { attachments: datum.attachments }; 506 | return { 507 | string: datum.message, 508 | custom, 509 | tags: [guildId], 510 | }; 511 | }); 512 | } catch (err) { 513 | L.error(err); 514 | return 'The provided attachment file has invalid formatting. See the logs for details.'; 515 | } 516 | 517 | if (clean) { 518 | L.debug('Deleting old data'); 519 | await markov.delete(); 520 | } else { 521 | L.debug('Not deleting old data during training'); 522 | } 523 | 524 | await markov.addData(trainingData); 525 | 526 | L.info(`Trained from ${trainingData.length} past human authored messages.`); 527 | return `Trained from ${trainingData.length} past human authored messages.`; 528 | } 529 | 530 | interface GenerateResponse { 531 | message?: AgnosticReplyOptions; 532 | debug?: AgnosticReplyOptions; 533 | error?: AgnosticReplyOptions; 534 | } 535 | 536 | interface GenerateOptions { 537 | tts?: boolean; 538 | debug?: boolean; 539 | startSeed?: string; 540 | } 541 | 542 | /** 543 | * General Markov-chain response function 544 | * @param interaction The message that invoked the action, used for channel info. 545 | * @param debug Sends debug info as a message if true. 546 | * @param tts If the message should be sent as TTS. Defaults to the TTS setting of the 547 | * invoking message. 548 | */ 549 | async function generateResponse( 550 | interaction: Discord.Message | Discord.CommandInteraction, 551 | options?: GenerateOptions, 552 | ): Promise { 553 | L.debug({ options }, 'Responding...'); 554 | const { tts = false, debug = false, startSeed } = options || {}; 555 | if (!interaction.guildId) { 556 | L.warn('Received an interaction without a guildId'); 557 | return { error: { content: INVALID_GUILD_MESSAGE } }; 558 | } 559 | if (!isAllowedUser(interaction.member)) { 560 | L.info('Member does not have permissions to generate a response'); 561 | return { error: { content: INVALID_PERMISSIONS_MESSAGE } }; 562 | } 563 | const markov = await getMarkovByGuildId(interaction.guildId); 564 | 565 | try { 566 | markovGenerateOptions.startSeed = startSeed; 567 | const response = await markov.generate(markovGenerateOptions); 568 | L.info({ string: response.string }, 'Generated response text'); 569 | L.debug({ response }, 'Generated response object'); 570 | const messageOpts: AgnosticReplyOptions = { 571 | tts, 572 | allowedMentions: { repliedUser: false, parse: [] }, 573 | }; 574 | const attachmentUrls = response.refs 575 | .filter((ref) => ref.custom && 'attachments' in ref.custom) 576 | .flatMap((ref) => (ref.custom as MarkovDataCustom).attachments); 577 | if (attachmentUrls.length > 0) { 578 | const randomRefAttachment = getRandomElement(attachmentUrls); 579 | const refreshedUrl = await refreshCdnUrl(randomRefAttachment); 580 | messageOpts.files = [refreshedUrl]; 581 | } else { 582 | const randomMessage = await MarkovInputData.createQueryBuilder< 583 | MarkovInputData 584 | >('input') 585 | .leftJoinAndSelect('input.markov', 'markov') 586 | .where({ markov: markov.db }) 587 | .orderBy('RANDOM()') 588 | .limit(1) 589 | .getOne(); 590 | const randomMessageAttachmentUrls = randomMessage?.custom?.attachments; 591 | if (randomMessageAttachmentUrls?.length) { 592 | const attachmentUrl = getRandomElement(randomMessageAttachmentUrls); 593 | const refreshedUrl = await refreshCdnUrl(attachmentUrl); 594 | messageOpts.files = [{ attachment: refreshedUrl }]; 595 | } 596 | } 597 | messageOpts.content = response.string; 598 | 599 | const responseMessages: GenerateResponse = { 600 | message: messageOpts, 601 | }; 602 | if (debug) { 603 | responseMessages.debug = { 604 | content: `\`\`\`\n${JSON.stringify(response, null, 2)}\n\`\`\``, 605 | allowedMentions: { repliedUser: false, parse: [] }, 606 | }; 607 | } 608 | return responseMessages; 609 | } catch (err) { 610 | L.error(err); 611 | return { 612 | error: { 613 | content: `\n\`\`\`\nERROR: ${err}\n\`\`\``, 614 | allowedMentions: { repliedUser: false, parse: [] }, 615 | }, 616 | }; 617 | } 618 | } 619 | 620 | async function listValidChannels(interaction: Discord.CommandInteraction): Promise { 621 | if (!interaction.guildId || !interaction.guild) return INVALID_GUILD_MESSAGE; 622 | const channels = await getValidChannels(interaction.guild); 623 | const channelText = channels.reduce((list, channel) => { 624 | return `${list}\n • <#${channel.id}>`; 625 | }, ''); 626 | return `This bot is currently listening and learning from ${channels.length} channel(s).${channelText}`; 627 | } 628 | 629 | function getChannelsFromInteraction( 630 | interaction: Discord.ChatInputCommandInteraction, 631 | ): Discord.TextChannel[] { 632 | const channels = Array.from(Array(CHANNEL_OPTIONS_MAX).keys()).map((index) => 633 | interaction.options.getChannel(`channel-${index + 1}`, index === 0), 634 | ); 635 | const textChannels = channels.filter( 636 | (c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel, 637 | ); 638 | return textChannels; 639 | } 640 | 641 | function helpMessage(): AgnosticReplyOptions { 642 | const avatarURL = client.user.avatarURL() || undefined; 643 | const embed = new Discord.EmbedBuilder() 644 | .setAuthor({ 645 | name: client.user.username || packageJson().name, 646 | iconURL: avatarURL, 647 | }) 648 | .setThumbnail(avatarURL as string) 649 | .setDescription( 650 | `A Markov chain chatbot that speaks based on learned messages from previous chat input.`, 651 | ) 652 | .addFields([ 653 | { 654 | name: `${config.messageCommandPrefix} or /${messageCommand.name}`, 655 | value: `Generates a sentence to say based on the chat database. Send your message as TTS to recieve it as TTS.`, 656 | }, 657 | 658 | { 659 | name: `/${listenChannelCommand.name}`, 660 | value: `Add, remove, list, or modify the list of channels the bot listens to.`, 661 | }, 662 | 663 | { 664 | name: `${config.messageCommandPrefix} train or /${trainCommand.name}`, 665 | value: `Fetches the maximum amount of previous messages in the listened to text channels. This takes some time.`, 666 | }, 667 | 668 | { 669 | name: `${config.messageCommandPrefix} invite or /${inviteCommand.name}`, 670 | value: `Post this bot's invite URL.`, 671 | }, 672 | 673 | { 674 | name: `${config.messageCommandPrefix} debug or /${messageCommand.name} debug: True`, 675 | value: `Runs the ${config.messageCommandPrefix} command and follows it up with debug info.`, 676 | }, 677 | 678 | { 679 | name: `${config.messageCommandPrefix} tts or /${messageCommand.name} tts: True`, 680 | value: `Runs the ${config.messageCommandPrefix} command and reads it with text-to-speech.`, 681 | }, 682 | ]) 683 | .setFooter({ 684 | text: `${packageJson().name} ${getVersion()} by ${ 685 | (packageJson().author as PackageJsonPerson).name 686 | }`, 687 | }); 688 | return { 689 | embeds: [embed], 690 | }; 691 | } 692 | 693 | function generateInviteUrl(): string { 694 | return client.generateInvite({ 695 | scopes: [Discord.OAuth2Scopes.Bot, Discord.OAuth2Scopes.ApplicationsCommands], 696 | permissions: [ 697 | 'ViewChannel', 698 | 'SendMessages', 699 | 'SendTTSMessages', 700 | 'AttachFiles', 701 | 'ReadMessageHistory', 702 | ], 703 | }); 704 | } 705 | 706 | function inviteMessage(): AgnosticReplyOptions { 707 | const avatarURL = client.user.avatarURL() || undefined; 708 | const inviteUrl = generateInviteUrl(); 709 | const embed = new Discord.EmbedBuilder() 710 | .setAuthor({ name: `Invite ${client.user?.username}`, iconURL: avatarURL }) 711 | .setThumbnail(avatarURL as string) 712 | .addFields([ 713 | { name: 'Invite', value: `[Invite ${client.user.username} to your server](${inviteUrl})` }, 714 | ]); 715 | return { embeds: [embed] }; 716 | } 717 | 718 | async function handleResponseMessage( 719 | generatedResponse: GenerateResponse, 720 | message: Discord.Message, 721 | ): Promise { 722 | if (generatedResponse.message) await message.reply(generatedResponse.message); 723 | if (generatedResponse.debug) await message.reply(generatedResponse.debug); 724 | if (generatedResponse.error) await message.reply(generatedResponse.error); 725 | } 726 | 727 | async function handleUnprivileged( 728 | interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction, 729 | deleteReply = true, 730 | ): Promise { 731 | if (deleteReply) await interaction.deleteReply(); 732 | await interaction.followUp({ content: INVALID_PERMISSIONS_MESSAGE, ephemeral: true }); 733 | } 734 | 735 | async function handleNoGuild( 736 | interaction: Discord.CommandInteraction | Discord.SelectMenuInteraction, 737 | deleteReply = true, 738 | ): Promise { 739 | if (deleteReply) await interaction.deleteReply(); 740 | await interaction.followUp({ content: INVALID_GUILD_MESSAGE, ephemeral: true }); 741 | } 742 | 743 | client.on('ready', async (readyClient) => { 744 | L.info({ inviteUrl: generateInviteUrl() }, 'Bot logged in'); 745 | 746 | await deployCommands(readyClient.user.id); 747 | 748 | const guildsToSave = readyClient.guilds.valueOf().map((guild) => Guild.create({ id: guild.id })); 749 | 750 | // Remove the duplicate commands 751 | if (!config.devGuildId) { 752 | await Promise.all(readyClient.guilds.valueOf().map(async (guild) => guild.commands.set([]))); 753 | } 754 | await Guild.upsert(guildsToSave, ['id']); 755 | }); 756 | 757 | client.on('guildCreate', async (guild) => { 758 | L.info({ guildId: guild.id }, 'Adding new guild'); 759 | await Guild.upsert(Guild.create({ id: guild.id }), ['id']); 760 | }); 761 | 762 | client.on('debug', (m) => L.trace(m)); 763 | client.on('warn', (m) => L.warn(m)); 764 | client.on('error', (m) => L.error(m)); 765 | 766 | client.on('messageCreate', async (message) => { 767 | if ( 768 | !( 769 | message.guild && 770 | (message.channel instanceof Discord.TextChannel || 771 | message.channel instanceof Discord.ThreadChannel) 772 | ) 773 | ) 774 | return; 775 | const command = validateMessage(message); 776 | if (command !== null) L.info({ command }, 'Recieved message command'); 777 | if (command === 'help') { 778 | await message.channel.send(helpMessage()); 779 | } 780 | if (command === 'invite') { 781 | await message.channel.send(inviteMessage()); 782 | } 783 | if (command === 'train') { 784 | const response = await saveGuildMessageHistory(message); 785 | await message.reply(response); 786 | } 787 | if (command === 'respond') { 788 | L.debug('Responding to legacy command'); 789 | const generatedResponse = await generateResponse(message); 790 | await handleResponseMessage(generatedResponse, message); 791 | } 792 | if (command === 'tts') { 793 | L.debug('Responding to legacy command tts'); 794 | const generatedResponse = await generateResponse(message, { tts: true }); 795 | await handleResponseMessage(generatedResponse, message); 796 | } 797 | if (command === 'debug') { 798 | L.debug('Responding to legacy command debug'); 799 | const generatedResponse = await generateResponse(message, { debug: true }); 800 | await handleResponseMessage(generatedResponse, message); 801 | } 802 | if (command === null) { 803 | if (isHumanAuthoredMessage(message)) { 804 | if (client.user && message.mentions.has(client.user)) { 805 | L.debug('Responding to mention'); 806 | // <@!278354154563567636> how are you doing? 807 | const startSeed = message.content.replace(/<@!\d+>/g, '').trim(); 808 | const generatedResponse = await generateResponse(message, { startSeed }); 809 | await handleResponseMessage(generatedResponse, message); 810 | } 811 | 812 | if (await isValidChannel(message.channel)) { 813 | L.debug('Listening'); 814 | const markov = await getMarkovByGuildId(message.channel.guildId); 815 | await markov.addData([messageToData(message)]); 816 | } 817 | } 818 | } 819 | }); 820 | 821 | client.on('messageDelete', async (message) => { 822 | if (!isHumanAuthoredMessage(message)) return; 823 | if (!(await isValidChannel(message.channel))) return; 824 | if (!message.guildId) return; 825 | 826 | L.debug(`Deleting message ${message.id}`); 827 | const markov = await getMarkovByGuildId(message.guildId); 828 | await markov.removeTags([message.id]); 829 | }); 830 | 831 | client.on('messageUpdate', async (oldMessage, newMessage) => { 832 | if (!isHumanAuthoredMessage(oldMessage)) return; 833 | if (!(await isValidChannel(oldMessage.channel))) return; 834 | if (!(oldMessage.guildId && newMessage.content)) return; 835 | 836 | L.debug(`Editing message ${oldMessage.id}`); 837 | const markov = await getMarkovByGuildId(oldMessage.guildId); 838 | await markov.removeTags([oldMessage.id]); 839 | await markov.addData([newMessage.content]); 840 | }); 841 | 842 | client.on('threadDelete', async (thread) => { 843 | if (!(await isValidChannel(thread))) return; 844 | if (!thread.guildId) return; 845 | 846 | L.debug(`Deleting thread messages ${thread.id}`); 847 | const markov = await getMarkovByGuildId(thread.guildId); 848 | await markov.removeTags([thread.id]); 849 | }); 850 | 851 | // eslint-disable-next-line consistent-return 852 | client.on('interactionCreate', async (interaction) => { 853 | if (interaction.isChatInputCommand()) { 854 | L.info({ command: interaction.commandName }, 'Recieved slash command'); 855 | 856 | if (interaction.commandName === helpCommand.name) { 857 | await interaction.reply(helpMessage()); 858 | } else if (interaction.commandName === inviteCommand.name) { 859 | await interaction.reply(inviteMessage()); 860 | } else if (interaction.commandName === messageCommand.name) { 861 | await interaction.deferReply(); 862 | const tts = interaction.options.getBoolean('tts') || false; 863 | const debug = interaction.options.getBoolean('debug') || false; 864 | const startSeed = interaction.options.getString('seed')?.trim() || undefined; 865 | const generatedResponse = await generateResponse(interaction, { tts, debug, startSeed }); 866 | 867 | /** 868 | * TTS doesn't work when using editReply, so instead we use delete + followUp 869 | * However, delete + followUp is ugly and shows the bot replying to "Message could not be loaded.", 870 | * so we avoid it if possible 871 | */ 872 | if (generatedResponse.message) { 873 | if (generatedResponse.message.tts) { 874 | await interaction.deleteReply(); 875 | await interaction.followUp(generatedResponse.message); 876 | } else { 877 | await interaction.editReply(generatedResponse.message); 878 | } 879 | } else { 880 | await interaction.deleteReply(); 881 | } 882 | if (generatedResponse.debug) await interaction.followUp(generatedResponse.debug); 883 | if (generatedResponse.error) { 884 | await interaction.followUp({ ...generatedResponse.error, ephemeral: true }); 885 | } 886 | } else if (interaction.commandName === listenChannelCommand.name) { 887 | await interaction.deferReply(); 888 | const subCommand = interaction.options.getSubcommand(true) as 'add' | 'remove' | 'list'; 889 | if (subCommand === 'list') { 890 | const reply = await listValidChannels(interaction); 891 | await interaction.editReply(reply); 892 | } else if (subCommand === 'add') { 893 | if (!isModerator(interaction.member)) { 894 | return handleUnprivileged(interaction); 895 | } 896 | if (!interaction.guildId) { 897 | return handleNoGuild(interaction); 898 | } 899 | const channels = getChannelsFromInteraction(interaction); 900 | await addValidChannels(channels, interaction.guildId); 901 | await interaction.editReply( 902 | `Added ${channels.length} text channels to the list. Use \`/train\` to update the past known messages.`, 903 | ); 904 | } else if (subCommand === 'remove') { 905 | if (!isModerator(interaction.member)) { 906 | return handleUnprivileged(interaction); 907 | } 908 | if (!interaction.guildId) { 909 | return handleNoGuild(interaction); 910 | } 911 | const channels = getChannelsFromInteraction(interaction); 912 | await removeValidChannels(channels, interaction.guildId); 913 | await interaction.editReply( 914 | `Removed ${channels.length} text channels from the list. Use \`/train\` to remove these channels from the past known messages.`, 915 | ); 916 | } else if (subCommand === 'modify') { 917 | if (!interaction.guild) { 918 | return handleNoGuild(interaction); 919 | } 920 | if (!isModerator(interaction.member)) { 921 | await handleUnprivileged(interaction); 922 | } 923 | await interaction.deleteReply(); 924 | const dbTextChannels = await getTextChannels(interaction.guild); 925 | const row = new Discord.ActionRowBuilder().addComponents( 926 | new Discord.StringSelectMenuBuilder() 927 | .setCustomId('listen-modify-select') 928 | .setPlaceholder('Nothing selected') 929 | .setMinValues(0) 930 | .setMaxValues(dbTextChannels.length) 931 | .addOptions( 932 | dbTextChannels.map((c) => ({ 933 | label: `#${c.name}` || c.id, 934 | value: c.id, 935 | default: c.listen || false, 936 | })), 937 | ), 938 | ); 939 | 940 | await interaction.followUp({ 941 | content: 'Select which channels you would like to the bot to actively listen to', 942 | components: [row], 943 | ephemeral: true, 944 | }); 945 | } 946 | } else if (interaction.commandName === trainCommand.name) { 947 | await interaction.deferReply(); 948 | const clean = interaction.options.getBoolean('clean') ?? true; 949 | const trainingJSON = interaction.options.getAttachment('json'); 950 | 951 | if (trainingJSON) { 952 | const responseMessage = await trainFromAttachmentJson(trainingJSON.url, interaction, clean); 953 | await interaction.followUp(responseMessage); 954 | } else { 955 | const reply = (await interaction.fetchReply()) as Discord.Message; // Must fetch the reply ASAP 956 | const responseMessage = await saveGuildMessageHistory(interaction, clean); 957 | // Send a message in reply to the reply to avoid the 15 minute webhook token timeout 958 | await reply.reply({ content: responseMessage }); 959 | } 960 | } 961 | } else if (interaction.isStringSelectMenu()) { 962 | if (interaction.customId === 'listen-modify-select') { 963 | await interaction.deferUpdate(); 964 | const { guild } = interaction; 965 | if (!isModerator(interaction.member)) { 966 | return handleUnprivileged(interaction, false); 967 | } 968 | if (!guild) { 969 | return handleNoGuild(interaction, false); 970 | } 971 | 972 | const allChannels = 973 | (interaction.component as Discord.StringSelectMenuComponent).options?.map((o) => o.value) || 974 | []; 975 | const selectedChannelIds = interaction.values; 976 | 977 | const textChannels = ( 978 | await Promise.all( 979 | allChannels.map(async (c) => { 980 | return guild.channels.fetch(c); 981 | }), 982 | ) 983 | ).filter((c): c is Discord.TextChannel => c !== null && c instanceof Discord.TextChannel); 984 | const unselectedChannels = textChannels.filter((t) => !selectedChannelIds.includes(t.id)); 985 | const selectedChannels = textChannels.filter((t) => selectedChannelIds.includes(t.id)); 986 | await addValidChannels(selectedChannels, guild.id); 987 | await removeValidChannels(unselectedChannels, guild.id); 988 | 989 | await interaction.followUp({ 990 | content: 'Updated actively listened to channels list.', 991 | ephemeral: true, 992 | }); 993 | } 994 | } 995 | }); 996 | 997 | /** 998 | * Loads the config settings from disk 999 | */ 1000 | async function main(): Promise { 1001 | const dataSourceOptions = Markov.extendDataSourceOptions(ormconfig); 1002 | const dataSource = new DataSource(dataSourceOptions); 1003 | await dataSource.initialize(); 1004 | await client.login(config.token); 1005 | } 1006 | 1007 | main(); 1008 | --------------------------------------------------------------------------------