├── .eslintignore ├── .eslintrc.json ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── main.yml │ ├── renovate-config-validator.yml │ └── schema.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config-sample.json ├── config-schema.json ├── filter.txt ├── meta.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── ai.ts ├── command.ts ├── config.ts ├── index.ts ├── message-like.ts ├── meta.json ├── misskey │ ├── channel.ts │ ├── index.ts │ ├── reaction.ts │ ├── timeline.ts │ ├── user.ts │ └── visibility.ts ├── module.ts ├── modules │ ├── admin.ts │ ├── auto-follow.ts │ ├── dice.ts │ ├── emoji-list.ts │ ├── greeting.ts │ ├── index.ts │ ├── kakariuke-graph.ts │ ├── markov-speaking │ │ ├── database.ts │ │ ├── databases │ │ │ ├── flexible-database.ts │ │ │ ├── index.ts │ │ │ └── onlyone-database.ts │ │ ├── index.ts │ │ └── word-filter.ts │ ├── math.ts │ ├── othello-redirect.ts │ ├── ping.ts │ ├── random-choice.ts │ ├── suru.ts │ └── sushi.ts ├── stream.ts └── util │ └── assert-property.ts ├── tsconfig.json └── update.sh /.eslintignore: -------------------------------------------------------------------------------- 1 | /built 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Add semi-colons to the entire codebase 2 | # Replace tab indent with space indent 3 | c319a7f46d2d6dd26486afe1231956b297a145dc 4 | 0e874a199e65347baaff5ed0c486e9481c3a0541 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Compile 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: 'npm' 20 | 21 | - name: Install Dependencies 22 | run: npm install 23 | 24 | - name: Run lint 25 | run: npm run lint 26 | 27 | - name: Run prettier 28 | run: npm run lint:prettier 29 | 30 | - name: Run test 31 | run: npm test 32 | -------------------------------------------------------------------------------- /.github/workflows/renovate-config-validator.yml: -------------------------------------------------------------------------------- 1 | name: Validate renovate.json 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | renovate-config-validator: 11 | 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repo 15 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: 'npm' 21 | 22 | - name: Install Renovate CLI 23 | run: npm install -g renovate 24 | 25 | - name: Validate renovate.json 26 | run: renovate-config-validator 27 | -------------------------------------------------------------------------------- /.github/workflows/schema.yml: -------------------------------------------------------------------------------- 1 | name: Check schema diff 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | prettier: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: 'npm' 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Generate schema 25 | run: npm run schema 26 | 27 | - name: Check diff 28 | run: git diff --exit-code config-schema.json 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | built/ 2 | db/* 3 | config.json 4 | 5 | node_modules/ 6 | .node_modules/ 7 | built/* 8 | tests/cases/rwc/* 9 | tests/cases/test262/* 10 | tests/cases/perf/* 11 | !tests/cases/webharness/compilerToString.js 12 | test-args.txt 13 | ~*.docx 14 | \#*\# 15 | .\#* 16 | tests/baselines/local/* 17 | tests/baselines/local.old/* 18 | tests/services/baselines/local/* 19 | tests/baselines/prototyping/local/* 20 | tests/baselines/rwc/* 21 | tests/baselines/test262/* 22 | tests/baselines/reference/projectOutput/* 23 | tests/baselines/local/projectOutput/* 24 | tests/baselines/reference/testresults.tap 25 | tests/services/baselines/prototyping/local/* 26 | tests/services/browser/typescriptServices.js 27 | src/harness/*.js 28 | src/compiler/diagnosticInformationMap.generated.ts 29 | src/compiler/diagnosticMessages.generated.json 30 | src/parser/diagnosticInformationMap.generated.ts 31 | src/parser/diagnosticMessages.generated.json 32 | rwc-report.html 33 | *.swp 34 | build.json 35 | *.actual 36 | tests/webTestServer.js 37 | tests/webTestServer.js.map 38 | tests/webhost/*.d.ts 39 | tests/webhost/webtsc.js 40 | tests/cases/**/*.js 41 | tests/cases/**/*.js.map 42 | *.config 43 | scripts/debug.bat 44 | scripts/run.bat 45 | scripts/word2md.js 46 | scripts/buildProtocol.js 47 | scripts/ior.js 48 | scripts/authors.js 49 | scripts/configurePrerelease.js 50 | scripts/open-user-pr.js 51 | scripts/processDiagnosticMessages.d.ts 52 | scripts/processDiagnosticMessages.js 53 | scripts/produceLKG.js 54 | scripts/importDefinitelyTypedTests/importDefinitelyTypedTests.js 55 | scripts/generateLocalizedDiagnosticMessages.js 56 | scripts/*.js.map 57 | scripts/typings/ 58 | coverage/ 59 | internal/ 60 | **/.DS_Store 61 | .settings 62 | **/.vs 63 | **/.vscode 64 | !**/.vscode/tasks.json 65 | !tests/cases/projects/projectOption/**/node_modules 66 | !tests/cases/projects/NodeModulesSearch/**/* 67 | !tests/baselines/reference/project/nodeModules*/**/* 68 | .idea 69 | yarn.lock 70 | yarn-error.log 71 | .parallelperf.* 72 | tests/cases/user/*/package-lock.json 73 | tests/cases/user/*/node_modules/ 74 | tests/cases/user/*/**/*.js 75 | tests/cases/user/*/**/*.js.map 76 | tests/cases/user/*/**/*.d.ts 77 | !tests/cases/user/zone.js/ 78 | !tests/cases/user/bignumber.js/ 79 | !tests/cases/user/discord.js/ 80 | tests/baselines/reference/dt 81 | .failed-tests 82 | TEST-results.xml -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .*ignore 2 | *.txt 3 | LICENSE 4 | *.sh 5 | yarn.lock 6 | /built 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | 2.2.2 (2020/10/19) 4 | 5 | - update dependencies 6 | - learning was all disabled due to the bug of `mecab-node` 7 | - The WebSocket connection is automatically reconnected every 1 hour now 8 | - たまに接続された状態を保っているのにサーバー側から全く情報が流れてこなくなるときがあるので 9 | 10 | --- 11 | 12 | ### ✨Improvements 13 | 14 | - learning notes posted with "followers" visibility can be disabled now by #9 15 | - linting for this repository is now automatically done by #10 16 | 17 | 2.2.1 (2020/10/05) 18 | 19 | --- 20 | 21 | ### ✨Improvements 22 | 23 | - learning notes posted with "followers" visibility can be disabled now by #9 24 | - linting for this repository is now automatically done by #10 25 | 26 | 2.2.0 (2020/09/19) 27 | 28 | --- 29 | 30 | ### ✨Improvements 31 | 32 | - Configuable `delay` 33 | - bot が返信するまでの間隔を、`config.json` の `delay` を変更することで弄れるようになりました。 34 | - Pagination of `/emoji` is now implemented 35 | 36 | 2.1.9 (2020/09/11) 37 | 38 | --- 39 | 40 | ### 🐛Bug fix 41 | 42 | - Update `node-cabocha` 43 | 44 | - for details, please check [this issue](https://github.com/fourseasonslab/node-cabocha/issues/1). 45 | 46 | 2.1.8 (2020/09/06) 47 | 48 | --- 49 | 50 | ### ✨Improvements 51 | 52 | - Changed how resetting the learned database works by adding `attenuationRate` configuration 53 | 54 | - 減衰率の値を今までの出現回数に掛けられた値の小数点以下切り捨てが新たなデータベースにおける出現回数となります。その値が 0 になったときは、それに対応する 3-gram の組は削除されます。 55 | 56 | 2.1.7 (2020/08/19) 57 | 58 | --- 59 | 60 | ### 🐛Bug fix 61 | 62 | - Visibility setting on the interval posts is working correctly now 63 | 64 | 2.1.6 (2020/08/18) 65 | 66 | --- 67 | 68 | ### ✨Improvements 69 | 70 | - Add `allowLearnCW` configuration 71 | 72 | - もしこの設定が偽であるならば、CW が指定された投稿が学習されないようになります。 73 | 74 | 2.1.3 (2020/05/05) 75 | 76 | --- 77 | 78 | ### ✨Improvements 79 | 80 | - Add `headers` configuration 81 | 82 | - config.json で `headers: {"X-xxx": "value"}` のように指定すれば、サーバー側の API (WebSocket 除く)を叩く際にそのヘッダーをリクエストに載せるようになりました。 83 | 84 | 2.1.2 (2020/02/08) 85 | 86 | --- 87 | 88 | ### ✨Improvements 89 | 90 | - Add `/markov delete` command 91 | - config.json で Op に指定されているアカウントならば、 `/markov delete` コマンドを用いることによって与えられた文章に含まれる形態素を含むチェーンをすべて削除することができます。 92 | - 注意点として、分かち書きされた結果含まれている助詞なども作用の対象となりますし、 MeCab 側の解析結果に依存する機能のため現在のデータベースを参考にして形態素解析の結果を予測してから利用することをおすすめします(係り受けモジュールを用いるのも一つの手かもしれません) 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2019 private-yusuke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # botot2 2 | 3 | [![Compile](https://github.com/private-yusuke/botot2/workflows/Compile/badge.svg)](https://github.com/private-yusuke/botot2/actions?query=workflow%3A"Compile") 4 | 5 | Misskey インスタンス上で動作するチャットボットです。 6 | 7 | - 日本語の文章を形態素解析して学習し、リプライが飛んできたら支離滅裂な返答をします。 8 | - 与えられた日本語の文章の係り受け木を描画します。 9 | - 与えられた Asciimath または LaTeX 形式の数式を描画します。 10 | 11 | A chatbot that works on Misskey instances. 12 | 13 | ## Install 14 | 15 | > You need to have MeCab and CaboCha installed on your computer. 16 | 17 | 1. `$ git clone https://github.com/private-yusuke/botot2` 18 | 2. `$ cp config-sample.json config.json` 19 | 3. `$ mkdir db` 20 | 4. `$ nano config.json` 21 | - replace `i` with your token 22 | 5. `$ npm install` 23 | 6. `$ npm run build` 24 | 7. `$ npm start` 25 | - You can use `forever start --killSignal=SIGINT built/` instead. 26 | 27 | ## Modules 28 | 29 | | 内部名(name) | 説明(Description) | 30 | | ---------------- | ------------------------------------------------------------------------------------------------ | 31 | | admin | 管理者向けモジュール (module for administrator) | 32 | | auto-follow | フォロバ自動化 (automatic follow-back) | 33 | | dice | サイコロをふる (roll dices) | 34 | | emoji-list | インスタンスに登録された絵文字の列挙 (emoji list) | 35 | | greeting | 挨拶を返す (respond with greeting) | 36 | | kakariuke-graph | 係り受け木の描画 (render the structure of Japanese sentence) | 37 | | markov-speaking | 学習して返答する (learn Japanese sentences and generate replies)
contains filtering feature | 38 | | math | 数式の描画 (render Asciimath, LaTeX), AsciiMath -> LaTeX conversion | 39 | | othello-redirect | contains "othello" -> reply "cc: @ai" | 40 | | ping | /ping -> pong! | 41 | | sushi | ランダム絵文字 (respond with a random emoji) | 42 | 43 | 各モジュールは`config.json`でオンオフの設定ができます。 44 | Each module can be either enabled or disabled by modifying `config.json`. 45 | 46 | `config.json`のその他の設定については、各モジュールのソースコードを参照してください。 47 | For other settings in `config.json`, please see the source codes of each module. 48 | 49 | --- 50 | 51 | Issue や Pull Request は大歓迎!気づいたことがあれば、ぜひ積極的に教えてください。 52 | We appreciate your issues and pull requests! If you have noticed something, please tell me asap. 53 | 54 | Twitter: [@public_yusuke](https://twitter.com/public_yusuke) 55 | -------------------------------------------------------------------------------- /config-sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/private-yusuke/botot2/master/config-schema.json", 3 | "i": "!xxxxxxxxxxx", 4 | "host": "misskey.xyz", 5 | "timeline": "hybrid", 6 | "connectionTimeout": 5000, 7 | "intervalPost": true, 8 | "intervalPostDuration": { 9 | "value": 1, 10 | "unit": "hour" 11 | }, 12 | "postMaxCharacterCount": 1000, 13 | "modules": [ 14 | "autoFollow", 15 | "othelloRedirect", 16 | "markovSpeaking", 17 | "greeting", 18 | "ping", 19 | "admin", 20 | "emojiList", 21 | "dice", 22 | "math", 23 | "kakariukeGraph", 24 | "sushi", 25 | "randomChoice", 26 | "suru" 27 | ], 28 | "markovSpeaking": { 29 | "allowLearn": true, 30 | "allowLearnCW": true, 31 | "allowLearnVisFollowers": true, 32 | "blocked": [ 33 | "somebody@example.com" 34 | ], 35 | "filtering": true, 36 | "wordFilterURL": "http://monoroch.net/kinshi/housouKinshiYougo.xml", 37 | "wordFilterFiles": [ 38 | "./filter.txt" 39 | ], 40 | "wordFilterLog": true 41 | }, 42 | "math": { 43 | "size": 20 44 | }, 45 | "op": [ 46 | "somebody@example.com" 47 | ], 48 | "database": { 49 | "path": "db/triplets_db.json", 50 | "type": "onlyOne", 51 | "saveFrequency": 5, 52 | "saveDuration": { 53 | "value": 30, 54 | "unit": "minute" 55 | }, 56 | "maxSize": 1e6, 57 | "attenuationRate": 0.5 58 | }, 59 | "sentenceLengthRange": { 60 | "start": 1, 61 | "end": 1 62 | }, 63 | "mecab": { 64 | "commandOptions": "" 65 | }, 66 | "visibility": "home", 67 | "cwStart": 600, 68 | "delay": 2000, 69 | "suru": { 70 | "yes": [ 71 | "はい", 72 | "いいよ", 73 | "やれ", 74 | "やりましょう", 75 | "やろ", 76 | "OK", 77 | "いいですよ", 78 | "今すぐやりなさい", 79 | "しよう" 80 | ], 81 | "no": [ 82 | "だめ", 83 | "だ~め", 84 | "いいえ", 85 | "やるな", 86 | "やってはいけません", 87 | "NG", 88 | "だめですよ", 89 | "まだその時ではない", 90 | "時期が悪い" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /config-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "definitions": { 4 | "Channel": { 5 | "enum": [ 6 | "drive", 7 | "gamesReversi", 8 | "gamesReversiGame", 9 | "globalTimeline", 10 | "hashtag", 11 | "homeTimeline", 12 | "hybridTimeline", 13 | "localTimeline", 14 | "main", 15 | "messaging", 16 | "messagingIndex", 17 | "notesStats", 18 | "serverStats", 19 | "userList" 20 | ], 21 | "type": "string" 22 | }, 23 | "Database": { 24 | "enum": [ 25 | "flexible", 26 | "onlyOne" 27 | ], 28 | "type": "string" 29 | }, 30 | "Timeline": { 31 | "enum": [ 32 | "global", 33 | "home", 34 | "hybrid", 35 | "local", 36 | "social" 37 | ], 38 | "type": "string" 39 | }, 40 | "Visibility": { 41 | "enum": [ 42 | "followers", 43 | "home", 44 | "private", 45 | "public", 46 | "specified" 47 | ], 48 | "type": "string" 49 | }, 50 | "moment.unitOfTime.Base": { 51 | "enum": [ 52 | "M", 53 | "d", 54 | "day", 55 | "days", 56 | "h", 57 | "hour", 58 | "hours", 59 | "m", 60 | "millisecond", 61 | "milliseconds", 62 | "minute", 63 | "minutes", 64 | "month", 65 | "months", 66 | "ms", 67 | "s", 68 | "second", 69 | "seconds", 70 | "w", 71 | "week", 72 | "weeks", 73 | "y", 74 | "year", 75 | "years" 76 | ], 77 | "type": "string" 78 | } 79 | }, 80 | "properties": { 81 | "apiURL": { 82 | "type": "string" 83 | }, 84 | "baseURL": { 85 | "type": "string" 86 | }, 87 | "connectionTimeout": { 88 | "type": "number" 89 | }, 90 | "cwStart": { 91 | "type": "number" 92 | }, 93 | "database": { 94 | "properties": { 95 | "attenuationRate": { 96 | "type": "number" 97 | }, 98 | "maxSize": { 99 | "type": "number" 100 | }, 101 | "path": { 102 | "type": "string" 103 | }, 104 | "saveDuration": { 105 | "properties": { 106 | "unit": { 107 | "$ref": "#/definitions/moment.unitOfTime.Base" 108 | }, 109 | "value": { 110 | "type": "number" 111 | } 112 | }, 113 | "type": "object" 114 | }, 115 | "saveFrequency": { 116 | "type": "number" 117 | }, 118 | "type": { 119 | "$ref": "#/definitions/Database" 120 | } 121 | }, 122 | "type": "object" 123 | }, 124 | "delay": { 125 | "type": "number" 126 | }, 127 | "headers": { 128 | "additionalProperties": { 129 | "type": "string" 130 | }, 131 | "type": "object" 132 | }, 133 | "host": { 134 | "type": "string" 135 | }, 136 | "i": { 137 | "type": "string" 138 | }, 139 | "intervalPost": { 140 | "type": "boolean" 141 | }, 142 | "intervalPostDuration": { 143 | "properties": { 144 | "unit": { 145 | "$ref": "#/definitions/moment.unitOfTime.Base" 146 | }, 147 | "value": { 148 | "type": "number" 149 | } 150 | }, 151 | "type": "object" 152 | }, 153 | "markovSpeaking": { 154 | "properties": { 155 | "allowLearn": { 156 | "type": "boolean" 157 | }, 158 | "allowLearnCW": { 159 | "type": "boolean" 160 | }, 161 | "allowLearnVisFollowers": { 162 | "type": "boolean" 163 | }, 164 | "blocked": { 165 | "items": { 166 | "type": "string" 167 | }, 168 | "type": "array" 169 | }, 170 | "filtering": { 171 | "type": "boolean" 172 | }, 173 | "wordFilterFiles": { 174 | "items": { 175 | "type": "string" 176 | }, 177 | "type": "array" 178 | }, 179 | "wordFilterLog": { 180 | "type": "boolean" 181 | }, 182 | "wordFilterURL": { 183 | "type": "string" 184 | } 185 | }, 186 | "type": "object" 187 | }, 188 | "math": { 189 | "properties": { 190 | "size": { 191 | "type": "number" 192 | } 193 | }, 194 | "type": "object" 195 | }, 196 | "mecab": { 197 | "properties": { 198 | "commandOptions": { 199 | "type": "string" 200 | } 201 | }, 202 | "type": "object" 203 | }, 204 | "modules": { 205 | "items": { 206 | "type": "string" 207 | }, 208 | "type": "array" 209 | }, 210 | "op": { 211 | "items": { 212 | "type": "string" 213 | }, 214 | "type": "array" 215 | }, 216 | "postMaxCharacterCount": { 217 | "type": "number" 218 | }, 219 | "revision": { 220 | "type": "string" 221 | }, 222 | "sentenceLengthRange": { 223 | "properties": { 224 | "end": { 225 | "type": "number" 226 | }, 227 | "start": { 228 | "type": "number" 229 | } 230 | }, 231 | "type": "object" 232 | }, 233 | "streamURL": { 234 | "type": "string" 235 | }, 236 | "suru": { 237 | "properties": { 238 | "no": { 239 | "items": { 240 | "type": "string" 241 | }, 242 | "type": "array" 243 | }, 244 | "yes": { 245 | "items": { 246 | "type": "string" 247 | }, 248 | "type": "array" 249 | } 250 | }, 251 | "type": "object" 252 | }, 253 | "timeline": { 254 | "$ref": "#/definitions/Timeline" 255 | }, 256 | "timelineChannel": { 257 | "$ref": "#/definitions/Channel" 258 | }, 259 | "version": { 260 | "type": "string" 261 | }, 262 | "visibility": { 263 | "$ref": "#/definitions/Visibility" 264 | }, 265 | "wsParams": { 266 | "additionalProperties": { 267 | "type": "string" 268 | }, 269 | "type": "object" 270 | }, 271 | "wsURL": { 272 | "type": "string" 273 | } 274 | }, 275 | "type": "object" 276 | } 277 | 278 | -------------------------------------------------------------------------------- /filter.txt: -------------------------------------------------------------------------------- 1 | // You type your desired words that you don't want the computer to learn. 2 | // Each line should only have one word. 3 | // Thus the whole sentence that contains these words won't be learned. 4 | // For example, 5 | 6 | てすとてすてすてすてすとてすて // <- A string containing this sequence won't be learned. 7 | -------------------------------------------------------------------------------- /meta.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const { execFileSync } = require("child_process"); 3 | const meta = require("./package.json"); 4 | 5 | const gitRev = execFileSync("git", ["rev-parse", "--short", "HEAD"]) 6 | .toString() 7 | .trim(); 8 | 9 | fs.writeFileSync( 10 | "./built/meta.json", 11 | JSON.stringify({ version: meta.version, revision: gitRev }), 12 | "utf-8" 13 | ); 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "botot2", 3 | "version": "4.0.1", 4 | "description": "A chatbot for Misskey with Markov chain", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node ./built", 8 | "schema": "typescript-json-schema ./src/config.ts Config -o config-schema.json", 9 | "build": "tsc && node meta.js", 10 | "lint": "eslint .", 11 | "lint:prettier": "prettier --check '**/*.{js,ts}'", 12 | "fix": "npm run fix:eslint; npm run fix:prettier", 13 | "fix:eslint": "eslint --fix .", 14 | "fix:prettier": "prettier --write '**/*.{js,ts}'", 15 | "test": "tsc" 16 | }, 17 | "author": "", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@types/node": "20.17.12", 21 | "@types/ws": "8.5.13", 22 | "@types/xml2js": "0.4.14", 23 | "eslint": "8.57.1", 24 | "eslint-config-prettier": "9.1.0", 25 | "eslint-config-standard": "17.1.0", 26 | "eslint-plugin-import": "2.31.0", 27 | "eslint-plugin-node": "11.1.0", 28 | "eslint-plugin-promise": "6.6.0", 29 | "eslint-plugin-standard": "5.0.0", 30 | "prettier": "2.8.8", 31 | "tslib": "2.8.1", 32 | "typescript-json-schema": "0.55.0" 33 | }, 34 | "dependencies": { 35 | "asciimath-to-latex": "^0.5.0", 36 | "graphviz": "0.0.9", 37 | "markov-ja": "^1.0.10", 38 | "mathjax-node": "^2.1.1", 39 | "moment": "^2.29.2", 40 | "node-cabocha": "0.0.5", 41 | "reconnecting-websocket": "^4.2.0", 42 | "request-promise-native": "^1.0.8", 43 | "svg2png": "^4.1.1", 44 | "timeout-as-promise": "^1.0.0", 45 | "typescript": "^5.0.0", 46 | "ws": "^8.0.0", 47 | "xml2js": "^0.6.0" 48 | }, 49 | "engines": { 50 | "node": ">=18.0.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "timezone": "Asia/Tokyo", 5 | "packageRules": [ 6 | { 7 | "matchDepTypes": ["devDependencies"], 8 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 9 | "automerge": true, 10 | "automergeType": "pr", 11 | "platformAutomerge": true 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/ai.ts: -------------------------------------------------------------------------------- 1 | import config from "./config"; 2 | import IModule from "./module"; 3 | import Modulez from "./modules"; 4 | import * as WebSocket from "ws"; 5 | import { User, Reaction, generateUserId, api } from "./misskey"; 6 | import * as moment from "moment"; 7 | const ReconnectingWebSocket = require("reconnecting-websocket"); 8 | import MessageLike from "./message-like"; 9 | import * as delay from "timeout-as-promise"; 10 | import { assertProperty } from "./util/assert-property"; 11 | 12 | export default class Ai { 13 | public account: User; 14 | public loadedModules: IModule[] = []; 15 | private connection: any; 16 | private isInterrupted: boolean = false; 17 | meta: any; 18 | 19 | constructor(account: User) { 20 | this.account = account; 21 | 22 | this.init(); 23 | } 24 | 25 | private moduleCandidates() { 26 | return Modulez.map((M) => new M(this)) 27 | .filter((m) => config.modules.includes(m.name)) 28 | .sort((a, b) => b.priority - a.priority); 29 | } 30 | 31 | private init() { 32 | for (let m of this.moduleCandidates()) { 33 | try { 34 | m.install(); 35 | this.loadedModules.push(m); 36 | } catch (e) { 37 | console.error(`An error has occured while loading module "${m.name}"`); 38 | console.error(e); 39 | } 40 | } 41 | console.info("loaded modules:"); 42 | this.loadedModules.forEach((m) => console.log(`${m.priority}: ${m.name}`)); 43 | 44 | this.initConnection(); 45 | console.log({ 46 | visibility: config.visibility, 47 | timelineChannel: config.timelineChannel, 48 | }); 49 | 50 | setInterval(() => { 51 | this.connection.send("ping"); 52 | if (process.env.DEBUG) console.log("ping from client"); 53 | }, moment.duration(1, "minute").asMilliseconds()); 54 | 55 | if (process.env.DEBUG) console.log("DEBUG enabled"); 56 | } 57 | 58 | private initConnection() { 59 | // config.streamURL must be string, because config.streamURL is generated in config.ts 60 | this.connection = new ReconnectingWebSocket( 61 | config.streamURL as string, 62 | [], 63 | { 64 | WebSocket: WebSocket, 65 | connectionTimeout: config.connectionTimeout || 5000, 66 | } 67 | ); 68 | 69 | this.connection.addEventListener("error", (e) => { 70 | console.error("WebSocket Error"); 71 | console.error(e); 72 | }); 73 | this.connection.addEventListener("open", async () => { 74 | console.log("WebSocket opened"); 75 | const timelineData = generateData("timeline", config.timelineChannel); 76 | const messageData = generateData("message", "messagingIndex"); 77 | const mainData = generateData("main", "main"); 78 | if (process.env.DEBUG) { 79 | console.log(timelineData); 80 | console.log(messageData); 81 | console.log(mainData); 82 | } 83 | function sleep(time: number) { 84 | return new Promise((resolve) => { 85 | setTimeout(() => resolve(), time); 86 | }); 87 | } 88 | await sleep(3000); 89 | this.connection.send(JSON.stringify(timelineData)); 90 | this.connection.send(JSON.stringify(messageData)); 91 | this.connection.send(JSON.stringify(mainData)); 92 | console.log(`WebSocket connected to ${config.timelineChannel}`); 93 | }); 94 | this.connection.addEventListener("close", () => { 95 | if (this.isInterrupted) this.connection.close(); 96 | console.log("WebSocket closed"); 97 | }); 98 | this.connection.addEventListener("message", (message) => { 99 | let msg: any = undefined; 100 | try { 101 | msg = JSON.parse(message.data); 102 | } catch { 103 | if (message.data == "pong" && process.env.DEBUG) { 104 | console.log("pong from server"); 105 | } 106 | return; 107 | } 108 | if (process.env.DEBUG) console.log(msg); 109 | this.onData(msg); 110 | }); 111 | function generateData(id: string, channel: string) { 112 | return { 113 | type: "connect", 114 | body: { 115 | id: id, 116 | channel: channel, 117 | }, 118 | }; 119 | } 120 | } 121 | 122 | private onData(msg: any) { 123 | // console.log(`${msg.body.type} from ${msg.body.id}`) 124 | switch (msg.type) { 125 | case "channel": 126 | switch (msg.body.id) { 127 | case "timeline": 128 | this.onNote(msg.body); 129 | break; 130 | case "message": 131 | if ( 132 | msg.body.type == "message" && 133 | msg.body.userId != this.account.id 134 | ) 135 | this.onMessage(msg.body.body); 136 | break; 137 | case "main": 138 | if (msg.body.type == "followed" && msg.body.id != this.account.id) 139 | this.onFollowed(msg.body.body); 140 | break; 141 | default: 142 | break; 143 | } 144 | break; 145 | default: 146 | break; 147 | } 148 | } 149 | 150 | private onNote(msg: any) { 151 | const body = msg.body; 152 | if (body.userId == this.account.id) return; 153 | const reply = body.reply || { userId: "none" }; 154 | let text = body.text || ""; 155 | let reg = text.match(/^@(.+?)\s/); 156 | if ( 157 | reply.userId == this.account.id || 158 | text == `@${this.account.username}@${this.account.host}` || 159 | (reg != null && 160 | reg[1] == `${this.account.username}@${this.account.host}` && 161 | text.startsWith(`@${this.account.username}@${this.account.host}`)) || 162 | ((!body.user.host || body.user.host == this.account.host) && 163 | (text == `@${this.account.username}` || 164 | (reg != null && 165 | reg[1] == this.account.username && 166 | text.startsWith(`@${this.account.username}`)))) 167 | ) { 168 | this.onMention(new MessageLike(body, false)); 169 | } 170 | if (body.user.isBot) return; 171 | 172 | this.loadedModules.filter(assertProperty("onNote")).forEach((m) => { 173 | return m.onNote(body); 174 | }); 175 | } 176 | 177 | private async onMention(msg: MessageLike) { 178 | if (msg.isMessage) { 179 | api("messaging/messages/read", { 180 | messageId: msg.id, 181 | }); 182 | } else { 183 | let reaction: Reaction; 184 | if (msg.user.isBot) reaction = "angry"; 185 | else reaction = "like"; 186 | await delay(config.delay); 187 | api("notes/reactions/create", { 188 | noteId: msg.id, 189 | reaction: reaction, 190 | }); 191 | } 192 | if (msg.user.isBot || msg.user.id == this.account.id || !msg.text) return; 193 | await delay(config.delay); 194 | // If the mention /some arg1 arg2 ..." 195 | let regex = new RegExp(`(?:@${this.account.username}\\s)?\\/(.+)?`, "i"); 196 | let r = msg.text.match(regex); 197 | if (r != null && r[1] != null) { 198 | console.log( 199 | `!${msg.user.name}(@${generateUserId(msg.user)}): ${msg.text}` 200 | ); 201 | let funcs = this.loadedModules.filter(assertProperty("onCommand")); 202 | let done = false; 203 | for (let i = 0; i < funcs.length; i++) { 204 | if (done) break; 205 | let res = await funcs[i].onCommand(msg, r[1].split(" ")); 206 | if (res === true || typeof res === "object") done = true; 207 | } 208 | if (!done) msg.reply("command not found"); 209 | } else { 210 | let res: ReturnType>; 211 | this.loadedModules.filter(assertProperty("onMention")).some((m) => { 212 | res = m.onMention(msg); 213 | return res === true || typeof res === "object"; 214 | }); 215 | } 216 | } 217 | 218 | private onMessage(msg: any) { 219 | this.onMention(new MessageLike(msg, true)); 220 | } 221 | 222 | private onFollowed(user: User) { 223 | this.loadedModules.filter(assertProperty("onFollowed")).forEach((m) => { 224 | return m.onFollowed(user); 225 | }); 226 | } 227 | 228 | async onInterrupt() { 229 | this.isInterrupted = true; 230 | this.connection.close(); 231 | this.loadedModules.filter(assertProperty("onInterrupted")).forEach((m) => { 232 | m.onInterrupted(); 233 | }); 234 | process.exit(0); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import Ai from "./ai"; 2 | import MessageLike from "./message-like"; 3 | import { User } from "./misskey"; 4 | 5 | export default interface ICommand { 6 | name: string; 7 | desc?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as moment from "moment"; 2 | import * as Misskey from "./misskey"; 3 | import { Database } from "./modules/markov-speaking/database"; 4 | 5 | export type Duration = { 6 | value: number; 7 | unit: moment.unitOfTime.Base; 8 | }; 9 | export type LengthRange = { 10 | start: number; 11 | end: number; 12 | }; 13 | 14 | export type Config = { 15 | i: string; 16 | host: string; 17 | headers?: { [key: string]: string }; 18 | baseURL?: string; 19 | wsURL?: string; 20 | wsParams?: { [key: string]: string }; 21 | apiURL?: string; 22 | streamURL?: string; 23 | connectionTimeout?: number; 24 | timeline: Misskey.Timeline; 25 | timelineChannel: Misskey.Channel; 26 | intervalPost: boolean; 27 | intervalPostDuration: Duration; 28 | postMaxCharacterCount: number; 29 | modules: string[]; 30 | markovSpeaking: { 31 | allowLearn: boolean; 32 | allowLearnCW: boolean; 33 | allowLearnVisFollowers: boolean; 34 | /* 35 | * If you want this bot not to learn the message 36 | * from a specified account, you can add an account 37 | * into this list to prevent from learning their posts. 38 | */ 39 | blocked: string[]; 40 | filtering: boolean; 41 | wordFilterURL: string; 42 | wordFilterFiles: string[]; 43 | wordFilterLog: boolean; 44 | }; 45 | math: { 46 | size: number; 47 | }; 48 | op: string[]; 49 | database: { 50 | path: string; 51 | type: Database; 52 | saveFrequency: number; 53 | saveDuration: Duration; 54 | 55 | maxSize: number; 56 | attenuationRate: number; 57 | }; 58 | sentenceLengthRange: LengthRange; 59 | mecab: { 60 | commandOptions?: string; 61 | }; 62 | visibility: Misskey.Visibility; 63 | cwStart: number; 64 | suru: { 65 | yes: string[]; 66 | no: string[]; 67 | }; 68 | 69 | version: string; 70 | revision: string; 71 | delay: number; 72 | }; 73 | 74 | const config = require("../config.json") as Config; 75 | const meta = require("./meta.json"); 76 | 77 | config.baseURL = `https://${config.host}`; 78 | config.wsURL = `wss://${config.host}`; 79 | config.apiURL = `${config.baseURL}/api`; 80 | 81 | const wsParams = new URLSearchParams({ i: config.i, ...config.wsParams }); 82 | config.streamURL = `${config.wsURL}/streaming?${wsParams.toString()}`; 83 | 84 | config.version = meta.version; 85 | config.revision = meta.revision; 86 | 87 | function getTimelineURL(config: Config) { 88 | switch (config.timeline) { 89 | case "home": 90 | return `${config.wsURL}/?i=${config.i}`; 91 | case "local": 92 | return `${config.wsURL}/local-timeline?i=${config.i}`; 93 | case "social": 94 | case "hybrid": 95 | return `${config.wsURL}/hybrid-timeline?i=${config.i}`; 96 | case "global": 97 | return `${config.wsURL}/global-timeline?i=${config.i}`; 98 | default: 99 | console.warn("Timeline not specified correctly, using home..."); 100 | return `${config.wsURL}/?i=${config.i}`; 101 | } 102 | } 103 | 104 | function getProperTimelineProperty(config: Config): Misskey.Channel { 105 | switch (config.timeline) { 106 | case "home": 107 | return "homeTimeline"; 108 | case "local": 109 | return "localTimeline"; 110 | case "global": 111 | return "globalTimeline"; 112 | case "hybrid": 113 | return "hybridTimeline"; 114 | case "social": 115 | console.warn( 116 | "specifying 'social' as a timeline is deprecated. using hybridTimeline." 117 | ); 118 | return "hybridTimeline"; 119 | default: 120 | console.warn("Timeline not specified correctly, using home..."); 121 | return "homeTimeline"; 122 | } 123 | } 124 | config.timelineChannel = getProperTimelineProperty(config); 125 | 126 | function getProperVisibilityProperty(config: Config) { 127 | switch (config.visibility) { 128 | case "followers": 129 | case "home": 130 | case "private": 131 | case "public": 132 | case "specified": 133 | return config.visibility; 134 | default: 135 | console.warn("Visibility not specified correctly, using home..."); 136 | return "home"; 137 | } 138 | } 139 | config.visibility = getProperVisibilityProperty(config); 140 | 141 | // * Backward compatibility 142 | if (config.database.attenuationRate == undefined) 143 | config.database.attenuationRate = 0; 144 | 145 | if (config.markovSpeaking.allowLearnVisFollowers == undefined) 146 | config.markovSpeaking.allowLearnVisFollowers = true; 147 | 148 | export default config as Config; 149 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import config from "./config"; 2 | import Ai from "./ai"; 3 | import Modulez from "./modules"; 4 | import IModule from "./module"; 5 | import { User } from "./misskey"; 6 | 7 | console.log(">>> starting... <<<"); 8 | 9 | let ai: Ai; 10 | async function main() { 11 | let tmp = await fetch(`${config.apiURL}/i`, { 12 | method: "POST", 13 | body: JSON.stringify({ 14 | i: config.i, 15 | }), 16 | headers: { 17 | "Content-Type": "application/json", 18 | ...config.headers, 19 | }, 20 | }); 21 | let me = (await tmp.json()) as User; // Force convert to misskey.User 22 | console.log(`I am ${me.name}(@${me.username})!`); 23 | console.log(`Version: ${config.version}(${config.revision})`); 24 | me.host = config.host; 25 | 26 | ai = new Ai(me); 27 | } 28 | 29 | process.on("SIGINT", async () => { 30 | console.log("Received interrupt signal, exiting..."); 31 | await ai.onInterrupt(); 32 | }); 33 | main(); 34 | -------------------------------------------------------------------------------- /src/message-like.ts: -------------------------------------------------------------------------------- 1 | import { api, User } from "./misskey"; 2 | import config from "./config"; 3 | const delay = require("timeout-as-promise"); 4 | 5 | export default class MessageLike { 6 | private messageOrNote: any; 7 | public isMessage: boolean; 8 | 9 | public get id(): string { 10 | return this.messageOrNote.id; 11 | } 12 | public get user(): User { 13 | return this.messageOrNote.user; 14 | } 15 | public get text(): string { 16 | return this.messageOrNote.text; 17 | } 18 | public get replyId(): string { 19 | return this.messageOrNote.replyId; 20 | } 21 | 22 | constructor(messageOrNote: any, isMessage: boolean) { 23 | this.messageOrNote = messageOrNote; 24 | this.isMessage = isMessage; 25 | } 26 | 27 | public async reply(text: string, cw?: string, meta?: any) { 28 | if (text == null) return null; 29 | if (cw == null && text.length > config.cwStart) cw = "Too long result"; 30 | 31 | await delay(config.delay); 32 | if (this.isMessage) { 33 | let obj = { 34 | userId: this.user.id, 35 | text: text, 36 | ...meta, 37 | }; 38 | return await (await api("messaging/messages/create", obj)).json(); 39 | } else { 40 | let a = await api("notes/create", { 41 | replyId: this.messageOrNote.id, 42 | text: text, 43 | cw: cw, 44 | visibility: this.messageOrNote.visibility, 45 | ...meta, 46 | }); 47 | return await (await a).json(); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "unknown", 3 | "revision": "unknown" 4 | } -------------------------------------------------------------------------------- /src/misskey/channel.ts: -------------------------------------------------------------------------------- 1 | type Channel = 2 | | "main" 3 | | "homeTimeline" 4 | | "localTimeline" 5 | | "hybridTimeline" 6 | | "globalTimeline" 7 | | "notesStats" 8 | | "serverStats" 9 | | "userList" 10 | | "messaging" 11 | | "messagingIndex" 12 | | "drive" 13 | | "hashtag" 14 | | "gamesReversi" 15 | | "gamesReversiGame"; 16 | export default Channel; 17 | -------------------------------------------------------------------------------- /src/misskey/index.ts: -------------------------------------------------------------------------------- 1 | import Visibility from "./visibility"; 2 | import Timeline from "./timeline"; 3 | import User from "./user"; 4 | import Reaction from "./reaction"; 5 | import Channel from "./channel"; 6 | import config from "../config"; 7 | import * as request from "request-promise-native"; 8 | 9 | function generateUserId(user: User): string { 10 | let res: string = user.username; 11 | if (user.host) res += `@${user.host}`; 12 | else res += `@${config.host}`; 13 | return res; 14 | } 15 | function isOp(user: User): boolean { 16 | return config.op.indexOf(generateUserId(user)) >= 0; 17 | } 18 | function isBlocked(user: User): boolean { 19 | return config.markovSpeaking.blocked.indexOf(generateUserId(user)) >= 0; 20 | } 21 | 22 | function api(endpoint: string, body?: any) { 23 | const url = `${config.apiURL}/${endpoint}`; 24 | const data = JSON.stringify( 25 | Object.assign( 26 | { 27 | i: config.i, 28 | }, 29 | body 30 | ) 31 | ); 32 | return fetch(url, { 33 | method: "POST", 34 | body: data, 35 | headers: { 36 | "Content-Type": "application/json", 37 | ...config.headers, 38 | }, 39 | }); 40 | } 41 | 42 | async function upload(file: Buffer, meta?: any) { 43 | const url = `${config.apiURL}/drive/files/create`; 44 | 45 | const res = await request.post({ 46 | url: url, 47 | formData: { 48 | i: config.i, 49 | file: { 50 | value: file, 51 | options: meta, 52 | }, 53 | }, 54 | json: true, 55 | headers: config.headers, 56 | }); 57 | return res; 58 | } 59 | 60 | export { 61 | Visibility, 62 | Timeline, 63 | User, 64 | Reaction, 65 | Channel, 66 | generateUserId, 67 | isOp, 68 | isBlocked, 69 | api, 70 | upload, 71 | }; 72 | -------------------------------------------------------------------------------- /src/misskey/reaction.ts: -------------------------------------------------------------------------------- 1 | type Reaction = 2 | | "like" 3 | | "love" 4 | | "laugh" 5 | | "hmm" 6 | | "surprise" 7 | | "congrats" 8 | | "angry" 9 | | "confused" 10 | | "rip" 11 | | "pudding"; 12 | export default Reaction; 13 | -------------------------------------------------------------------------------- /src/misskey/timeline.ts: -------------------------------------------------------------------------------- 1 | type Timeline = "home" | "local" | "social" | "hybrid" | "global"; 2 | export default Timeline; 3 | -------------------------------------------------------------------------------- /src/misskey/user.ts: -------------------------------------------------------------------------------- 1 | type User = { 2 | id: string; 3 | name: string; 4 | username: string; 5 | isBot: boolean; 6 | isCat: boolean; 7 | host?: string; 8 | hostLower?: string; 9 | }; 10 | export default User; 11 | -------------------------------------------------------------------------------- /src/misskey/visibility.ts: -------------------------------------------------------------------------------- 1 | type Visibility = "public" | "home" | "followers" | "specified" | "private"; 2 | export default Visibility; 3 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import MessageLike from "./message-like"; 2 | import { User } from "./misskey"; 3 | import ICommand from "./command"; 4 | 5 | export default interface IModule { 6 | name: string; 7 | priority: number; 8 | commands?: Array; 9 | install: () => void; 10 | onMention?: (msg: MessageLike) => boolean; 11 | onNote?: (note: any) => void; 12 | onFollowed?: (user: User) => void; 13 | onInterrupted?: () => void; 14 | onCommand?: (msg: MessageLike, cmd: string[]) => Promise; 15 | info?: () => string; 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/admin.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import Ai from "../ai"; 3 | import MessageLike from "../message-like"; 4 | import { isOp } from "../misskey"; 5 | import { now } from "moment"; 6 | import config from "../config"; 7 | import { assertProperty } from "../util/assert-property"; 8 | 9 | export default class AdminModule implements IModule { 10 | public readonly priority = 10; 11 | public readonly name = "admin"; 12 | public readonly commands = [ 13 | { 14 | name: "info", 15 | desc: "Display the status of the bot", 16 | }, 17 | { 18 | name: "help", 19 | desc: "Display all the comamnds with descriptions", 20 | }, 21 | { 22 | name: "halt", 23 | desc: "Shutdown the bot", 24 | }, 25 | ]; 26 | private ai: Ai; 27 | 28 | constructor(ai: Ai) { 29 | this.ai = ai; 30 | } 31 | 32 | public install() {} 33 | 34 | public getUptime(): string { 35 | return Math.floor(process.uptime()) + "s"; 36 | } 37 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 38 | if (cmd[0] == "info") { 39 | let res = `\`\`\` 40 | Modules: ${this.ai.loadedModules 41 | .map((i) => `${i.name}(${i.priority})`) 42 | .join(", ")} 43 | Uptime: ${this.getUptime()} 44 | ${process.title} ${process.version} ${process.arch} ${process.platform} 45 | Version: ${config.version}(${config.revision}) 46 | `; 47 | res += this.ai.loadedModules 48 | .filter(assertProperty("info")) 49 | .map((i) => i.info()) 50 | .join("\n"); 51 | res += "\n```"; 52 | msg.reply(res); 53 | return true; 54 | } else if (cmd[0] == "help") { 55 | let res = "```\n"; 56 | this.ai.loadedModules.forEach((v) => { 57 | if (v.commands) { 58 | v.commands.forEach((c) => { 59 | if (c.desc) res += `/${c.name}: ${c.desc}\n`; 60 | else res += `/${c.name}\n`; 61 | }); 62 | } 63 | }); 64 | res += "```"; 65 | msg.reply(res); 66 | return true; 67 | } else if (cmd[0] == "halt") { 68 | if (isOp(msg.user)) { 69 | await msg.reply(`OK trying to shutdown……\n${now().toLocaleString()}`); 70 | this.ai.onInterrupt(); 71 | } else { 72 | msg.reply("You don't have a permission to exec /halt."); 73 | } 74 | } 75 | return false; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/modules/auto-follow.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import { api } from "../misskey"; 3 | import { User } from "../misskey"; 4 | 5 | export default class AutoFollowModule implements IModule { 6 | public readonly priority = 0; 7 | public readonly name = "autoFollow"; 8 | 9 | public install() {} 10 | 11 | public onFollowed(user: User) { 12 | this.follow(user); 13 | } 14 | 15 | async follow(user: User) { 16 | try { 17 | const res = await api("following/create", { 18 | userId: user.id, 19 | }); 20 | const json = (await res.json()) as { error?: unknown }; // Force convert to { error?: unknown } 21 | if (json.error) 22 | console.log(`Following ${user.name}(@${user.username}): ${json.error}`); 23 | else console.log(`Followed user ${user.name}(@${user.username})`); 24 | return true; 25 | } catch (e) { 26 | console.error(`Failed to follow ${user.name}(@${user.username})`); 27 | return false; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/dice.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | import Ai from "../ai"; 4 | import config from "../config"; 5 | 6 | export default class DiceModule implements IModule { 7 | public readonly priority = 0; 8 | public readonly name = "dice"; 9 | public readonly commands = [ 10 | { 11 | name: "dice", 12 | desc: "3d6 -> /dice 3 6", 13 | }, 14 | ]; 15 | 16 | public install() {} 17 | 18 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 19 | if (cmd[0] == "dice") { 20 | if (cmd.length < 3) { 21 | msg.reply("Usage: /dice "); 22 | } else { 23 | let amount = Number(cmd[1]), 24 | m = Number(cmd[2]); 25 | if ( 26 | isNaN(amount) || 27 | amount <= 0 || 28 | isNaN(m) || 29 | m <= 0 || 30 | amount > 500 31 | ) { 32 | msg.reply( 33 | "Argument is invalid. (amount > 0, max > 0, amount <= 500)" 34 | ); 35 | return true; 36 | } 37 | let res: number[] = []; 38 | for (let i = 0; i < amount; i++) { 39 | res.push(Math.ceil(Math.random() * m)); 40 | } 41 | let text = `Result:\n${res.join(" ")}`; 42 | if (text.length > config.postMaxCharacterCount) 43 | text = `Too long result`; 44 | msg.reply(text); 45 | } 46 | return true; 47 | } 48 | return false; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/emoji-list.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | import { api } from "../misskey"; 4 | 5 | export default class EmojiListModule implements IModule { 6 | public readonly priority = 0; 7 | public readonly name = "emojiList"; 8 | public readonly commands = [ 9 | { 10 | name: "emoji", 11 | desc: "Display all the emojis registered in the instance.", 12 | }, 13 | ]; 14 | meta: any; 15 | 16 | public install() { 17 | api("meta") 18 | .then((meta) => meta.json()) 19 | .then((json) => (this.meta = json)) 20 | .catch((err) => console.error(err)); 21 | } 22 | 23 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 24 | if (cmd[0] == "emoji") { 25 | let emojiTexts = this.meta.emojis.map((i) => `:${i.name}:`); 26 | let maxNoteTextLength = this.meta.maxNoteTextLength; 27 | 28 | let k = 0; 29 | while (k < emojiTexts.length) { 30 | let emojiText = ""; 31 | while (true) { 32 | if (k == emojiTexts.length) break; 33 | if (emojiText.length + emojiTexts[k].length <= maxNoteTextLength) { 34 | emojiText += emojiTexts[k]; 35 | } else break; 36 | k++; 37 | } 38 | await msg.reply(emojiText, "emojis"); 39 | } 40 | return true; 41 | } 42 | return false; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/greeting.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | 4 | export default class GreetingModule implements IModule { 5 | public readonly priority = 2; 6 | public readonly name = "greeting"; 7 | 8 | public install() {} 9 | 10 | public onMention(msg: MessageLike) { 11 | if (!msg.text) return false; 12 | let m = msg.text.match(/(おはよう|こんにちは|こんばんは|おやすみ)/); 13 | if (m) { 14 | msg.reply(`${m[1]}〜!`); 15 | return true; 16 | } else return false; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/index.ts: -------------------------------------------------------------------------------- 1 | import AutoFollowModule from "./auto-follow"; 2 | import MarkovSpeakingModule from "./markov-speaking"; 3 | import OthelloRedirectModule from "./othello-redirect"; 4 | import GreetingModule from "./greeting"; 5 | import PingModule from "./ping"; 6 | import AdminModule from "./admin"; 7 | import EmojiListModule from "./emoji-list"; 8 | import DiceModule from "./dice"; 9 | import MathModule from "./math"; 10 | import KakariukeGraphModule from "./kakariuke-graph"; 11 | import SushiModule from "./sushi"; 12 | import RandomChoiceModule from "./random-choice"; 13 | import SuruModule from "./suru"; 14 | 15 | export default [ 16 | AutoFollowModule, 17 | MarkovSpeakingModule, 18 | OthelloRedirectModule, 19 | GreetingModule, 20 | PingModule, 21 | AdminModule, 22 | EmojiListModule, 23 | DiceModule, 24 | MathModule, 25 | KakariukeGraphModule, 26 | SushiModule, 27 | RandomChoiceModule, 28 | SuruModule, 29 | ]; 30 | -------------------------------------------------------------------------------- /src/modules/kakariuke-graph.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | import { upload } from "../misskey"; 4 | const Cabocha = require("node-cabocha"); 5 | const cabocha = new Cabocha(); 6 | const graphviz = require("graphviz"); 7 | 8 | export default class KakariukeGraphModule implements IModule { 9 | public readonly priority = 0; 10 | public readonly name = "kakariukeGraph"; 11 | public readonly commands = [ 12 | { 13 | name: "kakariuke(abbr: ka)", 14 | desc: "Generate a directed graph for kakariuke(Ja)", 15 | }, 16 | ]; 17 | 18 | public install() {} 19 | 20 | cabochaParsePromise(sentence: string): Promise { 21 | return new Promise((resolve) => { 22 | cabocha.parse(sentence, (result) => { 23 | resolve(result); 24 | }); 25 | }); 26 | } 27 | graphvizOutputPromise(graph: any, type: string): Promise { 28 | return new Promise((resolve, reject) => { 29 | graph.output( 30 | type, 31 | (data) => { 32 | resolve(data); 33 | }, 34 | (err) => { 35 | reject(err); 36 | } 37 | ); 38 | }); 39 | } 40 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 41 | if (cmd[0] == "kakariuke" || cmd[0] == "ka") { 42 | if (cmd.length == 1) { 43 | msg.reply("Usage: /kakariuke "); 44 | } else { 45 | let sentence = cmd.slice(1).join(""); 46 | let cabochaed = await this.cabochaParsePromise(sentence); 47 | let graph = graphviz.digraph("G"); 48 | if (cabochaed["depRels"].length > 1) { 49 | cabochaed["depRels"].forEach((chunk, index) => { 50 | if (chunk[0] != -1) { 51 | graph.addNode(`${chunk[1]}${index}`, { label: chunk[1] }); 52 | let out = cabochaed["depRels"][chunk[0]][1]; 53 | graph.addNode(`${out}${chunk[0]}`, { label: out }); 54 | graph.addEdge(`${chunk[1]}${index}`, `${out}${chunk[0]}`); 55 | } 56 | }); 57 | } else { 58 | let node = cabochaed["depRels"][0][1]; 59 | graph.addNode(node); 60 | graph.addEdge(node, node); 61 | } 62 | 63 | let resImage = await this.graphvizOutputPromise(graph, "png"); 64 | let imageRes = await upload(resImage, { 65 | filename: "graph.png", 66 | contentType: "image/png", 67 | }); 68 | 69 | if (msg.isMessage) { 70 | msg.reply("描画しました!", undefined, { 71 | fileId: imageRes.id, 72 | }); 73 | } else 74 | msg.reply("描画しました!!", undefined, { 75 | fileIds: [imageRes.id], 76 | }); 77 | } 78 | return true; 79 | } 80 | return false; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/modules/markov-speaking/database.ts: -------------------------------------------------------------------------------- 1 | import Ai from "../../ai"; 2 | import config from "../../config"; 3 | 4 | export type Database = "onlyOne" | "flexible"; 5 | export interface IDatabase { 6 | markov: any; 7 | load: () => void; 8 | save: () => void; 9 | updateSave: () => void; 10 | reset: () => void; 11 | onInterrupted: () => void; 12 | size: () => number; 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/markov-speaking/databases/flexible-database.ts: -------------------------------------------------------------------------------- 1 | import { IDatabase } from "../database"; 2 | import * as moment from "moment"; 3 | import config, { Duration } from "../../../config"; 4 | import * as fs from "fs"; 5 | 6 | export default class FlexibleDataBase implements IDatabase { 7 | public readonly markov: any; 8 | private readonly duration: Duration; 9 | private intervalObj: NodeJS.Timeout; 10 | 11 | constructor(markov: any) { 12 | this.markov = markov; 13 | this.duration = config.database.saveDuration; 14 | this.intervalObj = setInterval(() => { 15 | this.save(); 16 | }, moment.duration(this.duration.value, this.duration.unit).asMilliseconds()); 17 | } 18 | 19 | load() { 20 | try { 21 | this.markov.loadDatabase(fs.readFileSync(config.database.path), "utf-8"); 22 | } catch (e) { 23 | this.markov.loadDatabase("{}"); 24 | } 25 | } 26 | save() { 27 | fs.writeFileSync( 28 | `${config.database.path}-${moment().unix()}.json`, 29 | this.markov.exportDatabase(), 30 | "utf-8" 31 | ); 32 | console.log("database successfully saved"); 33 | } 34 | updateSave() {} 35 | reset() {} 36 | size() { 37 | return this.markov.exportDatabase().length; 38 | } 39 | 40 | onInterrupted() { 41 | clearInterval(this.intervalObj); 42 | this.save(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/markov-speaking/databases/index.ts: -------------------------------------------------------------------------------- 1 | import FlexibleDataBase from "./flexible-database"; 2 | import { IDatabase } from "../database"; 3 | import OnlyOneDatabase from "./onlyone-database"; 4 | type Database = "onlyOne" | "flexible"; 5 | 6 | export default function createDatabase(type: Database, markov: any): IDatabase { 7 | switch (type) { 8 | case "flexible": 9 | return new FlexibleDataBase(markov); 10 | case "onlyOne": 11 | return new OnlyOneDatabase(markov); 12 | default: 13 | console.warn("Database not specified, using OnlyOneDatabase..."); 14 | return new OnlyOneDatabase(markov); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/markov-speaking/databases/onlyone-database.ts: -------------------------------------------------------------------------------- 1 | import { IDatabase } from "../database"; 2 | import * as moment from "moment"; 3 | import * as fs from "fs"; 4 | import config from "../../../config"; 5 | import Ai from "../../../ai"; 6 | import { api } from "../../../misskey"; 7 | const MarkovJa = require("markov-ja"); 8 | 9 | export default class OnlyOneDatabase implements IDatabase { 10 | public readonly markov: any; 11 | private unsavedPostCount: number = 0; 12 | 13 | constructor(markov: any) { 14 | this.markov = markov; 15 | } 16 | 17 | load() { 18 | try { 19 | this.markov.loadDatabase(fs.readFileSync(config.database.path), "utf-8"); 20 | } catch (e) { 21 | this.markov.loadDatabase("{}"); 22 | } 23 | } 24 | async save() { 25 | fs.writeFileSync( 26 | config.database.path, 27 | this.markov.exportDatabase(), 28 | "utf-8" 29 | ); 30 | if (config.database.maxSize != 0) { 31 | const size = fs.statSync(config.database.path).size; 32 | if (size >= config.database.maxSize) { 33 | console.log( 34 | `database is too big. max = ${config.database.maxSize} <= size = ${size}.` 35 | ); 36 | console.log("renaming the database file."); 37 | let res = await api("notes/create", { 38 | text: `【Information】\n\`\`\`\nデータベースが所定の大きさ(${config.database.maxSize}bytes)以上になったので、データベースが初期化されました。\nmax = ${config.database.maxSize} <= size = ${size}\n\`\`\``, 39 | visibility: config.visibility, 40 | }); 41 | console.log(await res.json()); 42 | fs.renameSync( 43 | config.database.path, 44 | `${config.database.path}-${moment().unix()}.json` 45 | ); 46 | // this.markov.loadDatabase("{}") 47 | let oldTriplets: { [key: string]: number } = JSON.parse( 48 | this.markov.exportDatabase() 49 | ); 50 | let newTriplets: { [key: string]: number } = {}; 51 | for (const key of Object.keys(oldTriplets)) { 52 | let value = oldTriplets[key]; 53 | let newValue = Math.floor(value * config.database.attenuationRate); 54 | if (newValue > 0) newTriplets[key] = newValue; 55 | } 56 | console.debug(JSON.stringify(newTriplets)); 57 | this.markov.loadDatabase(JSON.stringify(newTriplets)); 58 | } 59 | } 60 | } 61 | updateSave() { 62 | this.unsavedPostCount++; 63 | if (this.unsavedPostCount >= config.database.saveFrequency) { 64 | this.save(); 65 | this.unsavedPostCount = 0; 66 | } 67 | } 68 | reset() { 69 | fs.writeFileSync( 70 | config.database.path, 71 | this.markov.exportDatabase(), 72 | "utf-8" 73 | ); 74 | fs.renameSync( 75 | config.database.path, 76 | `${config.database.path}-${moment().unix()}.json` 77 | ); 78 | this.markov.loadDatabase("{}"); 79 | } 80 | size() { 81 | return fs.statSync(config.database.path).size; 82 | } 83 | onInterrupted() { 84 | this.save(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/modules/markov-speaking/index.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../../module"; 2 | import MessageLike from "../../message-like"; 3 | import { User, generateUserId, isOp, isBlocked, api } from "../../misskey"; 4 | import { IDatabase } from "./database"; 5 | import createDatabase from "./databases"; 6 | import config from "../../config"; 7 | import * as moment from "moment"; 8 | import WordFilter from "./word-filter"; 9 | const MarkovJa = require("markov-ja"); 10 | 11 | export default class MarkovSpeakingModule implements IModule { 12 | public readonly priority = 1; 13 | public readonly name = "markovSpeaking"; 14 | public readonly commands = [ 15 | { 16 | name: "markov reset", 17 | desc: "Explode the DB of NLP related data", 18 | }, 19 | { 20 | name: "markov delete", 21 | desc: "Remove chains containing specified morphemes", 22 | }, 23 | ]; 24 | private markov: any; 25 | private database!: IDatabase; 26 | private filter!: WordFilter; 27 | 28 | private get sentenceLength(): number { 29 | function getRandomInt(max) { 30 | return Math.floor(Math.random() * Math.floor(max)); 31 | } 32 | if (config.sentenceLengthRange) { 33 | let l = config.sentenceLengthRange.start; 34 | let r = config.sentenceLengthRange.end; 35 | return getRandomInt(r - l + 1) + l; 36 | } else return 1; 37 | } 38 | 39 | public install() { 40 | this.markov = new MarkovJa(); 41 | this.database = createDatabase(config.database.type, this.markov); 42 | this.database.load(); 43 | this.filter = new WordFilter(); 44 | this.filter.init(); 45 | 46 | if (config.intervalPost) { 47 | let duration = moment 48 | .duration( 49 | config.intervalPostDuration.value, 50 | config.intervalPostDuration.unit 51 | ) 52 | .asMilliseconds(); 53 | if (duration == 0) { 54 | console.error( 55 | "Bad duration setting. intervalPost feature is disabled." 56 | ); 57 | } 58 | setInterval(async () => { 59 | let text = ""; 60 | text += this.markov.generate(this.sentenceLength).join("\n"); 61 | let res = await api("notes/create", { 62 | text: text, 63 | visibility: config.visibility, 64 | }); 65 | const json = (await res.json()) as { error?: unknown }; // Force convert to { error?: unknown } 66 | if (json.error) { 67 | console.error("An error occured while creating the interval post"); 68 | console.error(`content: ${text}`); 69 | console.error(json.error); 70 | } else { 71 | console.log("Successfully posted on setInterval"); 72 | } 73 | }, duration); 74 | } 75 | } 76 | 77 | public learn(sender: User, message: string) { 78 | // if "allowLearn" is specified as "false"... 79 | // (backward compatibility) 80 | if (config.markovSpeaking.allowLearn == false) return; 81 | if (!isBlocked(sender) && message) { 82 | this.markov.learn( 83 | message.replace(/@[A-Za-z0-9_]+(?:@[A-Za-z0-9\.\-]+[A-Za-z0-9])?/g, "") 84 | ); 85 | } 86 | } 87 | 88 | public onNote(note: any) { 89 | this.database.updateSave(); 90 | let bad = this.filter.isBad(note.text); 91 | if ( 92 | !bad && 93 | !(!config.markovSpeaking.allowLearnCW && note.cw) && 94 | !( 95 | !config.markovSpeaking.allowLearnVisFollowers && 96 | note.visibility === "followers" 97 | ) 98 | ) 99 | this.learn(note.user, note.text); 100 | console.log( 101 | `${isBlocked(note.user) ? "><" : ""}${bad ? "B* " : ""}|${ 102 | note.user.name 103 | }(${generateUserId(note.user)}): ${note.text}` 104 | ); 105 | } 106 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 107 | if (cmd[0] == "markov") { 108 | switch (cmd[1]) { 109 | case "reset": 110 | if (isOp(msg.user)) { 111 | this.database.reset(); 112 | msg.reply("👍"); 113 | } else { 114 | msg.reply("👎(You don't have a permission)"); 115 | } 116 | break; 117 | case "delete": 118 | if (isOp(msg.user)) { 119 | this.markov.removeTriplets(cmd.slice(2).join("")); 120 | msg.reply(`👍\n\`${cmd.slice(2)}\``); 121 | } else { 122 | msg.reply("👎(You don't have a permission)"); 123 | } 124 | break; 125 | default: 126 | msg.reply( 127 | "markov: /markov \nOnly op-ed users can run this command." 128 | ); 129 | break; 130 | } 131 | return true; 132 | } else return false; 133 | } 134 | public onMention(msg: MessageLike): boolean { 135 | if (msg.text) this.learn(msg.user, msg.text); 136 | if (msg.isMessage) 137 | console.log( 138 | `${isBlocked(msg.user) ? "><" : ""}*${msg.user.name}(@${generateUserId( 139 | msg.user 140 | )}): ${msg.text}` 141 | ); 142 | let speech: string; 143 | try { 144 | speech = this.markov.generate(this.sentenceLength).join("\n"); 145 | } catch (e) { 146 | speech = "..."; 147 | } 148 | if (speech.trim() == "") speech = "..."; 149 | try { 150 | msg.reply(speech); 151 | } catch (e) { 152 | console.error("ERROR! Couldn't reply!"); 153 | console.error(e); 154 | return false; 155 | } 156 | return true; 157 | } 158 | public info(): string { 159 | let res: string = `Database: ${ 160 | config.database.type 161 | }, ${this.database.size()} / ${config.database.maxSize} (${ 162 | (this.database.size() / config.database.maxSize) * 100 163 | }%)\nFilters: ${config.markovSpeaking.wordFilterFiles},${ 164 | config.markovSpeaking.wordFilterURL 165 | }`; 166 | return res; 167 | } 168 | 169 | public onInterrupted() { 170 | this.database.onInterrupted(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/modules/markov-speaking/word-filter.ts: -------------------------------------------------------------------------------- 1 | import * as XML from "xml2js"; 2 | import config from "../../config"; 3 | import * as fs from "fs"; 4 | 5 | export default class WordFilter { 6 | private filterURL: string; 7 | private initialized: boolean = false; 8 | public ngwordDict: string[] = []; 9 | public okwordDict: string[] = []; 10 | 11 | constructor() { 12 | this.filterURL = config.markovSpeaking.wordFilterURL; 13 | } 14 | private async fetchDict() { 15 | let dictReq; 16 | let filterXML; 17 | let badWords: string[] = []; 18 | let okWords: string[] = []; 19 | 20 | if (this.filterURL) { 21 | console.info(`Fetching abusive word list from ${this.filterURL}`); 22 | dictReq = await fetch(this.filterURL); 23 | 24 | let dictStr = await dictReq.text(); 25 | filterXML = await XML.parseStringPromise(dictStr); 26 | 27 | for (let i of filterXML.housouKinshiYougoList.dirtyWord) { 28 | badWords.push(i.word[0]._); 29 | badWords.push(i.word[0].$.reading); 30 | } 31 | } 32 | 33 | for (let filePath of config.markovSpeaking.wordFilterFiles) { 34 | console.info(`Loading filtered word list from ${filePath}`); 35 | const fileContent = fs.readFileSync(filePath, "utf-8"); 36 | for (let word of fileContent.split("\n")) { 37 | let m = word.match("//"); 38 | if (m) { 39 | /* 40 | * "abra // cadabra" => "abra" 41 | */ 42 | word = word.substring(0, m.index).trim(); 43 | } 44 | if (!word) continue; 45 | if (word.startsWith("-")) okWords.push(word.substr(1)); 46 | else badWords.push(word); 47 | } 48 | } 49 | // ["ok", "test", "ng", "good", "hey"] 50 | // => ['ok', 'ng', 'hey', 'test', 'good'] 51 | // sorted by the length of the element 52 | badWords.sort((a, b) => a.length - b.length); 53 | okWords.sort((a, b) => a.length - b.length); 54 | this.ngwordDict = badWords; 55 | this.okwordDict = okWords; 56 | } 57 | 58 | async init() { 59 | if (!config.markovSpeaking.filtering) return false; 60 | try { 61 | await this.fetchDict(); 62 | } catch (e) { 63 | console.error("Couldn't initialize the word filter."); 64 | console.error(e); 65 | } 66 | 67 | if (process.env.DEBUG_NGFILTER) { 68 | console.log(this.ngwordDict); 69 | console.log(this.okwordDict); 70 | } 71 | this.initialized = true; 72 | return true; 73 | } 74 | 75 | isBad(str: string) { 76 | if (!this.initialized) return false; 77 | if (!str) return false; 78 | let k = 0; 79 | while (k < str.length) { 80 | // ng: ["ab"] 81 | // ok: ["abc"] 82 | // str: "abc cba" => false 83 | // str: "ab cba" => true 84 | 85 | let ok: boolean = false; 86 | for (let okword of this.okwordDict) { 87 | if (str.length - k < okword.length) break; 88 | if (str.slice(k, k + okword.length) == okword) { 89 | k += okword.length; 90 | ok = true; 91 | break; 92 | } 93 | } 94 | if (ok) continue; 95 | 96 | for (let ngword of this.ngwordDict) { 97 | if (str.length - k < ngword.length) break; 98 | if (str.slice(k, k + ngword.length) == ngword) { 99 | if (config.markovSpeaking.wordFilterLog) console.log(`*B: ${ngword}`); 100 | return true; 101 | } 102 | } 103 | k++; 104 | } 105 | 106 | // safe! 107 | return false; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/modules/math.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | import config from "../config"; 4 | import asciimathToLaTeX from "asciimath-to-latex"; 5 | import { upload } from "../misskey"; 6 | const mj = require("mathjax-node"); 7 | const svg2png = require("svg2png"); 8 | 9 | export default class MathModule implements IModule { 10 | public readonly priority = 0; 11 | public readonly name = "math"; 12 | public readonly commands = [ 13 | { 14 | name: "math atol", 15 | desc: "Convert AsciiMath code to LaTeX", 16 | }, 17 | { 18 | name: "math atol-src", 19 | desc: "Convert AsciiMath code to LaTeX code", 20 | }, 21 | { 22 | name: "math render", 23 | desc: "Render LaTeX or AsciiMath to .png file", 24 | }, 25 | ]; 26 | public size!: number; 27 | 28 | public install() { 29 | this.size = config.math.size; 30 | mj.start(); 31 | } 32 | 33 | async generateImage(type: string, formula: string) { 34 | type = type.toLowerCase(); 35 | if (type == "latex" || type == "tex") type = "TeX"; 36 | else if (type == "asciimath") type = "AsciiMath"; 37 | else type = "TeX"; 38 | let out = await mj.typeset({ 39 | math: formula, 40 | format: type, 41 | svg: true, 42 | }); 43 | let image; 44 | image = await svg2png(out.svg, { 45 | width: out.width.slice(0, -2) * this.size, 46 | height: out.height.slice(0, -2) * this.size, 47 | }); 48 | return image; 49 | } 50 | async uploadRendered(type: string, formula: string) { 51 | let image = await this.generateImage(type, formula); 52 | if (!image) return null; 53 | 54 | let imageRes = await upload(image, { 55 | filename: "rendered.png", 56 | contentType: "image/png", 57 | }); 58 | return imageRes; 59 | } 60 | 61 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 62 | if (cmd[0] == "math") { 63 | switch (cmd[1]) { 64 | case "atol": 65 | if (!cmd.slice(2).join(" ")) 66 | msg.reply("math: /math atol "); 67 | msg.reply("\\(" + asciimathToLaTeX(cmd.slice(2).join(" ")) + "\\)"); 68 | break; 69 | case "atol-src": 70 | if (!cmd.slice(2).join(" ")) 71 | msg.reply("math: /math atol-src "); 72 | msg.reply( 73 | "```\n" + asciimathToLaTeX(cmd.slice(2).join(" ")) + "\n```" 74 | ); 75 | break; 76 | case "render": 77 | let file; 78 | try { 79 | file = await this.uploadRendered(cmd[2], cmd.slice(3).join(" ")); 80 | } catch (e) { 81 | msg.reply(`Couldn\'t process the input;\n${e}`, "error!"); 82 | return true; 83 | } 84 | 85 | if (msg.isMessage) { 86 | msg.reply("Rendered!", undefined, { 87 | fileId: file.id, 88 | }); 89 | } else 90 | msg.reply("Rendered! Here it is", undefined, { 91 | fileIds: [file.id], 92 | }); 93 | break; 94 | default: 95 | msg.reply("math: /math "); 96 | break; 97 | } 98 | return true; 99 | } 100 | return false; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/modules/othello-redirect.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | 4 | export default class OthelloRedirectModule implements IModule { 5 | public readonly name = "othelloRedirect"; 6 | public readonly priority = 2; 7 | 8 | public install() {} 9 | 10 | public onMention(msg: MessageLike): boolean { 11 | if (msg.text.match(/(オセロ|リバーシ|othello|reversi|Othello|Reversi)/)) { 12 | msg.reply("cc: @ai"); 13 | return true; 14 | } else return false; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/ping.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | 4 | export default class PingModule implements IModule { 5 | public readonly priority = 0; 6 | public readonly name = "ping"; 7 | public readonly commands = [ 8 | { 9 | name: "ping", 10 | }, 11 | ]; 12 | 13 | public install() {} 14 | 15 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 16 | if (cmd[0] == "ping") { 17 | msg.reply("PONG!"); 18 | return true; 19 | } 20 | return false; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/random-choice.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | 4 | export default class RandomChoiceModule implements IModule { 5 | public readonly priority = 0; 6 | public readonly name = "randomChoice"; 7 | public readonly commands = [ 8 | { 9 | name: "choose", 10 | desc: "choose one from given N choices", 11 | }, 12 | ]; 13 | 14 | public install() {} 15 | 16 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 17 | if (cmd[0] == "choose") { 18 | if (cmd.length < 2) { 19 | msg.reply("Usage: /choose ... "); 20 | } else { 21 | let choice = cmd.length - 1; 22 | msg.reply(cmd[Math.floor(Math.random() * choice) + 1]); 23 | } 24 | return true; 25 | } 26 | return false; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/suru.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | import config from "../config"; 4 | 5 | export default class SuruModule implements IModule { 6 | public readonly priority = 0; 7 | public readonly name = "suru"; 8 | public readonly commands = [ 9 | { 10 | name: "suru", 11 | desc: "return yes or no", 12 | }, 13 | ]; 14 | 15 | public install() {} 16 | 17 | public async onCommand(msg: MessageLike, cmd: string[]): Promise { 18 | if (cmd[0] == "suru") { 19 | const yesnoInt = this.getRandomInt(0, 1); 20 | 21 | // 0 を no とする 22 | const resMessageList = yesnoInt === 0 ? config.suru.no : config.suru.yes; 23 | 24 | const choiceInt = this.getRandomInt(0, resMessageList.length - 1); 25 | const choice = resMessageList[choiceInt]; 26 | msg.reply(choice); 27 | return true; 28 | } 29 | return false; 30 | } 31 | 32 | private getRandomInt(min: number, max: number) { 33 | return Math.floor(Math.random() * (max - min + 1)) + min; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/sushi.ts: -------------------------------------------------------------------------------- 1 | import IModule from "../module"; 2 | import MessageLike from "../message-like"; 3 | 4 | export default class SushiModule implements IModule { 5 | public readonly priority = 2; 6 | public readonly name = "sushi"; 7 | 8 | public install() {} 9 | 10 | public onMention(msg: MessageLike) { 11 | if (!msg.text) return false; 12 | let m = msg.text.match(/(お)?(すし|寿司)(にぎ|握)(って|れ|にぎ|り|ろ)/); 13 | if (m) { 14 | const emojiarr = [ 15 | this.randomEmoji(), 16 | this.randomEmoji(), 17 | this.randomEmoji(), 18 | ]; 19 | msg.reply(`にぎりました!${emojiarr.join("")}`); 20 | return true; 21 | } else return false; 22 | } 23 | 24 | private randomEmoji(): string { 25 | // https://gist.github.com/ikr7/c72843556ef3a12014c3 26 | // prettier-ignore 27 | const emojis = [ 28 | '😄', '😃', '😀', '😊', '☺', '😉', '😍', '😘', '😚', '😗', '😙', '😜', '😝', '😛', '😳', '😁', '😔', '😌', '😒', '😞', '😣', '😢', '😂', '😭', '😪', '😥', '😰', '😅', '😓', '😩', '😫', '😨', '😱', '😠', '😡', '😤', '😖', '😆', '😋', '😷', '😎', '😴', '😵', '😲', '😟', '😦', '😧', '😈', '👿', '😮', '😬', '😐', '😕', '😯', '😶', '😇', '😏', '😑', '👲', '👳', '👮', '👷', '💂', '👶', '👦', '👧', '👨', '👩', '👴', '👵', '👱', '👼', '👸', '😺', '😸', '😻', '😽', '😼', '🙀', '😿', '😹', '😾', '👹', '👺', '🙈', '🙉', '🙊', '💀', '👽', '💩', '🔥', '✨', '🌟', '💫', '💥', '💢', '💦', '💧', '💤', '💨', '👂', '👀', '👃', '👅', '👄', '👍', '👎', '👌', '👊', '✊', '✌', '👋', '✋', '👐', '👆', '👇', '👉', '👈', '🙌', '🙏', '☝', '👏', '💪', '🚶', '🏃', '💃', '👫', '👪', '👬', '👭', '💏', '💑', '👯', '🙆', '🙅', '💁', '🙋', '💆', '💇', '💅', '👰', '🙎', '🙍', '🙇', '🎩', '👑', '👒', '👟', '👞', '👡', '👠', '👢', '👕', '👔', '👚', '👗', '🎽', '👖', '👘', '👙', '💼', '👜', '👝', '👛', '👓', '🎀', '🌂', '💄', '💛', '💙', '💜', '💚', '❤', '💔', '💗', '💓', '💕', '💖', '💞', '💘', '💌', '💋', '💍', '💎', '👤', '👥', '💬', '👣', '💭', '🐶', '🐺', '🐱', '🐭', '🐹', '🐰', '🐸', '🐯', '🐨', '🐻', '🐷', '🐽', '🐮', '🐗', '🐵', '🐒', '🐴', '🐑', '🐘', '🐼', '🐧', '🐦', '🐤', '🐥', '🐣', '🐔', '🐍', '🐢', '🐛', '🐝', '🐜', '🐞', '🐌', '🐙', '🐚', '🐠', '🐟', '🐬', '🐳', '🐋', '🐄', '🐏', '🐀', '🐃', '🐅', '🐇', '🐉', '🐎', '🐐', '🐓', '🐕', '🐖', '🐁', '🐂', '🐲', '🐡', '🐊', '🐫', '🐪', '🐆', '🐈', '🐩', '🐾', '💐', '🌸', '🌷', '🍀', '🌹', '🌻', '🌺', '🍁', '🍃', '🍂', '🌿', '🌾', '🍄', '🌵', '🌴', '🌲', '🌳', '🌰', '🌱', '🌼', '🌐', '🌞', '🌝', '🌚', '🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘', '🌜', '🌛', '🌙', '🌍', '🌎', '🌏', '🌋', '🌌', '🌠', '⭐', '☀', '⛅', '☁', '⚡', '☔', '❄', '⛄', '🌀', '🌁', '🌈', '🌊', '🎍', '💝', '🎎', '🎒', '🎓', '🎏', '🎆', '🎇', '🎐', '🎑', '🎃', '👻', '🎅', '🎄', '🎁', '🎋', '🎉', '🎊', '🎈', '🎌', '🔮', '🎥', '📷', '📹', '📼', '💿', '📀', '💽', '💾', '💻', '📱', '☎', '📞', '📟', '📠', '📡', '📺', '📻', '🔊', '🔉', '🔈', '🔇', '🔔', '🔕', '📢', '📣', '⏳', '⌛', '⏰', '⌚', '🔓', '🔒', '🔏', '🔐', '🔑', '🔎', '💡', '🔦', '🔆', '🔅', '🔌', '🔋', '🔍', '🛁', '🛀', '🚿', '🚽', '🔧', '🔩', '🔨', '🚪', '🚬', '💣', '🔫', '🔪', '💊', '💉', '💰', '💴', '💵', '💷', '💶', '💳', '💸', '📲', '📧', '📥', '📤', '✉', '📩', '📨', '📯', '📫', '📪', '📬', '📭', '📮', '📦', '📝', '📄', '📃', '📑', '📊', '📈', '📉', '📜', '📋', '📅', '📆', '📇', '📁', '📂', '✂', '📌', '📎', '✒', '✏', '📏', '📐', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖', '🔖', '📛', '🔬', '🔭', '📰', '🎨', '🎬', '🎤', '🎧', '🎼', '🎵', '🎶', '🎹', '🎻', '🎺', '🎷', '🎸', '👾', '🎮', '🃏', '🎴', '🀄', '🎲', '🎯', '🏈', '🏀', '⚽', '⚾', '🎾', '🎱', '🏉', '🎳', '⛳', '🚵', '🚴', '🏁', '🏇', '🏆', '🎿', '🏂', '🏊', '🏄', '🎣', '☕', '🍵', '🍶', '🍼', '🍺', '🍻', '🍸', '🍹', '🍷', '🍴', '🍕', '🍔', '🍟', '🍗', '🍖', '🍝', '🍛', '🍤', '🍱', '🍣', '🍥', '🍙', '🍘', '🍚', '🍜', '🍲', '🍢', '🍡', '🍳', '🍞', '🍩', '🍮', '🍦', '🍨', '🍧', '🎂', '🍰', '🍪', '🍫', '🍬', '🍭', '🍯', '🍎', '🍏', '🍊', '🍋', '🍒', '🍇', '🍉', '🍓', '🍑', '🍈', '🍌', '🍐', '🍍', '🍠', '🍆', '🍅', '🌽', '🏠', '🏡', '🏫', '🏢', '🏣', '🏥', '🏦', '🏪', '🏩', '🏨', '💒', '⛪', '🏬', '🏤', '🌇', '🌆', '🏯', '🏰', '⛺', '🏭', '🗼', '🗾', '🗻', '🌄', '🌅', '🌃', '🗽', '🌉', '🎠', '🎡', '⛲', '🎢', '🚢', '⛵', '🚤', '🚣', '⚓', '🚀', '✈', '💺', '🚁', '🚂', '🚊', '🚉', '🚞', '🚆', '🚄', '🚅', '🚈', '🚇', '🚝', '🚋', '🚃', '🚎', '🚌', '🚍', '🚙', '🚘', '🚗', '🚕', '🚖', '🚛', '🚚', '🚨', '🚓', '🚔', '🚒', '🚑', '🚐', '🚲', '🚡', '🚟', '🚠', '🚜', '💈', '🚏', '🎫', '🚦', '🚥', '⚠', '🚧', '🔰', '⛽', '🏮', '🎰', '♨', '🗿', '🎪', '🎭', '📍', '🚩', '⬆', '⬇', '⬅', '➡', '🔠', '🔡', '🔤', '↗', '↖', '↘', '↙', '↔', '↕', '🔄', '◀', '▶', '🔼', '🔽', '↩', '↪', 'ℹ', '⏪', '⏩', '⏫', '⏬', '⤵', '⤴', '🆗', '🔀', '🔁', '🔂', '🆕', '🆙', '🆒', '🆓', '🆖', '📶', '🎦', '🈁', '🈯', '🈳', '🈵', '🈴', '🈲', '🉐', '🈹', '🈺', '🈶', '🈚', '🚻', '🚹', '🚺', '🚼', '🚾', '🚰', '🚮', '🅿', '♿', '🚭', '🈷', '🈸', '🈂', 'Ⓜ', '🛂', '🛄', '🛅', '🛃', '🉑', '㊙', '㊗', '🆑', '🆘', '🆔', '🚫', '🔞', '📵', '🚯', '🚱', '🚳', '🚷', '🚸', '⛔', '✳', '❇', '❎', '✅', '✴', '💟', '🆚', '📳', '📴', '🅰', '🅱', '🆎', '🅾', '💠', '➿', '♻', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '⛎', '🔯', '🏧', '💹', '💲', '💱', '©', '®', '™', '〽', '〰', '🔝', '🔚', '🔙', '🔛', '🔜', '❌', '⭕', '❗', '❓', '❕', '❔', '🔃', '🕛', '🕧', '🕐', '🕜', '🕑', '🕝', '🕒', '🕞', '🕓', '🕟', '🕔', '🕠', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕡', '🕢', '🕣', '🕤', '🕥', '🕦', '✖', '➕', '➖', '➗', '♠', '♥', '♣', '♦', '💮', '💯', '✔', '☑', '🔘', '🔗', '➰', '🔱', '🔲', '🔳', '◼', '◻', '◾', '◽', '▪', '▫', '🔺', '⬜', '⬛', '⚫', '⚪', '🔴', '🔵', '🔻', '🔶', '🔷', '🔸', '🔹' 29 | ]; 30 | 31 | return emojis[Math.floor(Math.random() * emojis.length)]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | export default class Stream {} 2 | -------------------------------------------------------------------------------- /src/util/assert-property.ts: -------------------------------------------------------------------------------- 1 | export type PickRequired = T & Required>; 2 | 3 | /** 4 | * Type argument K must be a literal type representing exactly one key given to `key`. 5 | * Normally, the type arguments for this function should be left to type inference rather than being typed in manually. 6 | * @param key - a key of property in type T 7 | * @returns A assertion function that checks if a value of type T has the property of `key` 8 | */ 9 | export function assertProperty( 10 | key: K 11 | ): (value: T) => value is PickRequired { 12 | return (value: T): value is PickRequired => { 13 | return value instanceof Object && key in value; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmitOnError": true, 5 | "noImplicitAny": false, 6 | "noImplicitReturns": true, 7 | "noImplicitThis": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "target": "es6", 12 | "module": "commonjs", 13 | "removeComments": false, 14 | "noLib": false, 15 | "outDir": "built", 16 | "rootDir": "src" 17 | }, 18 | "compileOnSave": false, 19 | "include": [ 20 | "./src/**/*.ts" 21 | ] 22 | } -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | git pull 3 | npm install 4 | npm run build 5 | --------------------------------------------------------------------------------