mirror of
https://github.com/nullnyat/NullcatChan.git
synced 2025-04-28 19:27:18 +09:00
update
This commit is contained in:
parent
f7543dc8bc
commit
06b942eed7
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@ -0,0 +1,13 @@
|
||||
config.json
|
||||
font.ttf
|
||||
nullcatchan.*
|
||||
*.md
|
||||
*.png
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
LICENSE
|
||||
|
||||
node_modules/
|
||||
test/
|
||||
data/
|
||||
.vscode/
|
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
config.json
|
||||
built
|
||||
node_modules
|
||||
memory.json
|
||||
font.ttf
|
||||
# Intelij-IDEA
|
||||
/.idea
|
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@ -0,0 +1,4 @@
|
||||
built
|
||||
node_modules
|
||||
package.json
|
||||
tsconfig.json
|
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"trailingComma": "none",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 200
|
||||
}
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib"
|
||||
}
|
26
Dockerfile_development
Normal file
26
Dockerfile_development
Normal file
@ -0,0 +1,26 @@
|
||||
FROM node:lts-bullseye
|
||||
|
||||
RUN apt-get update && apt-get install -y tini
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG enable_mecab=1
|
||||
|
||||
RUN if [ $enable_mecab -ne 0 ]; then apt-get update \
|
||||
&& apt-get install mecab libmecab-dev mecab-ipadic-utf8 make curl xz-utils file sudo tzdata --no-install-recommends -y \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt-get/lists/* \
|
||||
&& cd /opt \
|
||||
&& git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git \
|
||||
&& cd /opt/mecab-ipadic-neologd \
|
||||
&& ./bin/install-mecab-ipadic-neologd -n -y \
|
||||
&& rm -rf /opt/mecab-ipadic-neologd \
|
||||
&& echo "dicdir = /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/" > /etc/mecabrc \
|
||||
&& apt-get purge git make curl xz-utils file -y; fi
|
||||
|
||||
COPY . /nullcatchan
|
||||
|
||||
WORKDIR /nullcatchan
|
||||
RUN npm install && npm run build
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD npm run dev
|
26
Dockerfile_production
Normal file
26
Dockerfile_production
Normal file
@ -0,0 +1,26 @@
|
||||
FROM node:lts-bullseye
|
||||
|
||||
RUN apt-get update && apt-get install -y tini
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG enable_mecab=1
|
||||
|
||||
RUN if [ $enable_mecab -ne 0 ]; then apt-get update \
|
||||
&& apt-get install mecab libmecab-dev mecab-ipadic-utf8 make curl xz-utils file sudo tzdata --no-install-recommends -y \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt-get/lists/* \
|
||||
&& cd /opt \
|
||||
&& git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git \
|
||||
&& cd /opt/mecab-ipadic-neologd \
|
||||
&& ./bin/install-mecab-ipadic-neologd -n -y \
|
||||
&& rm -rf /opt/mecab-ipadic-neologd \
|
||||
&& echo "dicdir = /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/" > /etc/mecabrc \
|
||||
&& apt-get purge git make curl xz-utils file -y; fi
|
||||
|
||||
COPY . /nullcatchan
|
||||
|
||||
WORKDIR /nullcatchan
|
||||
RUN npm install && npm run build
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD npm start
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2022 syuilo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
67
README.md
Normal file
67
README.md
Normal file
@ -0,0 +1,67 @@
|
||||
## これってなに?
|
||||
Misskey用の[Aiベース](https://github.com/syuilo/ai)のBotです。
|
||||
```
|
||||
_ __ ____ __ ________ __
|
||||
/ | / /_ __/ / /________ _/ /_/ ____/ /_ ____ _____ / /
|
||||
/ |/ / / / / / / ___/ __ `/ __/ / / __ \/ __ `/ __ \/ /
|
||||
/ /| / /_/ / / / /__/ /_/ / /_/ /___/ / / / /_/ / / / /_/
|
||||
/_/ |_/\__,_/_/_/\___/\__,_/\__/\____/_/ /_/\__,_/_/ /_(_)
|
||||
```
|
||||
|
||||
## 大きな変更点
|
||||
- 自動投稿の内容
|
||||
- pingに対する返答の内容
|
||||
- 自動返信の内容
|
||||
- ゴママヨに反応([ここ](https://github.com/ThinaticSystem/gomamayo.js)から持ってきた)
|
||||
- ゲーム機能と絵文字を自動生成するやつがない
|
||||
- GitHubのStatusがわかる
|
||||
- CloudflareのStatusがわかる
|
||||
- やることを決めてくれる
|
||||
- 気圧の状況を教えてくれる
|
||||
- 時報機能
|
||||
- シェル芸機能([ここ](https://github.com/sim1222/shellgei-misskey)から持ってきた)
|
||||
- 怪レい曰本语に変換してくれる機能
|
||||
|
||||
## 導入方法
|
||||
> Node.js と npm と MeCab がインストールされている必要があります。
|
||||
|
||||
まず適当なディレクトリに `git clone` します。
|
||||
次にそのディレクトリに `config.json` を作成します。中身は次のようにします:
|
||||
``` json
|
||||
{
|
||||
"host": "https:// + あなたのインスタンスのURL (末尾の / は除く)",
|
||||
"i": "ぬるきゃっとちゃん!として動かしたいアカウントのアクセストークン",
|
||||
"master": "管理者のユーザー名(オプション)",
|
||||
"notingEnabled": "ランダムにノートを投稿する機能。true(on) or false(off)",
|
||||
"keywordEnabled": "キーワードを覚える機能 (MeCab が必要) true or false",
|
||||
"serverMonitoring": "サーバー監視の機能(重かったりすると教えてくれるよ。)true or false",
|
||||
"mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab) true or false",
|
||||
"mecabDic": "MeCab の辞書ファイルパス",
|
||||
"memoryDir": "memory.jsonの保存先(オプション、デフォルトは'.'(レポジトリのルートです))",
|
||||
"shellgeiUrl": "シェル芸BotのAPIのURLです(デフォルトではhttps://websh.jiro4989.com/api/shellgei)"
|
||||
}
|
||||
```
|
||||
`npm install` して `npm run build` して `npm start` すれば起動できます。
|
||||
|
||||
### Dockerで動かす
|
||||
まず適当なディレクトリに `git clone` します。<br>
|
||||
次にそのディレクトリに `config.json` を作成します。中身は次のようにします:
|
||||
(MeCabの設定、memoryDirについては触らないでください)
|
||||
``` json
|
||||
{
|
||||
"host": "https:// + あなたのインスタンスのURL (末尾の / は除く)",
|
||||
"i": "ぬるきゃっとちゃん!として動かしたいアカウントのアクセストークン",
|
||||
"master": "管理者のユーザー名(オプション)",
|
||||
"notingEnabled": "ランダムにノートを投稿する機能。true(on) or false(off)",
|
||||
"keywordEnabled": "キーワードを覚える機能 (MeCab が必要) true or false",
|
||||
"mecab": "/usr/bin/mecab",
|
||||
"mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/",
|
||||
"memoryDir": "data",
|
||||
"shellgeiUrl": "シェル芸BotのAPIのURLです(デフォルトではhttps://websh.jiro4989.com/api/shellgei)"
|
||||
}
|
||||
```
|
||||
`npm install` して `npm run docker` すれば起動できます。<br>
|
||||
`docker-compose.yml` の `enable_mecab` を `0` にすると、MeCabをインストールしないようにもできます。(メモリが少ない環境など)
|
||||
|
||||
#### 一部の機能にはフォントが必要です。NullcatChan!にはフォントは同梱されていないので、ご自身でフォントをインストールしてそのフォントを`font.ttf`という名前でインストールディレクトリに設置してください。
|
||||
#### NullcatChan!は記憶の保持にインメモリデータベースを使用しており、僕のインストールディレクトリに `memory.json` という名前で永続化されます。
|
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@ -0,0 +1,15 @@
|
||||
version: '3'
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
dockerfile: Dockerfile_production
|
||||
context: .
|
||||
args:
|
||||
- enable_mecab=1
|
||||
volumes:
|
||||
- './config.json:/nullcatchan/config.json:ro'
|
||||
- './font.ttf:/nullcatchan/font.ttf:ro'
|
||||
- './data:/nullcatchan/data'
|
||||
restart: always
|
||||
environment:
|
||||
TZ: Asia/Tokyo
|
5
docker-compose_development.yml
Normal file
5
docker-compose_development.yml
Normal file
@ -0,0 +1,5 @@
|
||||
version: '3'
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
dockerfile: Dockerfile_development
|
184
ngwords.txt
Normal file
184
ngwords.txt
Normal file
@ -0,0 +1,184 @@
|
||||
オーガズム
|
||||
オルガスムス
|
||||
メスイキ
|
||||
ポルノ
|
||||
-ポルノグラフィティ
|
||||
にょろり
|
||||
オナホ
|
||||
アクメ
|
||||
淫夢
|
||||
チンカス
|
||||
ふたなり
|
||||
マラ
|
||||
-ガテマラ
|
||||
-グァテマラ
|
||||
TENGA
|
||||
種付
|
||||
たねつ
|
||||
たねづ
|
||||
アスペ
|
||||
-アスペクト
|
||||
クリトリス
|
||||
dick
|
||||
セックス
|
||||
セクロス
|
||||
-ぱちんこ
|
||||
-がちんこ
|
||||
-喉ちんこ
|
||||
-のどちんこ
|
||||
ちんこ
|
||||
ちんぽ
|
||||
chinko
|
||||
chinpo
|
||||
tinko
|
||||
tinpo
|
||||
-ビックリマンコラボ
|
||||
-ウルトラマンコスモス
|
||||
まんこ
|
||||
manko
|
||||
ペニス
|
||||
penis
|
||||
vagina
|
||||
ヴァギナ
|
||||
バギナ
|
||||
肉棒
|
||||
勃起
|
||||
ぼっき
|
||||
精子
|
||||
精液
|
||||
射精
|
||||
ザーメン
|
||||
ザー汁
|
||||
放射性
|
||||
金玉
|
||||
キンタマ
|
||||
semen
|
||||
体位
|
||||
淫乱
|
||||
アナル
|
||||
anus
|
||||
おっぱい
|
||||
巨乳
|
||||
貧乳
|
||||
爆乳
|
||||
虚乳
|
||||
普乳
|
||||
適乳
|
||||
美乳
|
||||
豊乳
|
||||
超乳
|
||||
魔乳
|
||||
きょにゅう
|
||||
きょにゅー
|
||||
ひんにゅう
|
||||
ひんにゅー
|
||||
ばくにゅう
|
||||
ばくにゅー
|
||||
ふにゅう
|
||||
ふにゅー
|
||||
てきにゅう
|
||||
てきにゅー
|
||||
びにゅう
|
||||
びにゅー
|
||||
ほうにゅう
|
||||
ほうにゅー
|
||||
ちょうにゅう
|
||||
ちょうにゅー
|
||||
まにゅう
|
||||
まにゅー
|
||||
何カップ
|
||||
乳首
|
||||
ちくび
|
||||
ビーチク
|
||||
自慰
|
||||
オナニ
|
||||
オナ二
|
||||
オナヌ
|
||||
マスターベーション
|
||||
マスタベーション
|
||||
シコい
|
||||
シコっ
|
||||
脱げ
|
||||
ぬげ
|
||||
脱いで
|
||||
ぬいで
|
||||
脱ごう
|
||||
ぬごう
|
||||
喘いで
|
||||
あえいで
|
||||
クンニ
|
||||
-フェラーリ
|
||||
-カフェラテ
|
||||
-フェライト
|
||||
-フェラガモ
|
||||
-フェラーラ
|
||||
-フェライニ
|
||||
-フェラーズ
|
||||
-フェラリア
|
||||
フェラ
|
||||
デリヘル
|
||||
-姦し
|
||||
姦
|
||||
犯す
|
||||
ヤリマン
|
||||
ヤリチン
|
||||
パイパン
|
||||
中出し
|
||||
中で出
|
||||
スカトロ
|
||||
ケツ
|
||||
コキ
|
||||
手マン
|
||||
潮吹
|
||||
下乳
|
||||
横乳
|
||||
指マン
|
||||
パイズリ
|
||||
ペェズリ
|
||||
-スレイプニル
|
||||
レイプ
|
||||
オフパコ
|
||||
パコる
|
||||
ドピュ
|
||||
ブリュ
|
||||
-ちんちん電車
|
||||
ちんちん
|
||||
ぽこちん
|
||||
マン汁
|
||||
膣
|
||||
下の口
|
||||
コンドーム
|
||||
ハメ撮り
|
||||
ちん毛
|
||||
まん毛
|
||||
陰毛
|
||||
インポ
|
||||
童貞もらって
|
||||
童貞貰
|
||||
童貞をもらって
|
||||
童貞を貰
|
||||
ケツの穴
|
||||
糞を出
|
||||
糞が出
|
||||
ヨツンヴァイン
|
||||
しゃぶれ
|
||||
邪淫
|
||||
処女
|
||||
早漏
|
||||
オナホ
|
||||
アクメ
|
||||
淫夢
|
||||
チンカス
|
||||
ふたなり
|
||||
マラ
|
||||
-ガテマラ
|
||||
-グァテマラ
|
||||
TENGA
|
||||
種付
|
||||
たねつ
|
||||
たねづ
|
||||
アスペ
|
||||
-アスペクト
|
||||
クリトリス
|
||||
dick
|
||||
おしっこ
|
90
package.json
Normal file
90
package.json
Normal file
@ -0,0 +1,90 @@
|
||||
{
|
||||
"version": "2.2.0",
|
||||
"main": "./built/index.js",
|
||||
"scripts": {
|
||||
"docker:dev": "cross-env DOCKER_ENV=development docker-compose -f docker-compose.yml -f docker-compose_development.yml up -d --build && docker-compose logs -f",
|
||||
"docker": "cross-env DOCKER_ENV=production docker-compose up -d --build && docker-compose logs -f",
|
||||
"dev": "cross-env NODE_ENV=development node ./built",
|
||||
"start": "cross-env NODE_ENV=production node ./built",
|
||||
"lint": "prettier --write ./src/",
|
||||
"build": "tsc",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/accurate-interval": "1.0.0",
|
||||
"@types/chalk": "2.2.0",
|
||||
"@types/humanize-duration": "3.27.1",
|
||||
"@types/lokijs": "1.5.4",
|
||||
"@types/moji": "0.5.0",
|
||||
"@types/node": "16.0.1",
|
||||
"@types/promise-retry": "1.1.3",
|
||||
"@types/random-seed": "0.3.3",
|
||||
"@types/request-promise-native": "1.0.18",
|
||||
"@types/seedrandom": "2.4.28",
|
||||
"@types/twemoji-parser": "13.1.1",
|
||||
"@types/uuid": "8.3.1",
|
||||
"@types/ws": "7.4.6",
|
||||
"accurate-interval": "1.0.9",
|
||||
"autobind-decorator": "2.4.0",
|
||||
"canvas": "2.8.0",
|
||||
"chalk": "4.1.1",
|
||||
"cjp": "1.2.3",
|
||||
"gomamayo-js": "0.2.1",
|
||||
"humanize-duration": "3.27.1",
|
||||
"lokijs": "1.5.12",
|
||||
"memory-streams": "0.1.3",
|
||||
"misskey-reversi": "0.0.5",
|
||||
"module-alias": "2.2.2",
|
||||
"moji": "0.5.1",
|
||||
"node-fetch": "2.6.7",
|
||||
"promise-retry": "2.0.1",
|
||||
"random-seed": "0.3.0",
|
||||
"reconnecting-websocket": "4.4.0",
|
||||
"request": "2.88.2",
|
||||
"request-promise-native": "1.0.9",
|
||||
"seedrandom": "3.0.5",
|
||||
"timeout-as-promise": "1.0.0",
|
||||
"ts-node": "10.0.0",
|
||||
"twemoji-parser": "13.1.0",
|
||||
"typescript": "4.5.5",
|
||||
"uuid": "8.3.2",
|
||||
"ws": "7.5.2",
|
||||
"zod": "3.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@koa/router": "9.4.0",
|
||||
"@types/jest": "26.0.23",
|
||||
"@types/koa": "2.13.1",
|
||||
"@types/koa__router": "8.0.4",
|
||||
"@types/websocket": "1.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"jest": "26.6.3",
|
||||
"koa": "2.13.1",
|
||||
"koa-json-body": "5.3.0",
|
||||
"prettier": "2.5.1",
|
||||
"ts-jest": "26.5.6",
|
||||
"websocket": "1.0.34"
|
||||
},
|
||||
"_moduleAliases": {
|
||||
"@": "built"
|
||||
},
|
||||
"jest": {
|
||||
"testRegex": "/test/.*",
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"js"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
"globals": {
|
||||
"ts-jest": {
|
||||
"tsConfig": "test/tsconfig.json"
|
||||
}
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^@/(.+)": "<rootDir>/src/$1",
|
||||
"^#/(.+)": "<rootDir>/test/$1"
|
||||
}
|
||||
}
|
||||
}
|
520
src/ai.ts
Normal file
520
src/ai.ts
Normal file
@ -0,0 +1,520 @@
|
||||
// AI CORE
|
||||
|
||||
import * as fs from 'fs';
|
||||
import autobind from 'autobind-decorator';
|
||||
import * as loki from 'lokijs';
|
||||
import * as request from 'request-promise-native';
|
||||
import * as chalk from 'chalk';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
const delay = require('timeout-as-promise');
|
||||
|
||||
import config from '@/config';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
import Friend, { FriendDoc } from '@/friend';
|
||||
import { User } from '@/misskey/user';
|
||||
import Stream from '@/stream';
|
||||
import log from '@/utils/log';
|
||||
const pkg = require('../package.json');
|
||||
|
||||
type MentionHook = (msg: Message) => Promise<boolean | HandlerResult>;
|
||||
type ContextHook = (key: any, msg: Message, data?: any) => Promise<void | boolean | HandlerResult>;
|
||||
type TimeoutCallback = (data?: any) => void;
|
||||
|
||||
export type HandlerResult = {
|
||||
reaction?: string | null;
|
||||
immediate?: boolean;
|
||||
};
|
||||
|
||||
export type InstallerResult = {
|
||||
mentionHook?: MentionHook;
|
||||
contextHook?: ContextHook;
|
||||
timeoutCallback?: TimeoutCallback;
|
||||
};
|
||||
|
||||
export type Meta = {
|
||||
lastWakingAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 藍
|
||||
*/
|
||||
export default class 藍 {
|
||||
public readonly version = pkg._v;
|
||||
public account: User;
|
||||
public connection: Stream;
|
||||
public modules: Module[] = [];
|
||||
private mentionHooks: MentionHook[] = [];
|
||||
private contextHooks: { [moduleName: string]: ContextHook } = {};
|
||||
private timeoutCallbacks: { [moduleName: string]: TimeoutCallback } = {};
|
||||
public db: loki;
|
||||
public lastSleepedAt: number;
|
||||
|
||||
private meta: loki.Collection<Meta>;
|
||||
|
||||
private contexts: loki.Collection<{
|
||||
isDm: boolean;
|
||||
noteId?: string;
|
||||
userId?: string;
|
||||
module: string;
|
||||
key: string | null;
|
||||
data?: any;
|
||||
}>;
|
||||
|
||||
private timers: loki.Collection<{
|
||||
id: string;
|
||||
module: string;
|
||||
insertedAt: number;
|
||||
delay: number;
|
||||
data?: any;
|
||||
}>;
|
||||
|
||||
public friends: loki.Collection<FriendDoc>;
|
||||
public moduleData: loki.Collection<any>;
|
||||
|
||||
/**
|
||||
* 藍インスタンスを生成します
|
||||
* @param account 藍として使うアカウント
|
||||
* @param modules モジュール。先頭のモジュールほど高優先度
|
||||
*/
|
||||
constructor(account: User, modules: Module[]) {
|
||||
this.account = account;
|
||||
this.modules = modules;
|
||||
|
||||
let memoryDir = '.';
|
||||
if (config.memoryDir) {
|
||||
memoryDir = config.memoryDir;
|
||||
}
|
||||
const file = process.env.NODE_ENV === 'test' ? `${memoryDir}/test.memory.json` : `${memoryDir}/memory.json`;
|
||||
|
||||
this.log(`Lodaing the memory from ${file}...`);
|
||||
|
||||
this.db = new loki(file, {
|
||||
autoload: true,
|
||||
autosave: true,
|
||||
autosaveInterval: 1000,
|
||||
autoloadCallback: err => {
|
||||
if (err) {
|
||||
this.log(chalk.red(`Failed to load the memory: ${err}`));
|
||||
} else {
|
||||
this.log(chalk.green('The memory loaded successfully'));
|
||||
this.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
public log(msg: string) {
|
||||
log(chalk`[{magenta Core}]: ${msg}`);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private run() {
|
||||
//#region Init DB
|
||||
this.meta = this.getCollection('meta', {});
|
||||
|
||||
this.contexts = this.getCollection('contexts', {
|
||||
indices: ['key']
|
||||
});
|
||||
|
||||
this.timers = this.getCollection('timers', {
|
||||
indices: ['module']
|
||||
});
|
||||
|
||||
this.friends = this.getCollection('friends', {
|
||||
indices: ['userId']
|
||||
});
|
||||
|
||||
this.moduleData = this.getCollection('moduleData', {
|
||||
indices: ['module']
|
||||
});
|
||||
//#endregion
|
||||
|
||||
const meta = this.getMeta();
|
||||
this.lastSleepedAt = meta.lastWakingAt;
|
||||
|
||||
// Init stream
|
||||
this.connection = new Stream();
|
||||
|
||||
//#region Main stream
|
||||
const mainStream = this.connection.useSharedConnection('main');
|
||||
|
||||
// メンションされたとき
|
||||
mainStream.on('mention', async data => {
|
||||
if (data.userId == this.account.id) return; // 自分は弾く
|
||||
if (data.text && data.text.startsWith('@' + this.account.username)) {
|
||||
// Misskeyのバグで投稿が非公開扱いになる
|
||||
if (data.text == null) data = await this.api('notes/show', { noteId: data.id });
|
||||
this.onReceiveMessage(new Message(this, data, false));
|
||||
}
|
||||
});
|
||||
|
||||
// 返信されたとき
|
||||
mainStream.on('reply', async data => {
|
||||
if (data.userId == this.account.id) return; // 自分は弾く
|
||||
if (data.text && data.text.startsWith('@' + this.account.username)) return;
|
||||
// Misskeyのバグで投稿が非公開扱いになる
|
||||
if (data.text == null) data = await this.api('notes/show', { noteId: data.id });
|
||||
this.onReceiveMessage(new Message(this, data, false));
|
||||
});
|
||||
|
||||
// Renoteされたとき
|
||||
mainStream.on('renote', async data => {
|
||||
if (data.userId == this.account.id) return; // 自分は弾く
|
||||
if (data.text == null && (data.files || []).length == 0) return;
|
||||
|
||||
// リアクションする
|
||||
this.api('notes/reactions/create', {
|
||||
noteId: data.id,
|
||||
reaction: ':love_nullcatchan:'
|
||||
});
|
||||
});
|
||||
|
||||
// メッセージ
|
||||
mainStream.on('messagingMessage', data => {
|
||||
if (data.userId == this.account.id) return; // 自分は弾く
|
||||
this.onReceiveMessage(new Message(this, data, true));
|
||||
});
|
||||
|
||||
// 通知
|
||||
mainStream.on('notification', data => {
|
||||
this.onNotification(data);
|
||||
});
|
||||
//#endregion
|
||||
|
||||
// Install modules
|
||||
this.modules.forEach(m => {
|
||||
this.log(`Installing ${chalk.cyan.italic(m.name)}\tmodule...`);
|
||||
m.init(this);
|
||||
const res = m.install();
|
||||
if (res != null) {
|
||||
if (res.mentionHook) this.mentionHooks.push(res.mentionHook);
|
||||
if (res.contextHook) this.contextHooks[m.name] = res.contextHook;
|
||||
if (res.timeoutCallback) this.timeoutCallbacks[m.name] = res.timeoutCallback;
|
||||
}
|
||||
});
|
||||
|
||||
// タイマー監視
|
||||
this.crawleTimer();
|
||||
setInterval(this.crawleTimer, 1000);
|
||||
|
||||
setInterval(this.logWaking, 10000);
|
||||
|
||||
this.log(chalk.green.bold('Nullcat chan is now running!'));
|
||||
this.log(`Mode: ${process.env.NODE_ENV}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ユーザーから話しかけられたとき
|
||||
* (メンション、リプライ、トークのメッセージ)
|
||||
*/
|
||||
@autobind
|
||||
private async onReceiveMessage(msg: Message): Promise<void> {
|
||||
this.log(chalk.gray(`<<< An message received: ${chalk.underline(msg.id)}`));
|
||||
|
||||
// Ignore message if the user is a bot
|
||||
// To avoid infinity reply loop.
|
||||
if (msg.user.isBot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNoContext = !msg.isDm && msg.replyId == null;
|
||||
|
||||
// Look up the context
|
||||
const context = isNoContext
|
||||
? null
|
||||
: this.contexts.findOne(
|
||||
msg.isDm
|
||||
? {
|
||||
isDm: true,
|
||||
userId: msg.userId
|
||||
}
|
||||
: {
|
||||
isDm: false,
|
||||
noteId: msg.replyId
|
||||
}
|
||||
);
|
||||
|
||||
let reaction: string | null = ':love_nullcatchan:';
|
||||
let immediate: boolean = false;
|
||||
|
||||
//#region
|
||||
const invokeMentionHooks = async () => {
|
||||
let res: boolean | HandlerResult | null = null;
|
||||
|
||||
for (const handler of this.mentionHooks) {
|
||||
res = await handler(msg);
|
||||
if (res === true || typeof res === 'object') break;
|
||||
}
|
||||
|
||||
if (res != null && typeof res === 'object') {
|
||||
if (res.reaction != null) reaction = res.reaction;
|
||||
if (res.immediate != null) immediate = res.immediate;
|
||||
}
|
||||
};
|
||||
|
||||
// コンテキストがあればコンテキストフック呼び出し
|
||||
// なければそれぞれのモジュールについてフックが引っかかるまで呼び出し
|
||||
if (context != null) {
|
||||
const handler = this.contextHooks[context.module];
|
||||
const res = await handler(context.key, msg, context.data);
|
||||
|
||||
if (res != null && typeof res === 'object') {
|
||||
if (res.reaction != null) reaction = res.reaction;
|
||||
if (res.immediate != null) immediate = res.immediate;
|
||||
}
|
||||
|
||||
if (res === false) {
|
||||
await invokeMentionHooks();
|
||||
}
|
||||
} else {
|
||||
await invokeMentionHooks();
|
||||
}
|
||||
//#endregion
|
||||
|
||||
if (!immediate) {
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
if (msg.isDm) {
|
||||
// 既読にする
|
||||
this.api('messaging/messages/read', {
|
||||
messageId: msg.id
|
||||
});
|
||||
} else {
|
||||
// リアクションする
|
||||
if (reaction) {
|
||||
this.api('notes/reactions/create', {
|
||||
noteId: msg.id,
|
||||
reaction: reaction
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private onNotification(notification: any) {
|
||||
switch (notification.type) {
|
||||
// リアクションされたら親愛度を少し上げる
|
||||
// TODO: リアクション取り消しをよしなにハンドリングする
|
||||
case 'reaction': {
|
||||
const friend = new Friend(this, { user: notification.user });
|
||||
friend.incLove(0.1);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private crawleTimer() {
|
||||
const timers = this.timers.find();
|
||||
for (const timer of timers) {
|
||||
// タイマーが時間切れかどうか
|
||||
if (Date.now() - (timer.insertedAt + timer.delay) >= 0) {
|
||||
this.log(`Timer expired: ${timer.module} ${timer.id}`);
|
||||
this.timers.remove(timer);
|
||||
this.timeoutCallbacks[timer.module](timer.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private logWaking() {
|
||||
this.setMeta({
|
||||
lastWakingAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* データベースのコレクションを取得します
|
||||
*/
|
||||
@autobind
|
||||
public getCollection(name: string, opts?: any): loki.Collection {
|
||||
let collection: loki.Collection;
|
||||
|
||||
collection = this.db.getCollection(name);
|
||||
|
||||
if (collection == null) {
|
||||
collection = this.db.addCollection(name, opts);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public lookupFriend(userId: User['id']): Friend | null {
|
||||
const doc = this.friends.findOne({
|
||||
userId: userId
|
||||
});
|
||||
|
||||
if (doc == null) return null;
|
||||
|
||||
const friend = new Friend(this, { doc: doc });
|
||||
|
||||
return friend;
|
||||
}
|
||||
|
||||
/**
|
||||
* ファイルをドライブにアップロードします
|
||||
*/
|
||||
@autobind
|
||||
public async upload(file: Buffer | fs.ReadStream, meta: any) {
|
||||
const res = await request.post({
|
||||
url: `${config.apiUrl}/drive/files/create`,
|
||||
formData: {
|
||||
i: config.i,
|
||||
file: {
|
||||
value: file,
|
||||
options: meta
|
||||
}
|
||||
},
|
||||
json: true
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 投稿します
|
||||
*/
|
||||
@autobind
|
||||
public async post(param: any) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const res = await this.api('notes/create', param);
|
||||
return res.createdNote;
|
||||
} else {
|
||||
log(chalk`[{magenta Debug:Post}]: ${JSON.stringify(param)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定ユーザーにトークメッセージを送信します
|
||||
*/
|
||||
@autobind
|
||||
public sendMessage(userId: any, param: any) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return this.api(
|
||||
'messaging/messages/create',
|
||||
Object.assign(
|
||||
{
|
||||
userId: userId
|
||||
},
|
||||
param
|
||||
)
|
||||
);
|
||||
} else {
|
||||
log(chalk`[{magenta Debug:SendMessage}]: userId: ${userId}`);
|
||||
log(chalk`[{magenta Debug:SendMessage}]: param: ${JSON.stringify(param)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* APIを呼び出します
|
||||
*/
|
||||
@autobind
|
||||
public api(endpoint: string, param?: any) {
|
||||
return request.post(`${config.apiUrl}/${endpoint}`, {
|
||||
json: Object.assign(
|
||||
{
|
||||
i: config.i
|
||||
},
|
||||
param
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* コンテキストを生成し、ユーザーからの返信を待ち受けます
|
||||
* @param module 待ち受けるモジュール名
|
||||
* @param key コンテキストを識別するためのキー
|
||||
* @param isDm トークメッセージ上のコンテキストかどうか
|
||||
* @param id トークメッセージ上のコンテキストならばトーク相手のID、そうでないなら待ち受ける投稿のID
|
||||
* @param data コンテキストに保存するオプションのデータ
|
||||
*/
|
||||
@autobind
|
||||
public subscribeReply(module: Module, key: string | null, isDm: boolean, id: string, data?: any) {
|
||||
this.contexts.insertOne(
|
||||
isDm
|
||||
? {
|
||||
isDm: true,
|
||||
userId: id,
|
||||
module: module.name,
|
||||
key: key,
|
||||
data: data
|
||||
}
|
||||
: {
|
||||
isDm: false,
|
||||
noteId: id,
|
||||
module: module.name,
|
||||
key: key,
|
||||
data: data
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返信の待ち受けを解除します
|
||||
* @param module 解除するモジュール名
|
||||
* @param key コンテキストを識別するためのキー
|
||||
*/
|
||||
@autobind
|
||||
public unsubscribeReply(module: Module, key: string | null) {
|
||||
this.contexts.findAndRemove({
|
||||
key: key,
|
||||
module: module.name
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定したミリ秒経過後に、そのモジュールのタイムアウトコールバックを呼び出します。
|
||||
* このタイマーは記憶に永続化されるので、途中でプロセスを再起動しても有効です。
|
||||
* @param module モジュール名
|
||||
* @param delay ミリ秒
|
||||
* @param data オプションのデータ
|
||||
*/
|
||||
@autobind
|
||||
public setTimeoutWithPersistence(module: Module, delay: number, data?: any) {
|
||||
const id = uuid();
|
||||
this.timers.insertOne({
|
||||
id: id,
|
||||
module: module.name,
|
||||
insertedAt: Date.now(),
|
||||
delay: delay,
|
||||
data: data
|
||||
});
|
||||
|
||||
this.log(`Timer persisted: ${module.name} ${id} ${delay}ms`);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public getMeta() {
|
||||
const rec = this.meta.findOne();
|
||||
|
||||
if (rec) {
|
||||
return rec;
|
||||
} else {
|
||||
const initial: Meta = {
|
||||
lastWakingAt: Date.now()
|
||||
};
|
||||
|
||||
this.meta.insertOne(initial);
|
||||
return initial;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public setMeta(meta: Partial<Meta>) {
|
||||
const rec = this.getMeta();
|
||||
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
rec[k] = v;
|
||||
}
|
||||
|
||||
this.meta.update(rec);
|
||||
}
|
||||
}
|
21
src/config.ts
Normal file
21
src/config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
type Config = {
|
||||
host: string;
|
||||
i: string;
|
||||
master?: string;
|
||||
wsUrl: string;
|
||||
apiUrl: string;
|
||||
keywordEnabled: boolean;
|
||||
notingEnabled: boolean;
|
||||
serverMonitoring: boolean;
|
||||
mecab?: string;
|
||||
mecabDic?: string;
|
||||
memoryDir?: string;
|
||||
shellgeiUrl: string;
|
||||
};
|
||||
|
||||
const config = require('../config.json');
|
||||
|
||||
config.wsUrl = config.host.replace('http', 'ws');
|
||||
config.apiUrl = config.host + '/api';
|
||||
|
||||
export default config as Config;
|
190
src/friend.ts
Normal file
190
src/friend.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import 藍 from '@/ai';
|
||||
import IModule from '@/module';
|
||||
import getDate from '@/utils/get-date';
|
||||
import { User } from '@/misskey/user';
|
||||
import { genItem } from '@/vocabulary';
|
||||
|
||||
export type FriendDoc = {
|
||||
userId: string;
|
||||
user: User;
|
||||
name?: string | null;
|
||||
love?: number;
|
||||
lastLoveIncrementedAt?: string;
|
||||
todayLoveIncrements?: number;
|
||||
perModulesData?: any;
|
||||
married?: boolean;
|
||||
transferCode?: string;
|
||||
};
|
||||
|
||||
export default class Friend {
|
||||
private ai: 藍;
|
||||
|
||||
public get userId() {
|
||||
return this.doc.userId;
|
||||
}
|
||||
|
||||
public get name() {
|
||||
return this.doc.name;
|
||||
}
|
||||
|
||||
public get love() {
|
||||
return this.doc.love || 0;
|
||||
}
|
||||
|
||||
public get married() {
|
||||
return this.doc.married;
|
||||
}
|
||||
|
||||
public doc: FriendDoc;
|
||||
|
||||
constructor(ai: 藍, opts: { user?: User; doc?: FriendDoc }) {
|
||||
this.ai = ai;
|
||||
|
||||
if (opts.user) {
|
||||
const exist = this.ai.friends.findOne({
|
||||
userId: opts.user.id
|
||||
});
|
||||
|
||||
if (exist == null) {
|
||||
const inserted = this.ai.friends.insertOne({
|
||||
userId: opts.user.id,
|
||||
user: opts.user
|
||||
});
|
||||
|
||||
if (inserted == null) {
|
||||
throw new Error('Failed to insert friend doc');
|
||||
}
|
||||
|
||||
this.doc = inserted;
|
||||
} else {
|
||||
this.doc = exist;
|
||||
this.doc.user = { ...this.doc.user, ...opts.user };
|
||||
this.save();
|
||||
}
|
||||
} else if (opts.doc) {
|
||||
this.doc = opts.doc;
|
||||
} else {
|
||||
throw new Error('No friend info specified');
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public updateUser(user: Partial<User>) {
|
||||
this.doc.user = {
|
||||
...this.doc.user,
|
||||
...user
|
||||
};
|
||||
this.save();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public getPerModulesData(module: IModule) {
|
||||
if (this.doc.perModulesData == null) {
|
||||
this.doc.perModulesData = {};
|
||||
this.doc.perModulesData[module.name] = {};
|
||||
this.save();
|
||||
} else if (this.doc.perModulesData[module.name] == null) {
|
||||
this.doc.perModulesData[module.name] = {};
|
||||
this.save();
|
||||
}
|
||||
|
||||
return this.doc.perModulesData[module.name];
|
||||
}
|
||||
|
||||
@autobind
|
||||
public setPerModulesData(module: IModule, data: any) {
|
||||
if (this.doc.perModulesData == null) {
|
||||
this.doc.perModulesData = {};
|
||||
}
|
||||
|
||||
this.doc.perModulesData[module.name] = data;
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public incLove(amount = 1) {
|
||||
const today = getDate();
|
||||
|
||||
if (this.doc.lastLoveIncrementedAt != today) {
|
||||
this.doc.todayLoveIncrements = 0;
|
||||
}
|
||||
|
||||
// 1日に上げられる親愛度は最大3
|
||||
if (this.doc.lastLoveIncrementedAt == today && (this.doc.todayLoveIncrements || 0) >= 3) return;
|
||||
|
||||
if (this.doc.love == null) this.doc.love = 0;
|
||||
this.doc.love += amount;
|
||||
|
||||
// 最大 100
|
||||
if (this.doc.love > 100) this.doc.love = 100;
|
||||
|
||||
this.doc.lastLoveIncrementedAt = today;
|
||||
this.doc.todayLoveIncrements = (this.doc.todayLoveIncrements || 0) + amount;
|
||||
this.save();
|
||||
|
||||
this.ai.log(`💗 ${this.userId} +${amount}`);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public decLove(amount = 1) {
|
||||
// 親愛度MAXなら下げない
|
||||
if (this.doc.love === 100) return;
|
||||
|
||||
if (this.doc.love == null) this.doc.love = 0;
|
||||
this.doc.love -= amount;
|
||||
|
||||
// 最低 -30
|
||||
if (this.doc.love < -30) this.doc.love = -30;
|
||||
|
||||
// 親愛度マイナスなら名前を忘れる
|
||||
if (this.doc.love < 0) {
|
||||
this.doc.name = null;
|
||||
}
|
||||
|
||||
this.save();
|
||||
|
||||
this.ai.log(`💢 ${this.userId} -${amount}`);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public updateName(name: string) {
|
||||
this.doc.name = name;
|
||||
this.save();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public save() {
|
||||
this.ai.friends.update(this.doc);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public generateTransferCode(): string {
|
||||
const code = genItem();
|
||||
|
||||
this.doc.transferCode = code;
|
||||
this.save();
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public transferMemory(code: string): boolean {
|
||||
const src = this.ai.friends.findOne({
|
||||
transferCode: code
|
||||
});
|
||||
|
||||
if (src == null) return false;
|
||||
|
||||
this.doc.name = src.name;
|
||||
this.doc.love = src.love;
|
||||
this.doc.married = src.married;
|
||||
this.doc.perModulesData = src.perModulesData;
|
||||
this.save();
|
||||
|
||||
// TODO: 合言葉を忘れる
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
112
src/index.ts
Normal file
112
src/index.ts
Normal file
@ -0,0 +1,112 @@
|
||||
// AiOS bootstrapper
|
||||
|
||||
import 'module-alias/register';
|
||||
|
||||
import * as chalk from 'chalk';
|
||||
import * as request from 'request-promise-native';
|
||||
const promiseRetry = require('promise-retry');
|
||||
|
||||
import 藍 from './ai';
|
||||
import config from './config';
|
||||
import _log from './utils/log';
|
||||
const pkg = require('../package.json');
|
||||
|
||||
import CoreModule from './modules/core';
|
||||
import TalkModule from './modules/talk';
|
||||
import BirthdayModule from './modules/birthday';
|
||||
import PingModule from './modules/ping';
|
||||
import EmojiReactModule from './modules/emoji-react';
|
||||
import FortuneModule from './modules/fortune';
|
||||
import KeywordModule from './modules/keyword';
|
||||
import TimerModule from './modules/timer';
|
||||
import ServerModule from './modules/server';
|
||||
import FollowModule from './modules/follow';
|
||||
import ValentineModule from './modules/valentine';
|
||||
import SleepReportModule from './modules/sleep-report';
|
||||
import NotingModule from './modules/noting';
|
||||
import ReminderModule from './modules/reminder';
|
||||
|
||||
// Additional modules
|
||||
import FeelingModule from './modules/feeling';
|
||||
import GitHubStatusModule from './modules/github-status';
|
||||
import CloudflareStatus from './modules/cloudflare-status';
|
||||
import GomamayoModule from './modules/gomamayo';
|
||||
import JihouModule from './modules/jihou';
|
||||
import KiatsuModule from './modules/kiatsu';
|
||||
import RoguboModule from './modules/rogubo';
|
||||
import TraceMoeModule from './modules/trace-moe';
|
||||
import IsNaniModule from './modules/is-nani';
|
||||
import YarukotoModule from './modules/yarukoto';
|
||||
import ShellGeiModule from './modules/shellgei';
|
||||
import VersionModule from './modules/version';
|
||||
import AyashiiModule from './modules/ayashii';
|
||||
|
||||
console.log(' _ __ ____ __ ________ __ ');
|
||||
console.log(' / | / /_ __/ / /________ _/ /_/ ____/ /_ ____ _____ / / ');
|
||||
console.log(' / |/ / / / / / / ___/ __ `/ __/ / / __ \\/ __ `/ __ \\/ / ');
|
||||
console.log(' / /| / /_/ / / / /__/ /_/ / /_/ /___/ / / / /_/ / / / /_/ ');
|
||||
console.log('/_/ |_/\\__,_/_/_/\\___/\\__,_/\\__/\\____/_/ /_/\\__,_/_/ /_(_)\n');
|
||||
|
||||
function log(msg: string): void {
|
||||
_log(`[Boot]: ${msg}`);
|
||||
}
|
||||
|
||||
log(chalk.bold(`Nullcat chan! v${pkg._v}`));
|
||||
|
||||
promiseRetry(
|
||||
retry => {
|
||||
log(`Account fetching... ${chalk.gray(config.host)}`);
|
||||
|
||||
// アカウントをフェッチ
|
||||
return request
|
||||
.post(`${config.apiUrl}/i`, {
|
||||
json: {
|
||||
i: config.i
|
||||
}
|
||||
})
|
||||
.catch(retry);
|
||||
},
|
||||
{
|
||||
retries: 3
|
||||
}
|
||||
)
|
||||
.then(account => {
|
||||
const acct = `@${account.username}`;
|
||||
log(chalk.green(`Account fetched successfully: ${chalk.underline(acct)}`));
|
||||
|
||||
log('Starting Nullcat chan...');
|
||||
|
||||
// 藍起動
|
||||
new 藍(account, [
|
||||
new CoreModule(),
|
||||
new EmojiReactModule(),
|
||||
new FortuneModule(),
|
||||
new TimerModule(),
|
||||
new TalkModule(),
|
||||
new PingModule(),
|
||||
new ServerModule(),
|
||||
new FollowModule(),
|
||||
new BirthdayModule(),
|
||||
new ValentineModule(),
|
||||
new KeywordModule(),
|
||||
new SleepReportModule(),
|
||||
new NotingModule(),
|
||||
new ReminderModule(),
|
||||
new GomamayoModule(),
|
||||
new GitHubStatusModule(),
|
||||
new CloudflareStatus(),
|
||||
new YarukotoModule(),
|
||||
new RoguboModule(),
|
||||
new KiatsuModule(),
|
||||
new JihouModule(),
|
||||
new IsNaniModule(),
|
||||
new FeelingModule(),
|
||||
new TraceMoeModule(),
|
||||
new ShellGeiModule(),
|
||||
new VersionModule(),
|
||||
new AyashiiModule()
|
||||
]);
|
||||
})
|
||||
.catch(e => {
|
||||
log(chalk.red('Failed to fetch the account'));
|
||||
});
|
127
src/message.ts
Normal file
127
src/message.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import * as chalk from 'chalk';
|
||||
const delay = require('timeout-as-promise');
|
||||
|
||||
import 藍 from '@/ai';
|
||||
import Friend from '@/friend';
|
||||
import { User } from '@/misskey/user';
|
||||
import { MisskeyFile } from '@/misskey/file';
|
||||
import includes from '@/utils/includes';
|
||||
import or from '@/utils/or';
|
||||
import config from '@/config';
|
||||
|
||||
export default class Message {
|
||||
private ai: 藍;
|
||||
private messageOrNote: any;
|
||||
public isDm: boolean;
|
||||
|
||||
public get id(): string {
|
||||
return this.messageOrNote.id;
|
||||
}
|
||||
|
||||
public get user(): User {
|
||||
return this.messageOrNote.user;
|
||||
}
|
||||
|
||||
public get userId(): string {
|
||||
return this.messageOrNote.userId;
|
||||
}
|
||||
|
||||
public get text(): string {
|
||||
return this.messageOrNote.text;
|
||||
}
|
||||
|
||||
public get renotedText(): string | null {
|
||||
return this.messageOrNote.renote.text;
|
||||
}
|
||||
|
||||
public get quoteId(): string | null {
|
||||
return this.messageOrNote.renoteId;
|
||||
}
|
||||
|
||||
public get files(): MisskeyFile[] | undefined {
|
||||
return this.messageOrNote.files;
|
||||
}
|
||||
|
||||
public get visibility(): string {
|
||||
return this.messageOrNote.visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* メンション部分を除いたテキスト本文
|
||||
*/
|
||||
public get extractedText(): string {
|
||||
const host = new URL(config.host).host.replace(/\./g, '\\.');
|
||||
return this.text
|
||||
.replace(new RegExp(`^@${this.ai.account.username}@${host}\\s`, 'i'), '')
|
||||
.replace(new RegExp(`^@${this.ai.account.username}\\s`, 'i'), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
public get replyId(): string {
|
||||
return this.messageOrNote.replyId;
|
||||
}
|
||||
|
||||
public friend: Friend;
|
||||
|
||||
constructor(ai: 藍, messageOrNote: any, isDm: boolean) {
|
||||
this.ai = ai;
|
||||
this.messageOrNote = messageOrNote;
|
||||
this.isDm = isDm;
|
||||
|
||||
this.friend = new Friend(ai, { user: this.user });
|
||||
|
||||
// メッセージなどに付いているユーザー情報は省略されている場合があるので完全なユーザー情報を持ってくる
|
||||
this.ai
|
||||
.api('users/show', {
|
||||
userId: this.userId
|
||||
})
|
||||
.then(user => {
|
||||
this.friend.updateUser(user);
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
public async reply(
|
||||
text: string | null,
|
||||
opts?: {
|
||||
file?: any;
|
||||
cw?: string;
|
||||
renote?: string;
|
||||
immediate?: boolean;
|
||||
}
|
||||
) {
|
||||
if (text == null) return;
|
||||
|
||||
this.ai.log(`>>> Sending reply to ${chalk.underline(this.id)}`);
|
||||
|
||||
if (!opts?.immediate) {
|
||||
await delay(2000);
|
||||
}
|
||||
|
||||
if (this.isDm) {
|
||||
return await this.ai.sendMessage(this.messageOrNote.userId, {
|
||||
text: text,
|
||||
fileId: opts?.file?.id
|
||||
});
|
||||
} else {
|
||||
return await this.ai.post({
|
||||
replyId: this.messageOrNote.id,
|
||||
text: text,
|
||||
fileIds: opts?.file ? [opts?.file.id] : undefined,
|
||||
cw: opts?.cw,
|
||||
renoteId: opts?.renote
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public includes(words: string[]): boolean {
|
||||
return includes(this.text, words);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public or(words: (string | RegExp)[]): boolean {
|
||||
return or(this.text, words);
|
||||
}
|
||||
}
|
23
src/misskey/file.ts
Normal file
23
src/misskey/file.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { User } from './user';
|
||||
|
||||
export interface MisskeyFile {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
type: string;
|
||||
md5: string;
|
||||
size: number;
|
||||
isSensitive: boolean;
|
||||
blurhash: string | null;
|
||||
properties: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
comment?: unknown | null; // FIXME
|
||||
folderId: string | null;
|
||||
folder?: unknown | null; // FIXME
|
||||
userId: string | null;
|
||||
user: User | null;
|
||||
}
|
13
src/misskey/note.ts
Normal file
13
src/misskey/note.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type Note = {
|
||||
id: string;
|
||||
text: string | null;
|
||||
reply: any | null;
|
||||
poll?: {
|
||||
choices: {
|
||||
votes: number;
|
||||
text: string;
|
||||
}[];
|
||||
expiredAfter: number;
|
||||
multiple: boolean;
|
||||
} | null;
|
||||
};
|
8
src/misskey/user.ts
Normal file
8
src/misskey/user.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
host?: string | null;
|
||||
isFollowing?: boolean;
|
||||
isBot: boolean;
|
||||
};
|
74
src/module.ts
Normal file
74
src/module.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import 藍, { InstallerResult } from '@/ai';
|
||||
|
||||
export default abstract class Module {
|
||||
public abstract readonly name: string;
|
||||
|
||||
protected ai: 藍;
|
||||
private doc: any;
|
||||
|
||||
public init(ai: 藍) {
|
||||
this.ai = ai;
|
||||
|
||||
this.doc = this.ai.moduleData.findOne({
|
||||
module: this.name
|
||||
});
|
||||
|
||||
if (this.doc == null) {
|
||||
this.doc = this.ai.moduleData.insertOne({
|
||||
module: this.name,
|
||||
data: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public abstract install(): InstallerResult;
|
||||
|
||||
@autobind
|
||||
protected log(msg: string) {
|
||||
this.ai.log(`[${this.name}]: ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* コンテキストを生成し、ユーザーからの返信を待ち受けます
|
||||
* @param key コンテキストを識別するためのキー
|
||||
* @param isDm トークメッセージ上のコンテキストかどうか
|
||||
* @param id トークメッセージ上のコンテキストならばトーク相手のID、そうでないなら待ち受ける投稿のID
|
||||
* @param data コンテキストに保存するオプションのデータ
|
||||
*/
|
||||
@autobind
|
||||
protected subscribeReply(key: string | null, isDm: boolean, id: string, data?: any) {
|
||||
this.ai.subscribeReply(this, key, isDm, id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返信の待ち受けを解除します
|
||||
* @param key コンテキストを識別するためのキー
|
||||
*/
|
||||
@autobind
|
||||
protected unsubscribeReply(key: string | null) {
|
||||
this.ai.unsubscribeReply(this, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定したミリ秒経過後に、タイムアウトコールバックを呼び出します。
|
||||
* このタイマーは記憶に永続化されるので、途中でプロセスを再起動しても有効です。
|
||||
* @param delay ミリ秒
|
||||
* @param data オプションのデータ
|
||||
*/
|
||||
@autobind
|
||||
public setTimeoutWithPersistence(delay: number, data?: any) {
|
||||
this.ai.setTimeoutWithPersistence(this, delay, data);
|
||||
}
|
||||
|
||||
@autobind
|
||||
protected getData() {
|
||||
return this.doc.data;
|
||||
}
|
||||
|
||||
@autobind
|
||||
protected setData(data: any) {
|
||||
this.doc.data = data;
|
||||
this.ai.moduleData.update(this.doc);
|
||||
}
|
||||
}
|
28
src/modules/ayashii/index.ts
Normal file
28
src/modules/ayashii/index.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
import { generate } from 'cjp';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'ayashii';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(message: Message) {
|
||||
if (message.includes(['#怪しい日本語'])) {
|
||||
const context = message.extractedText.replace('#怪しい日本語', '').trim();
|
||||
const cjp = generate(context);
|
||||
|
||||
message.reply(cjp + ' #怪レい曰本语');
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
56
src/modules/birthday/index.ts
Normal file
56
src/modules/birthday/index.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Friend from '@/friend';
|
||||
import serifs from '@/serifs';
|
||||
|
||||
function zeroPadding(num: number, length: number): string {
|
||||
return ('0000000000' + num).slice(-length);
|
||||
}
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'birthday';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
this.crawleBirthday();
|
||||
setInterval(this.crawleBirthday, 1000 * 60 * 3);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 誕生日のユーザーがいないかチェック(いたら祝う)
|
||||
*/
|
||||
@autobind
|
||||
private crawleBirthday() {
|
||||
const now = new Date();
|
||||
const m = now.getMonth();
|
||||
const d = now.getDate();
|
||||
// Misskeyの誕生日は 2018-06-16 のような形式
|
||||
const today = `${zeroPadding(m + 1, 2)}-${zeroPadding(d, 2)}`;
|
||||
|
||||
const birthFriends = this.ai.friends.find({
|
||||
'user.birthday': { $regex: new RegExp('-' + today + '$') }
|
||||
} as any);
|
||||
|
||||
birthFriends.forEach(f => {
|
||||
const friend = new Friend(this.ai, { doc: f });
|
||||
|
||||
// 親愛度が3以上必要
|
||||
if (friend.love < 3) return;
|
||||
|
||||
const data = friend.getPerModulesData(this);
|
||||
|
||||
if (data.lastBirthdayChecked == today) return;
|
||||
|
||||
data.lastBirthdayChecked = today;
|
||||
friend.setPerModulesData(this, data);
|
||||
|
||||
const text = serifs.birthday.happyBirthday(friend.name);
|
||||
|
||||
this.ai.sendMessage(friend.userId, {
|
||||
text: text
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
61
src/modules/cloudflare-status/index.ts
Normal file
61
src/modules/cloudflare-status/index.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import config from '@/config';
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'cloudflare-status';
|
||||
|
||||
private readonly schema = z.object({
|
||||
status: z.object({
|
||||
description: z.string(),
|
||||
indicator: z.enum(['none', 'minor', 'major', 'critical'])
|
||||
})
|
||||
});
|
||||
|
||||
private indicator: z.infer<typeof this.schema>['status']['indicator'] = 'none';
|
||||
private description: z.infer<typeof this.schema>['status']['description'] = '';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
setInterval(this.updateStatus, 10 * 60 * 1000);
|
||||
this.updateStatus();
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async updateStatus() {
|
||||
try {
|
||||
const response = await fetch('https://www.cloudflarestatus.com/api/v2/status.json');
|
||||
const data = await response.json();
|
||||
|
||||
const result = this.schema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
this.indicator = result.data.status.indicator;
|
||||
this.description = result.data.status.description;
|
||||
} else {
|
||||
this.log('Validation failed.');
|
||||
console.warn(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('Failed to fetch status from Cloudflare.');
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.text?.toLowerCase().includes('cloudflare')) {
|
||||
msg.reply(`いまのCloudflareのステータスだよ!\n\nじょうきょう: ${this.indicator}\nせつめい: ${this.description}\nhttps://www.cloudflarestatus.com`);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
153
src/modules/core/index.ts
Normal file
153
src/modules/core/index.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
import serifs from '@/serifs';
|
||||
import { safeForInterpolate } from '@/utils/safe-for-interpolate';
|
||||
|
||||
const titles = ['さん', 'くん', '君', 'ちゃん', '様', '先生'];
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'core';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook,
|
||||
contextHook: this.contextHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (!msg.text) return false;
|
||||
|
||||
return this.transferBegin(msg) || this.transferEnd(msg) || this.setName(msg) || this.modules(msg) || this.version(msg);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private transferBegin(msg: Message): boolean {
|
||||
if (!msg.text) return false;
|
||||
if (!msg.includes(['引継', '引き継ぎ', '引越', '引っ越し'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) {
|
||||
msg.reply(serifs.core.transferNeedDm);
|
||||
return true;
|
||||
}
|
||||
|
||||
const code = msg.friend.generateTransferCode();
|
||||
|
||||
msg.reply(serifs.core.transferCode(code));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private transferEnd(msg: Message): boolean {
|
||||
if (!msg.text) return false;
|
||||
if (!msg.text.startsWith('「') || !msg.text.endsWith('」')) return false;
|
||||
|
||||
const code = msg.text.substring(1, msg.text.length - 1);
|
||||
|
||||
const succ = msg.friend.transferMemory(code);
|
||||
|
||||
if (succ) {
|
||||
msg.reply(serifs.core.transferDone(msg.friend.name));
|
||||
} else {
|
||||
msg.reply(serifs.core.transferFailed);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private setName(msg: Message): boolean {
|
||||
if (!msg.text) return false;
|
||||
if (!msg.text.includes('って呼んで')) return false;
|
||||
if (msg.text.startsWith('って呼んで')) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
const name = msg.text.match(/^(.+?)って呼んで/)![1];
|
||||
|
||||
if (name.length > 10) {
|
||||
msg.reply(serifs.core.tooLong);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!safeForInterpolate(name)) {
|
||||
msg.reply(serifs.core.invalidName);
|
||||
return true;
|
||||
}
|
||||
|
||||
const withSan = titles.some(t => name.endsWith(t));
|
||||
|
||||
if (withSan) {
|
||||
msg.friend.updateName(name);
|
||||
msg.reply(serifs.core.setNameOk(name));
|
||||
} else {
|
||||
msg.reply(serifs.core.san).then(reply => {
|
||||
this.subscribeReply(msg.userId, msg.isDm, msg.isDm ? msg.userId : reply.id, {
|
||||
name: name
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private modules(msg: Message): boolean {
|
||||
if (!msg.text) return false;
|
||||
if (!msg.or(['modules'])) return false;
|
||||
|
||||
let text = '```\n';
|
||||
|
||||
for (const m of this.ai.modules) {
|
||||
text += `${m.name}\n`;
|
||||
}
|
||||
|
||||
text += '```';
|
||||
|
||||
msg.reply(text, {
|
||||
immediate: true
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private version(msg: Message): boolean {
|
||||
if (!msg.text) return false;
|
||||
if (!msg.or(['v', 'version', 'バージョン'])) return false;
|
||||
|
||||
msg.reply(`\`\`\`\nv${this.ai.version}\n\`\`\``, {
|
||||
immediate: true
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async contextHook(key: any, msg: Message, data: any) {
|
||||
if (msg.text == null) return;
|
||||
|
||||
const done = () => {
|
||||
msg.reply(serifs.core.setNameOk(msg.friend.name));
|
||||
this.unsubscribeReply(key);
|
||||
};
|
||||
|
||||
if (msg.text.includes('うん')) {
|
||||
msg.friend.updateName(data.name + 'ちゃん');
|
||||
done();
|
||||
} else if (msg.text.includes('いいえ')) {
|
||||
msg.friend.updateName(data.name);
|
||||
done();
|
||||
} else {
|
||||
msg.reply(serifs.core.yesOrNo).then(reply => {
|
||||
this.subscribeReply(msg.userId, msg.isDm, reply.id, data);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
71
src/modules/emoji-react/index.ts
Normal file
71
src/modules/emoji-react/index.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import { parse } from 'twemoji-parser';
|
||||
const delay = require('timeout-as-promise');
|
||||
|
||||
import { Note } from '@/misskey/note';
|
||||
import Module from '@/module';
|
||||
import Stream from '@/stream';
|
||||
import includes from '@/utils/includes';
|
||||
|
||||
const gomamayo = require('gomamayo-js');
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'emoji-react';
|
||||
|
||||
private htl: ReturnType<Stream['useSharedConnection']>;
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
this.htl = this.ai.connection.useSharedConnection('homeTimeline');
|
||||
this.htl.on('note', this.onNote);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async onNote(note: Note) {
|
||||
if (note.reply != null) return;
|
||||
if (note.text == null) return;
|
||||
if (note.text.includes('@')) return; // (自分または他人問わず)メンションっぽかったらreject
|
||||
|
||||
const react = async (reaction: string, immediate = false) => {
|
||||
if (!immediate) {
|
||||
await delay(1500);
|
||||
}
|
||||
this.ai.api('notes/reactions/create', {
|
||||
noteId: note.id,
|
||||
reaction: reaction
|
||||
});
|
||||
};
|
||||
|
||||
if (await gomamayo.find(note.text)) return react(':bikkuribikkuri_:');
|
||||
if (includes(note.text, ['ぬるきゃっとちゃん', 'ぬるきゃぼっと', 'ぬるきゃっとぼっと'])) return react(':bibibi_nullcatchan:');
|
||||
if (
|
||||
includes(note.text, [
|
||||
'ねむい',
|
||||
'ねむたい',
|
||||
'ねたい',
|
||||
'ねれない',
|
||||
'ねれん',
|
||||
'ねれぬ',
|
||||
'ふむ',
|
||||
'つら',
|
||||
'死に',
|
||||
'つかれた',
|
||||
'疲れた',
|
||||
'しにたい',
|
||||
'きえたい',
|
||||
'消えたい',
|
||||
'やだ',
|
||||
'いやだ',
|
||||
'なきそう',
|
||||
'泣きそう',
|
||||
'辛い'
|
||||
])
|
||||
)
|
||||
return react(':nadenade_neko:');
|
||||
if (includes(note.text, ['理解した', 'りかいした', 'わかった', '頑張った', 'がんばった'])) return react(':erai:');
|
||||
if (note.text.match(/う[~|ー]*んこ/) || note.text.match(/unko/)) return react(':anataima_unkotte_iimashitane:');
|
||||
if (note.text.match(/う[~|ー]*ん$/) || note.text.match(/un$/)) return react(':ti_:');
|
||||
}
|
||||
}
|
31
src/modules/feeling/index.ts
Normal file
31
src/modules/feeling/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
import * as seedrandom from 'seedrandom';
|
||||
|
||||
export const feelings = ['つらい', 'ねむい', 'るんるん', '虚無'];
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'feeling';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.includes(['気分', 'きぶん'])) {
|
||||
const date = new Date();
|
||||
const seed = `${date.getFullYear()}/${date.getMonth()}/${date.getDate()}/${date.getHours()}/${msg.userId}`;
|
||||
const rng = seedrandom(seed);
|
||||
const feeling = feelings[Math.floor(rng() * feelings.length)];
|
||||
msg.reply(`**今は${feeling}かも**`);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
35
src/modules/follow/index.ts
Normal file
35
src/modules/follow/index.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'follow';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.text && msg.includes(['フォロー', 'フォロバ', 'follow me'])) {
|
||||
if (!msg.user.isFollowing) {
|
||||
this.ai.api('following/create', {
|
||||
userId: msg.userId
|
||||
});
|
||||
msg.reply('これからよろしくね!', { immediate: true });
|
||||
return {
|
||||
reaction: msg.friend.love >= 0 ? ':love_nullcatchan:' : null
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
reaction: msg.friend.love >= 0 ? ':love_nullcatchan:' : null
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
36
src/modules/fortune/index.ts
Normal file
36
src/modules/fortune/index.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
import serifs from '@/serifs';
|
||||
import * as seedrandom from 'seedrandom';
|
||||
import { genItem } from '@/vocabulary';
|
||||
|
||||
export const blessing = ['にゃん吉🐈', 'みゃ~吉🐾', 'ぬるきゃっと吉:love_nullcatchan:', 'なんかすごい吉✨', '特大吉✨', '大大吉🎊', '大吉🎊', '吉🎉', '中吉🎉', '小吉🎉', '凶🗿', '大凶🗿'];
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'fortune';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.includes(['占', 'うらな', '運勢', 'おみくじ'])) {
|
||||
const date = new Date();
|
||||
const seed = `${date.getFullYear()}/${date.getMonth()}/${date.getDate()}@${msg.userId}`;
|
||||
const rng = seedrandom(seed);
|
||||
const omikuji = blessing[Math.floor(rng() * blessing.length)];
|
||||
const item = genItem(rng);
|
||||
msg.reply(`**${omikuji}**\nラッキーアイテム: ${item}`, {
|
||||
cw: serifs.fortune.cw(msg.friend.name)
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
90
src/modules/github-status/index.ts
Normal file
90
src/modules/github-status/index.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import config from '@/config';
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'github-status';
|
||||
|
||||
private readonly schema = z.object({
|
||||
status: z.object({
|
||||
description: z.string(),
|
||||
indicator: z.enum(['none', 'minor', 'major', 'critical', 'maintenance'])
|
||||
})
|
||||
});
|
||||
|
||||
private indicatorString: Record<z.infer<typeof this.schema>['status']['indicator'], string> = {
|
||||
none: '今はGitHubなんともないみたい!!',
|
||||
minor: 'GitHubにちょっとしたエラーが起きてるかも',
|
||||
major: 'GitHubにエラーが起きてるみたい',
|
||||
critical: 'GitHubに重大なエラーが起きてるみたい',
|
||||
maintenance: 'GitHubがメンテナンス中みたい'
|
||||
};
|
||||
|
||||
private indicator: z.infer<typeof this.schema>['status']['indicator'] = 'none';
|
||||
private description: z.infer<typeof this.schema>['status']['description'] = '';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
setInterval(this.updateStatus, 10 * 60 * 1000);
|
||||
setInterval(this.postStatus, 60 * 60 * 1000);
|
||||
|
||||
this.updateStatus();
|
||||
this.postStatus();
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async updateStatus() {
|
||||
try {
|
||||
const response = await fetch('https://www.githubstatus.com/api/v2/status.json');
|
||||
const data = await response.json();
|
||||
|
||||
const result = this.schema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
this.indicator = result.data.status.indicator;
|
||||
this.description = result.data.status.description;
|
||||
} else {
|
||||
this.log('Validation failed.');
|
||||
console.warn(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('Failed to fetch status from GitHub.');
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private postStatus() {
|
||||
switch (this.indicator) {
|
||||
case 'minor':
|
||||
case 'major':
|
||||
case 'critical':
|
||||
this.ai.post({
|
||||
text: `${this.indicatorString[this.indicator]}\nせつめい: ${this.description}\nhttps://www.githubstatus.com/`
|
||||
});
|
||||
|
||||
this.log('Report posted.');
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.text?.toLowerCase().includes('github')) {
|
||||
msg.reply(`${this.indicatorString[this.indicator]}\nせつめい: ${this.description}\nhttps://www.githubstatus.com`);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
37
src/modules/gomamayo/index.ts
Normal file
37
src/modules/gomamayo/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
const gomamayo = require('gomamayo-js');
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'gomamayo';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.text && msg.text.includes('ゴママヨ')) {
|
||||
const notetext = msg.renotedText != null ? msg.renotedText : msg.text;
|
||||
const gomamayoResult = await gomamayo.find(notetext.replace(/ゴママヨ/g, ''));
|
||||
let resBodyText, resCwText;
|
||||
if (gomamayoResult) {
|
||||
resCwText = 'ゴママヨかもしれない';
|
||||
resBodyText = JSON.stringify(gomamayoResult, undefined, 2);
|
||||
} else {
|
||||
resBodyText = 'ゴママヨじゃないかも';
|
||||
}
|
||||
msg.reply(resBodyText, {
|
||||
immediate: true,
|
||||
cw: resCwText
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
27
src/modules/is-nani/index.ts
Normal file
27
src/modules/is-nani/index.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'is-nani';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(message: Message) {
|
||||
if (!message.includes(['って何', 'ってなに', 'ってにゃに', ':is_nani:'])) return false;
|
||||
|
||||
const match = message.extractedText.match(/(.+?)って(何|なに|にゃに)/);
|
||||
|
||||
if (match) {
|
||||
message.reply(`Google先生に聞いてみた!!!\n${match[1]} 検索`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
49
src/modules/jihou/index.ts
Normal file
49
src/modules/jihou/index.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
|
||||
const accurateInterval = require('accurate-interval');
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'jihou';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
accurateInterval(this.post, 1000 * 60 * 60, { aligned: true, immediate: true });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async post() {
|
||||
const date = new Date();
|
||||
date.setMinutes(date.getMinutes() + 1);
|
||||
|
||||
const hour = date.getHours();
|
||||
|
||||
switch (hour) {
|
||||
default:
|
||||
this.ai.post({
|
||||
text: `${hour}時だよ!`
|
||||
});
|
||||
break;
|
||||
|
||||
case 7:
|
||||
this.ai.post({
|
||||
text: `みんなおはよ!${hour}時だよ!`
|
||||
});
|
||||
break;
|
||||
|
||||
case 1:
|
||||
this.ai.post({
|
||||
text: `${hour}時だよ!みんなそろそろ寝る時間かな?`
|
||||
});
|
||||
break;
|
||||
|
||||
case 5:
|
||||
this.ai.post({
|
||||
text: `${hour}時だよ!ログボリセットの時間だよ!!`
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
101
src/modules/keyword/index.ts
Normal file
101
src/modules/keyword/index.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import * as loki from 'lokijs';
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import NGWord from '@/ng-words';
|
||||
import config from '@/config';
|
||||
import serifs from '@/serifs';
|
||||
import { mecab } from './mecab';
|
||||
|
||||
function kanaToHira(str: string) {
|
||||
return str.replace(/[\u30a1-\u30f6]/g, match => {
|
||||
const chr = match.charCodeAt(0) - 0x60;
|
||||
return String.fromCharCode(chr);
|
||||
});
|
||||
}
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'keyword';
|
||||
|
||||
private learnedKeywords: loki.Collection<{
|
||||
keyword: string;
|
||||
learnedAt: number;
|
||||
}>;
|
||||
|
||||
private ngWord = new NGWord();
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
if (!config.keywordEnabled) return {};
|
||||
|
||||
this.learnedKeywords = this.ai.getCollection('_keyword_learnedKeywords', {
|
||||
indices: ['userId']
|
||||
});
|
||||
|
||||
setInterval(this.learn, 1000 * 60 * 45);
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async learn() {
|
||||
const tl = await this.ai.api('notes/hybrid-timeline', {
|
||||
limit: 30
|
||||
});
|
||||
|
||||
const interestedNotes = tl.filter(note => note.userId !== this.ai.account.id && note.text != null && note.cw == null);
|
||||
|
||||
let keywords: string[][] = [];
|
||||
|
||||
for (const note of interestedNotes) {
|
||||
const tokens = await mecab(note.text, config.mecab, config.mecabDic);
|
||||
const keywordsInThisNote = tokens.filter(token => token[2] == '固有名詞' && token[8] != null);
|
||||
keywords = keywords.concat(keywordsInThisNote);
|
||||
}
|
||||
|
||||
if (keywords.length === 0) return;
|
||||
|
||||
const rnd = Math.floor((1 - Math.sqrt(Math.random())) * keywords.length);
|
||||
const keyword = keywords.sort((a, b) => (a[0].length < b[0].length ? 1 : -1))[rnd];
|
||||
|
||||
const exist = this.learnedKeywords.findOne({
|
||||
keyword: keyword[0]
|
||||
});
|
||||
|
||||
let text: string;
|
||||
|
||||
if (exist) {
|
||||
return;
|
||||
} else {
|
||||
this.learnedKeywords.insertOne({
|
||||
keyword: keyword[0],
|
||||
learnedAt: Date.now()
|
||||
});
|
||||
|
||||
const isNGWord = this.ngWord.get.some(word => keyword[0] === word);
|
||||
if (isNGWord) return;
|
||||
|
||||
text = serifs.keyword.learned(keyword[0], kanaToHira(keyword[8]));
|
||||
}
|
||||
|
||||
this.ai.post({
|
||||
text: text
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.includes(['覚えて', 'おぼえて'])) {
|
||||
this.log('Keyword learn requested');
|
||||
msg.reply('がんばってみるね');
|
||||
this.learn();
|
||||
return {
|
||||
reaction: ':bikkuri_nullcatchan:'
|
||||
};
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
45
src/modules/keyword/mecab.ts
Normal file
45
src/modules/keyword/mecab.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { spawn } from 'child_process';
|
||||
import * as util from 'util';
|
||||
import * as stream from 'stream';
|
||||
import * as memoryStreams from 'memory-streams';
|
||||
import { EOL } from 'os';
|
||||
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
/**
|
||||
* Run MeCab
|
||||
* @param text Text to analyze
|
||||
* @param mecab mecab bin
|
||||
* @param dic mecab dictionaly path
|
||||
*/
|
||||
export async function mecab(text: string, mecab = 'mecab', dic?: string): Promise<string[][]> {
|
||||
const args: string[] = [];
|
||||
if (dic) args.push('-d', dic);
|
||||
|
||||
const lines = await cmd(mecab, args, `${text.replace(/[\n\s\t]/g, ' ')}\n`);
|
||||
|
||||
const results: string[][] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === 'EOS') break;
|
||||
const [word, value = ''] = line.split('\t');
|
||||
const array = value.split(',');
|
||||
array.unshift(word);
|
||||
results.push(array);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function cmd(command: string, args: string[], stdin: string): Promise<string[]> {
|
||||
const mecab = spawn(command, args);
|
||||
|
||||
const writable = new memoryStreams.WritableStream();
|
||||
|
||||
mecab.stdin.write(stdin);
|
||||
mecab.stdin.end();
|
||||
|
||||
await pipeline(mecab.stdout, writable);
|
||||
|
||||
return writable.toString().split(EOL);
|
||||
}
|
96
src/modules/kiatsu/index.ts
Normal file
96
src/modules/kiatsu/index.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'kiatsu';
|
||||
|
||||
private readonly itemSchema = z.object({
|
||||
time: z.enum(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23']),
|
||||
weather: z.string(),
|
||||
temp: z.string(),
|
||||
pressure: z.string(),
|
||||
pressure_level: z.enum(['0', '1', '2', '3', '4'])
|
||||
});
|
||||
|
||||
private readonly schema = z.object({
|
||||
place_name: z.literal('東京都中央区'),
|
||||
place_id: z.literal('102'),
|
||||
prefectures_id: z.literal('13'),
|
||||
dateTime: z.string(),
|
||||
yesterday: z.array(this.itemSchema).optional(),
|
||||
today: z.array(this.itemSchema),
|
||||
tomorrow: z.array(this.itemSchema).optional(),
|
||||
dayaftertomorrow: z.array(this.itemSchema).optional()
|
||||
});
|
||||
|
||||
private currentPressure: z.infer<typeof this.itemSchema>['pressure'] = '';
|
||||
|
||||
private currentPressureLevel: z.infer<typeof this.itemSchema>['pressure_level'] = '0';
|
||||
|
||||
private readonly stringPressureLevel: { [K in typeof this.currentPressureLevel]: (hPa: string) => string } = {
|
||||
0: hPa => `${hPa}hPaだから問題ないかも。無理しないでね。`,
|
||||
1: hPa => `${hPa}hPaだから問題ないかも。無理しないでね。`,
|
||||
2: hPa => `気圧${hPa}hPaでちょっとやばいかも。無理しないでね。`,
|
||||
3: hPa => `気圧${hPa}hPaでやばいかも。無理しないでね。`,
|
||||
4: hPa => `気圧${hPa}hPaでかなりやばいかも。無理しないでね。`
|
||||
} as const;
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
setInterval(this.update, 10 * 60 * 1000);
|
||||
setInterval(this.post, 12 * 60 * 60 * 1000);
|
||||
|
||||
this.update();
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async update() {
|
||||
try {
|
||||
const response = await fetch('https://zutool.jp/api/getweatherstatus/13102');
|
||||
const data = await response.json();
|
||||
|
||||
const result = this.schema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
this.log('Validation failed.');
|
||||
console.warn(result.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date();
|
||||
const hour = this.itemSchema.shape.time.parse(date.getHours().toString());
|
||||
|
||||
this.currentPressureLevel = result.data.today[hour].pressure_level;
|
||||
this.currentPressure = result.data.today[hour].pressure;
|
||||
} catch (error) {
|
||||
this.log('Failed to fetch status.');
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private post() {
|
||||
if (this.currentPressureLevel === '0' || this.currentPressureLevel === '1') return;
|
||||
|
||||
this.ai.post({
|
||||
text: this.stringPressureLevel[this.currentPressureLevel](this.currentPressure)
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(message: Message) {
|
||||
if (!message.includes(['気圧', 'きあつ'])) return false;
|
||||
|
||||
message.reply(this.stringPressureLevel[this.currentPressureLevel](this.currentPressure), { immediate: true });
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
34
src/modules/noting/index.ts
Normal file
34
src/modules/noting/index.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import serifs from '@/serifs';
|
||||
import config from '@/config';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'noting';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
if (config.notingEnabled === false) return {};
|
||||
|
||||
setInterval(() => {
|
||||
if (Math.random() < 0.1) {
|
||||
this.post();
|
||||
}
|
||||
}, 1000 * 60 * 10);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private post() {
|
||||
const notes = serifs.noting.notes;
|
||||
|
||||
const note = notes[Math.floor(Math.random() * notes.length)];
|
||||
|
||||
// TODO: 季節に応じたセリフ
|
||||
|
||||
this.ai.post({
|
||||
text: note
|
||||
});
|
||||
}
|
||||
}
|
26
src/modules/ping/index.ts
Normal file
26
src/modules/ping/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'ping';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.text && msg.text.includes('ping')) {
|
||||
msg.reply('$[x2 :bibibi_nullcatchan:]', {
|
||||
immediate: true
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
183
src/modules/reminder/index.ts
Normal file
183
src/modules/reminder/index.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import * as loki from 'lokijs';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
import serifs, { getSerif } from '@/serifs';
|
||||
import { acct } from '@/utils/acct';
|
||||
import config from '@/config';
|
||||
|
||||
const NOTIFY_INTERVAL = 1000 * 60 * 60 * 1;
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'reminder';
|
||||
|
||||
private reminds: loki.Collection<{
|
||||
userId: string;
|
||||
id: string;
|
||||
isDm: boolean;
|
||||
thing: string | null;
|
||||
quoteId: string | null;
|
||||
times: number; // 催促した回数(使うのか?)
|
||||
createdAt: number;
|
||||
}>;
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
this.reminds = this.ai.getCollection('reminds', {
|
||||
indices: ['userId', 'id']
|
||||
});
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook,
|
||||
contextHook: this.contextHook,
|
||||
timeoutCallback: this.timeoutCallback
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
let text = msg.extractedText.toLowerCase();
|
||||
if (!text.startsWith('リマインド') && !text.startsWith('todo') && !text.startsWith('これやる')) return false;
|
||||
|
||||
if (text.startsWith('リスト') || text.startsWith('todos')) {
|
||||
const reminds = this.reminds.find({
|
||||
userId: msg.userId
|
||||
});
|
||||
|
||||
const getQuoteLink = id => `[${id}](${config.host}/notes/${id})`;
|
||||
if (reminds.length === 0) {
|
||||
msg.reply(serifs.reminder.none);
|
||||
} else {
|
||||
msg.reply(serifs.reminder.reminds + '\n' + reminds.map(remind => `・${remind.thing ? remind.thing : getQuoteLink(remind.quoteId)}`).join('\n'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text.match(/^(.+?)\s(.+)/)) {
|
||||
text = text.replace(/^(.+?)\s/, '');
|
||||
} else {
|
||||
text = '';
|
||||
}
|
||||
|
||||
const separatorIndex = text.indexOf(' ') > -1 ? text.indexOf(' ') : text.indexOf('\n');
|
||||
const thing = text.substr(separatorIndex + 1).trim();
|
||||
|
||||
if ((thing === '' && msg.quoteId == null) || msg.visibility === 'followers') {
|
||||
msg.reply(serifs.reminder.invalid);
|
||||
return {
|
||||
reaction: '🆖',
|
||||
immediate: true
|
||||
};
|
||||
}
|
||||
|
||||
const remind = this.reminds.insertOne({
|
||||
id: msg.id,
|
||||
userId: msg.userId,
|
||||
isDm: msg.isDm,
|
||||
thing: thing === '' ? null : thing,
|
||||
quoteId: msg.quoteId,
|
||||
times: 0,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
// メンションをsubscribe
|
||||
this.subscribeReply(remind!.id, msg.isDm, msg.isDm ? msg.userId : msg.id, {
|
||||
id: remind!.id
|
||||
});
|
||||
|
||||
if (msg.quoteId) {
|
||||
// 引用元をsubscribe
|
||||
this.subscribeReply(remind!.id, false, msg.quoteId, {
|
||||
id: remind!.id
|
||||
});
|
||||
}
|
||||
|
||||
// タイマーセット
|
||||
this.setTimeoutWithPersistence(NOTIFY_INTERVAL, {
|
||||
id: remind!.id
|
||||
});
|
||||
|
||||
return {
|
||||
reaction: '🆗',
|
||||
immediate: true
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async contextHook(key: any, msg: Message, data: any) {
|
||||
if (msg.text == null) return;
|
||||
|
||||
const remind = this.reminds.findOne({
|
||||
id: data.id
|
||||
});
|
||||
|
||||
if (remind == null) {
|
||||
this.unsubscribeReply(key);
|
||||
return;
|
||||
}
|
||||
|
||||
const done = msg.includes(['done', 'やった', 'やりました', 'はい', 'どね', 'ドネ']);
|
||||
const cancel = msg.includes(['やめる', 'やめた', 'キャンセル']);
|
||||
const isOneself = msg.userId === remind.userId;
|
||||
|
||||
if ((done || cancel) && isOneself) {
|
||||
this.unsubscribeReply(key);
|
||||
this.reminds.remove(remind);
|
||||
msg.reply(done ? getSerif(serifs.reminder.done(msg.friend.name)) : serifs.reminder.cancel);
|
||||
return;
|
||||
} else if (isOneself === false) {
|
||||
msg.reply(serifs.reminder.doneFromInvalidUser);
|
||||
return;
|
||||
} else {
|
||||
if (msg.isDm) this.unsubscribeReply(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async timeoutCallback(data) {
|
||||
const remind = this.reminds.findOne({
|
||||
id: data.id
|
||||
});
|
||||
if (remind == null) return;
|
||||
|
||||
remind.times++;
|
||||
this.reminds.update(remind);
|
||||
|
||||
const friend = this.ai.lookupFriend(remind.userId);
|
||||
if (friend == null) return; // 処理の流れ上、実際にnullになることは無さそうだけど一応
|
||||
|
||||
let reply;
|
||||
if (remind.isDm) {
|
||||
this.ai.sendMessage(friend.userId, {
|
||||
text: serifs.reminder.notifyWithThing(remind.thing, friend.name)
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
reply = await this.ai.post({
|
||||
renoteId: remind.thing == null && remind.quoteId ? remind.quoteId : remind.id,
|
||||
text: acct(friend.doc.user) + ' ' + serifs.reminder.notify(friend.name),
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [remind.userId]
|
||||
});
|
||||
} catch (err) {
|
||||
// renote対象が消されていたらリマインダー解除
|
||||
if (err.statusCode === 400) {
|
||||
this.unsubscribeReply(remind.thing == null && remind.quoteId ? remind.quoteId : remind.id);
|
||||
this.reminds.remove(remind);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.subscribeReply(remind.id, remind.isDm, remind.isDm ? remind.userId : reply.id, {
|
||||
id: remind.id
|
||||
});
|
||||
|
||||
// タイマーセット
|
||||
this.setTimeoutWithPersistence(NOTIFY_INTERVAL, {
|
||||
id: remind.id
|
||||
});
|
||||
}
|
||||
}
|
41
src/modules/rogubo/index.ts
Normal file
41
src/modules/rogubo/index.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import Module from '@/module';
|
||||
import serifs from '@/serifs';
|
||||
import autobind from 'autobind-decorator';
|
||||
|
||||
const accurateInterval = require('accurate-interval');
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'rogubo';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
accurateInterval(this.post, 1000 * 60 * 60, { aligned: true, immediate: true });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async post() {
|
||||
const date = new Date();
|
||||
date.setMinutes(date.getMinutes() + 1);
|
||||
|
||||
if (!(date.getHours() === 6)) return;
|
||||
|
||||
const data = this.getData();
|
||||
const localDateString = date.toLocaleDateString();
|
||||
|
||||
if (data.lastPostDate === localDateString) {
|
||||
this.log('Already posted today.');
|
||||
return;
|
||||
}
|
||||
|
||||
data.lastPostDate = localDateString;
|
||||
this.setData(data);
|
||||
|
||||
setTimeout(() => {
|
||||
this.ai.post({
|
||||
text: serifs.rogubo
|
||||
});
|
||||
}, 1000 * 60 * 60 * Math.random());
|
||||
}
|
||||
}
|
79
src/modules/server/index.ts
Normal file
79
src/modules/server/index.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import serifs from '@/serifs';
|
||||
import config from '@/config';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'server';
|
||||
|
||||
private connection?: any;
|
||||
private recentStat: any;
|
||||
private warned = false;
|
||||
private lastWarnedAt: number;
|
||||
|
||||
/**
|
||||
* 1秒毎のログ1分間分
|
||||
*/
|
||||
private statsLogs: any[] = [];
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
if (!config.serverMonitoring) return {};
|
||||
|
||||
this.connection = this.ai.connection.useSharedConnection('serverStats');
|
||||
this.connection.on('stats', this.onStats);
|
||||
|
||||
setInterval(() => {
|
||||
this.statsLogs.unshift(this.recentStat);
|
||||
if (this.statsLogs.length > 60) this.statsLogs.pop();
|
||||
}, 1000);
|
||||
|
||||
setInterval(() => {
|
||||
this.check();
|
||||
}, 3000);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private check() {
|
||||
const average = arr => arr.reduce((a, b) => a + b) / arr.length;
|
||||
|
||||
const cpuPercentages = this.statsLogs.map(s => (s && (s.cpu_usage || s.cpu) * 100) || 0);
|
||||
const cpuPercentage = average(cpuPercentages);
|
||||
if (cpuPercentage >= 70) {
|
||||
this.warn();
|
||||
} else if (cpuPercentage <= 30) {
|
||||
this.warned = false;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async onStats(stats: any) {
|
||||
this.recentStat = stats;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private warn() {
|
||||
//#region 前に警告したときから一旦落ち着いた状態を経験していなければ警告しない
|
||||
// 常に負荷が高いようなサーバーで無限に警告し続けるのを防ぐため
|
||||
if (this.warned) return;
|
||||
//#endregion
|
||||
|
||||
//#region 前の警告から1時間経っていない場合は警告しない
|
||||
const now = Date.now();
|
||||
|
||||
if (this.lastWarnedAt != null) {
|
||||
if (now - this.lastWarnedAt < 1000 * 60 * 60) return;
|
||||
}
|
||||
|
||||
this.lastWarnedAt = now;
|
||||
//#endregion
|
||||
|
||||
this.ai.post({
|
||||
text: serifs.server.cpu
|
||||
});
|
||||
|
||||
this.warned = true;
|
||||
}
|
||||
}
|
65
src/modules/shellgei/index.ts
Normal file
65
src/modules/shellgei/index.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
import config from '@/config';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'shellgei';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (!msg.text) return false;
|
||||
if ((msg.text && msg.text.includes('#シェル芸')) || msg.text.includes('#shellgei')) {
|
||||
const myInfoBody = { i: config.i };
|
||||
const myInfoOptions = { method: 'POST', body: JSON.stringify(myInfoBody), headers: { 'Content-Type': 'application/json' } };
|
||||
const myInfo = await fetch(`${config.apiUrl}/i`, myInfoOptions);
|
||||
const myInfoJson: any = await myInfo.json();
|
||||
const myId = myInfoJson.username;
|
||||
|
||||
const acct = `@${myId}`;
|
||||
const hostname = config.host.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
const hostnameat = `@${hostname}`;
|
||||
|
||||
const shellText = msg.text.replace('#シェル芸', '').replace('#shellgei', '').replace(acct, '').replace(hostnameat, '');
|
||||
this.log(shellText);
|
||||
const shellgeiBody = { code: shellText, images: [] };
|
||||
const shellgeiOptions = { method: 'POST', body: JSON.stringify(shellgeiBody), headers: { 'Content-Type': 'application/json' } };
|
||||
const shellgeiURL = config.shellgeiUrl;
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
const shellgeiResult = await fetch(shellgeiURL, shellgeiOptions);
|
||||
const shellgeiResultJson: any = await shellgeiResult.json();
|
||||
const shellgeiResultStdOut = shellgeiResultJson.stdout;
|
||||
const shellgeiResultStdErr = shellgeiResultJson.stderr;
|
||||
if (shellgeiResultStdOut === '' && shellgeiResultStdErr === '') {
|
||||
msg.reply(`結果がなかったよ:cry_nullcatchan:`, {
|
||||
immediate: true
|
||||
});
|
||||
} else {
|
||||
msg.reply(shellgeiResultStdOut + shellgeiResultStdErr, {
|
||||
immediate: true
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
msg.reply(`エラーが発生しちゃったよ:cry_nullcatchan:\n${e}`, {
|
||||
immediate: true
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
35
src/modules/sleep-report/index.ts
Normal file
35
src/modules/sleep-report/index.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import serifs from '@/serifs';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'sleepReport';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
this.report();
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private report() {
|
||||
const now = Date.now();
|
||||
|
||||
const sleepTime = now - this.ai.lastSleepedAt;
|
||||
|
||||
const sleepHours = sleepTime / 1000 / 60 / 60;
|
||||
|
||||
if (sleepHours < 0.1) return;
|
||||
|
||||
if (sleepHours >= 1) {
|
||||
this.ai.post({
|
||||
text: serifs.sleepReport.report(Math.round(sleepHours))
|
||||
});
|
||||
} else {
|
||||
this.ai.post({
|
||||
text: serifs.sleepReport.reportUtatane
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
343
src/modules/talk/index.ts
Normal file
343
src/modules/talk/index.ts
Normal file
@ -0,0 +1,343 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import { HandlerResult } from '@/ai';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
import serifs, { getSerif } from '@/serifs';
|
||||
import getDate from '@/utils/get-date';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'talk';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (!msg.text) return false;
|
||||
|
||||
return (
|
||||
this.greet(msg) ||
|
||||
this.erait(msg) ||
|
||||
this.omedeto(msg) ||
|
||||
this.nadenade(msg) ||
|
||||
this.kawaii(msg) ||
|
||||
this.suki(msg) ||
|
||||
this.hug(msg) ||
|
||||
this.humu(msg) ||
|
||||
this.batou(msg) ||
|
||||
this.itai(msg) ||
|
||||
this.turai(msg) ||
|
||||
this.kurusii(msg) ||
|
||||
this.ote(msg) ||
|
||||
this.ponkotu(msg) ||
|
||||
this.rmrf(msg) ||
|
||||
this.shutdown(msg)
|
||||
);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private greet(msg: Message): boolean {
|
||||
if (msg.text == null) return false;
|
||||
|
||||
const incLove = () => {
|
||||
//#region 1日に1回だけ親愛度を上げる
|
||||
const today = getDate();
|
||||
|
||||
const data = msg.friend.getPerModulesData(this);
|
||||
|
||||
if (data.lastGreetedAt == today) return;
|
||||
|
||||
data.lastGreetedAt = today;
|
||||
msg.friend.setPerModulesData(this, data);
|
||||
|
||||
msg.friend.incLove();
|
||||
//#endregion
|
||||
};
|
||||
|
||||
// 末尾のエクスクラメーションマーク
|
||||
const tension = (msg.text.match(/[!!]{2,}/g) || ['']).sort((a, b) => (a.length < b.length ? 1 : -1))[0].substr(1);
|
||||
|
||||
if (msg.includes(['こんにちは', 'こんにちわ'])) {
|
||||
msg.reply(serifs.core.hello(msg.friend.name));
|
||||
incLove();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.includes(['こんばんは', 'こんばんわ'])) {
|
||||
msg.reply(serifs.core.helloNight(msg.friend.name));
|
||||
incLove();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.includes(['おは', 'おっは', 'お早う'])) {
|
||||
msg.reply(serifs.core.goodMorning(tension, msg.friend.name));
|
||||
incLove();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.includes(['おやすみ', 'お休み'])) {
|
||||
msg.reply(serifs.core.goodNight(msg.friend.name));
|
||||
incLove();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.includes(['行ってくる', '行ってきます', 'いってくる', 'いってきます'])) {
|
||||
msg.reply(msg.friend.love >= 7 ? serifs.core.itterassyai.love(msg.friend.name) : serifs.core.itterassyai.normal(msg.friend.name));
|
||||
incLove();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.includes(['ただいま'])) {
|
||||
msg.reply(
|
||||
msg.friend.love >= 15 ? serifs.core.okaeri.love2(msg.friend.name) : msg.friend.love >= 7 ? getSerif(serifs.core.okaeri.love(msg.friend.name)) : serifs.core.okaeri.normal(msg.friend.name)
|
||||
);
|
||||
incLove();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private erait(msg: Message): boolean {
|
||||
const match = msg.extractedText.match(/(.+?)た(から|ので)(褒|ほ)めて/);
|
||||
if (match) {
|
||||
msg.reply(getSerif(serifs.core.erait.specify(match[1], msg.friend.name)));
|
||||
return true;
|
||||
}
|
||||
|
||||
const match2 = msg.extractedText.match(/(.+?)る(から|ので)(褒|ほ)めて/);
|
||||
if (match2) {
|
||||
msg.reply(getSerif(serifs.core.erait.specify(match2[1], msg.friend.name)));
|
||||
return true;
|
||||
}
|
||||
|
||||
const match3 = msg.extractedText.match(/(.+?)だから(褒|ほ)めて/);
|
||||
if (match3) {
|
||||
msg.reply(getSerif(serifs.core.erait.specify(match3[1], msg.friend.name)));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!msg.includes(['褒めて', 'ほめて'])) return false;
|
||||
|
||||
msg.reply(getSerif(serifs.core.erait.general(msg.friend.name)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private omedeto(msg: Message): boolean {
|
||||
if (!msg.includes(['おめでと'])) return false;
|
||||
|
||||
msg.reply(serifs.core.omedeto(msg.friend.name));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private nadenade(msg: Message): boolean {
|
||||
if (!msg.includes(['なでなで'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
//#region 1日に1回だけ親愛度を上げる(嫌われてない場合のみ)
|
||||
if (msg.friend.love >= 0) {
|
||||
const today = getDate();
|
||||
|
||||
const data = msg.friend.getPerModulesData(this);
|
||||
|
||||
if (data.lastNadenadeAt != today) {
|
||||
data.lastNadenadeAt = today;
|
||||
msg.friend.setPerModulesData(this, data);
|
||||
|
||||
msg.friend.incLove();
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
msg.reply(
|
||||
getSerif(
|
||||
msg.friend.love >= 10
|
||||
? serifs.core.nadenade.love3
|
||||
: msg.friend.love >= 5
|
||||
? serifs.core.nadenade.love2
|
||||
: msg.friend.love <= -15
|
||||
? serifs.core.nadenade.hate4
|
||||
: msg.friend.love <= -10
|
||||
? serifs.core.nadenade.hate3
|
||||
: msg.friend.love <= -5
|
||||
? serifs.core.nadenade.hate2
|
||||
: msg.friend.love <= -1
|
||||
? serifs.core.nadenade.hate1
|
||||
: serifs.core.nadenade.normal
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private kawaii(msg: Message): boolean {
|
||||
if (!msg.includes(['かわいい', '可愛い'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
msg.reply(getSerif(msg.friend.love >= 5 ? serifs.core.kawaii.love : msg.friend.love <= -3 ? serifs.core.kawaii.hate : serifs.core.kawaii.normal));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private suki(msg: Message): boolean {
|
||||
if (!msg.or(['好き', 'すき'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
msg.reply(msg.friend.love >= 5 ? (msg.friend.name ? serifs.core.suki.love(msg.friend.name) : serifs.core.suki.normal) : msg.friend.love <= -3 ? serifs.core.suki.hate : serifs.core.suki.normal);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private hug(msg: Message): boolean {
|
||||
if (!msg.or(['ぎゅ', 'むぎゅ', /^はぐ(し(て|よ|よう)?)?$/])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
//#region 前のハグから1分経ってない場合は返信しない
|
||||
// これは、「ハグ」と言って「ぎゅー」と返信したとき、相手が
|
||||
// それに対してさらに「ぎゅー」と返信するケースがあったため。
|
||||
// そうするとその「ぎゅー」に対してもマッチするため、また
|
||||
// 藍がそれに返信してしまうことになり、少し不自然になる。
|
||||
// これを防ぐために前にハグしてから少し時間が経っていないと
|
||||
// 返信しないようにする
|
||||
const now = Date.now();
|
||||
|
||||
const data = msg.friend.getPerModulesData(this);
|
||||
|
||||
if (data.lastHuggedAt != null) {
|
||||
if (now - data.lastHuggedAt < 1000 * 60) return true;
|
||||
}
|
||||
|
||||
data.lastHuggedAt = now;
|
||||
msg.friend.setPerModulesData(this, data);
|
||||
//#endregion
|
||||
|
||||
msg.reply(msg.friend.love >= 5 ? serifs.core.hug.love : msg.friend.love <= -3 ? serifs.core.hug.hate : serifs.core.hug.normal);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private humu(msg: Message): boolean {
|
||||
if (!msg.includes(['踏んで'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
msg.reply(msg.friend.love >= 5 ? serifs.core.humu.love : msg.friend.love <= -3 ? serifs.core.humu.hate : serifs.core.humu.normal);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private batou(msg: Message): boolean {
|
||||
if (!msg.includes(['罵倒して', '罵って'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
msg.reply(msg.friend.love >= 5 ? serifs.core.batou.love : msg.friend.love <= -5 ? serifs.core.batou.hate : serifs.core.batou.normal);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private itai(msg: Message): boolean {
|
||||
if (!msg.or(['痛い', 'いたい']) && !msg.extractedText.endsWith('痛い')) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
msg.reply(serifs.core.itai(msg.friend.name));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private turai(msg: Message): boolean {
|
||||
if (!msg.or(['辛い', 'つらい'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
msg.reply(msg.friend.love >= 5 ? serifs.core.turai.love(msg.friend.name) : msg.friend.love >= -3 ? serifs.core.turai.hate : serifs.core.turai.normal(msg.friend.name));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private kurusii(msg: Message): boolean {
|
||||
if (!msg.or(['苦しい', 'くるしい'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
msg.reply(msg.friend.love >= 5 ? serifs.core.kurusii.love(msg.friend.name) : msg.friend.love >= -3 ? serifs.core.kurusii.hate : serifs.core.kurusii.normal(msg.friend.name));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private ote(msg: Message): boolean {
|
||||
if (!msg.or(['お手'])) return false;
|
||||
|
||||
// メッセージのみ
|
||||
if (!msg.isDm) return true;
|
||||
|
||||
msg.reply(msg.friend.love >= 10 ? serifs.core.ote.love2 : msg.friend.love >= 5 ? serifs.core.ote.love1 : serifs.core.ote.normal);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private ponkotu(msg: Message): boolean | HandlerResult {
|
||||
if (!msg.includes(['ぽんこつ'])) return false;
|
||||
|
||||
msg.friend.decLove();
|
||||
|
||||
return {
|
||||
reaction: 'angry'
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private rmrf(msg: Message): boolean | HandlerResult {
|
||||
if (!msg.includes(['rm -rf'])) return false;
|
||||
|
||||
msg.friend.decLove();
|
||||
|
||||
return {
|
||||
reaction: 'angry'
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private shutdown(msg: Message): boolean | HandlerResult {
|
||||
if (!msg.includes(['shutdown'])) return false;
|
||||
|
||||
msg.reply(serifs.core.shutdown);
|
||||
|
||||
return {
|
||||
reaction: 'confused'
|
||||
};
|
||||
}
|
||||
}
|
72
src/modules/timer/index.ts
Normal file
72
src/modules/timer/index.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
import serifs from '@/serifs';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'timer';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook,
|
||||
timeoutCallback: this.timeoutCallback
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
const secondsQuery = (msg.text || '').match(/([0-9]+)秒/);
|
||||
const minutesQuery = (msg.text || '').match(/([0-9]+)分/);
|
||||
const hoursQuery = (msg.text || '').match(/([0-9]+)時間/);
|
||||
|
||||
const seconds = secondsQuery ? parseInt(secondsQuery[1], 10) : 0;
|
||||
const minutes = minutesQuery ? parseInt(minutesQuery[1], 10) : 0;
|
||||
const hours = hoursQuery ? parseInt(hoursQuery[1], 10) : 0;
|
||||
|
||||
if (!(secondsQuery || minutesQuery || hoursQuery)) return false;
|
||||
|
||||
if (seconds + minutes + hours == 0) {
|
||||
msg.reply(serifs.timer.invalid);
|
||||
return true;
|
||||
}
|
||||
|
||||
const time = 1000 * seconds + 1000 * 60 * minutes + 1000 * 60 * 60 * hours;
|
||||
|
||||
if (time > 86400000) {
|
||||
msg.reply(serifs.timer.tooLong);
|
||||
return true;
|
||||
}
|
||||
|
||||
msg.reply(serifs.timer.set);
|
||||
|
||||
const str = `${hours ? hoursQuery![0] : ''}${minutes ? minutesQuery![0] : ''}${seconds ? secondsQuery![0] : ''}`;
|
||||
|
||||
// タイマーセット
|
||||
this.setTimeoutWithPersistence(time, {
|
||||
isDm: msg.isDm,
|
||||
msgId: msg.id,
|
||||
userId: msg.friend.userId,
|
||||
time: str
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private timeoutCallback(data) {
|
||||
const friend = this.ai.lookupFriend(data.userId);
|
||||
if (friend == null) return; // 処理の流れ上、実際にnullになることは無さそうだけど一応
|
||||
const text = serifs.timer.notify(data.time, friend.name);
|
||||
if (data.isDm) {
|
||||
this.ai.sendMessage(friend.userId, {
|
||||
text: text
|
||||
});
|
||||
} else {
|
||||
this.ai.post({
|
||||
replyId: data.msgId,
|
||||
text: text
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
156
src/modules/trace-moe/index.ts
Normal file
156
src/modules/trace-moe/index.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
import humanizeDuration = require('humanize-duration');
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'trace-moe';
|
||||
|
||||
private readonly itemSchema = z.object({
|
||||
anilist: z.object({
|
||||
title: z.object({
|
||||
native: z.string().nullable(),
|
||||
romaji: z.string().nullable(),
|
||||
english: z.string().nullable()
|
||||
}),
|
||||
isAdult: z.boolean().nullable()
|
||||
}),
|
||||
episode: z.number().or(z.string()).or(z.array(z.number())).nullable(),
|
||||
from: z.number().nullable(),
|
||||
to: z.number().nullable(),
|
||||
similarity: z.number()
|
||||
});
|
||||
|
||||
private readonly schema = z.object({
|
||||
error: z.string(),
|
||||
result: z.array(this.itemSchema)
|
||||
});
|
||||
|
||||
@autobind
|
||||
private getImageUrl(message: Message) {
|
||||
if (!message.files) {
|
||||
this.log('No files found.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredImageFiles = message.files.filter(file => file.type.startsWith('image'));
|
||||
|
||||
if (!filteredImageFiles.length) {
|
||||
this.log('No valid images found.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return filteredImageFiles[0].url;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async getFromTraceMoe(imageUrl: string) {
|
||||
try {
|
||||
const response = await fetch(`https://api.trace.moe/search?anilistInfo&url=${encodeURIComponent(imageUrl)}`);
|
||||
|
||||
const data = await response.json();
|
||||
const result = this.schema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
this.log('Validation failed.');
|
||||
this.log(JSON.stringify(data));
|
||||
console.warn(result.error);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.result[0];
|
||||
} catch (error) {
|
||||
this.log('Failed to fetch data from Trace Moe.');
|
||||
console.warn(error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(message: Message) {
|
||||
if (!message.includes(['アニメ'])) return false;
|
||||
|
||||
if (message.isDm) {
|
||||
message.reply('僕にアニメのシーンの画像を添付して「アニメ教えて」ってメンションすると、何のアニメか教えるよ!');
|
||||
return true;
|
||||
}
|
||||
|
||||
const imageUrl = this.getImageUrl(message);
|
||||
|
||||
if (!imageUrl) {
|
||||
message.reply('画像を添付してね!');
|
||||
return true;
|
||||
}
|
||||
|
||||
const traceMoe = await this.getFromTraceMoe(imageUrl);
|
||||
|
||||
if (!traceMoe) {
|
||||
message.reply('ぬぁ~~~、いまはめんどくさいかも…');
|
||||
return true;
|
||||
}
|
||||
|
||||
const animeTitle = traceMoe.anilist.title.native || traceMoe.anilist.title.english;
|
||||
|
||||
if (!animeTitle) {
|
||||
message.reply('ごめんね、わかんないや…');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof traceMoe.episode === 'string') traceMoe.episode = traceMoe.episode.replace(/\|/g, 'か');
|
||||
else if (Array.isArray(traceMoe.episode)) traceMoe.episode = traceMoe.episode.join('話と');
|
||||
|
||||
const options = {
|
||||
language: 'ja',
|
||||
round: true,
|
||||
delimiter: '',
|
||||
spacer: ''
|
||||
};
|
||||
const fromText = traceMoe.from !== null ? humanizeDuration(traceMoe.from * 1000, options) : null;
|
||||
const toText = traceMoe.to !== null ? humanizeDuration(traceMoe.to * 1000, options) : null;
|
||||
|
||||
const pronoun = traceMoe.episode || (traceMoe.from && traceMoe.to) ? 'これは' : 'このアニメは';
|
||||
|
||||
const prefix = (() => {
|
||||
if (traceMoe.similarity >= 0.9) return pronoun;
|
||||
if (traceMoe.similarity >= 0.8) return `${pronoun}たぶん`;
|
||||
return 'よくわかんないけど、強いて言うなら';
|
||||
})();
|
||||
|
||||
const suffix = (() => {
|
||||
if (traceMoe.similarity >= 0.9) return 'だよ!';
|
||||
if (traceMoe.similarity >= 0.8) return 'だと思う!';
|
||||
return 'に似てるかな';
|
||||
})();
|
||||
|
||||
const time = fromText && toText && fromText === toText ? fromText : `${fromText}から${toText}`;
|
||||
|
||||
const detail = (() => {
|
||||
if (traceMoe.episode && traceMoe.from && traceMoe.to) return `第${traceMoe.episode}話の${time}`;
|
||||
if (traceMoe.from && traceMoe.to) return `の${time}`;
|
||||
if (traceMoe.episode) return `の第${traceMoe.episode}話`;
|
||||
return '';
|
||||
})();
|
||||
const content = `『${animeTitle}』${detail}`;
|
||||
|
||||
const messageToReply = `${prefix}${content}${suffix}`;
|
||||
|
||||
if (traceMoe.anilist.isAdult) {
|
||||
message.reply(messageToReply, { cw: 'そぎぎ' });
|
||||
} else {
|
||||
message.reply(messageToReply);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
51
src/modules/valentine/index.ts
Normal file
51
src/modules/valentine/index.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Friend from '@/friend';
|
||||
import serifs from '@/serifs';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'valentine';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
this.crawleValentine();
|
||||
setInterval(this.crawleValentine, 1000 * 60 * 3);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* チョコ配り
|
||||
*/
|
||||
@autobind
|
||||
private crawleValentine() {
|
||||
const now = new Date();
|
||||
|
||||
const isValentine = now.getMonth() == 1 && now.getDate() == 14;
|
||||
if (!isValentine) return;
|
||||
|
||||
const date = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
|
||||
|
||||
const friends = this.ai.friends.find({} as any);
|
||||
|
||||
friends.forEach(f => {
|
||||
const friend = new Friend(this.ai, { doc: f });
|
||||
|
||||
// 親愛度が5以上必要
|
||||
if (friend.love < 5) return;
|
||||
|
||||
const data = friend.getPerModulesData(this);
|
||||
|
||||
if (data.lastChocolated == date) return;
|
||||
|
||||
data.lastChocolated = date;
|
||||
friend.setPerModulesData(this, data);
|
||||
|
||||
const text = serifs.valentine.chocolateForYou(friend.name);
|
||||
|
||||
this.ai.sendMessage(friend.userId, {
|
||||
text: text
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
103
src/modules/version/index.ts
Normal file
103
src/modules/version/index.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '../../module';
|
||||
import Message from '../../message';
|
||||
//import serifs from '../../serifs';
|
||||
|
||||
/**
|
||||
* バージョン情報
|
||||
*/
|
||||
interface Version {
|
||||
/**
|
||||
* サーバーバージョン(meta.Sversion)
|
||||
*/
|
||||
server: string;
|
||||
/**
|
||||
* クライアントバージョン(meta.clientVersion)
|
||||
*/
|
||||
client: string;
|
||||
}
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'version';
|
||||
|
||||
private latest?: Version;
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
this.versionCheck();
|
||||
setInterval(this.versionCheck, 1000 * 60 * 60 * 1);
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
public versionCheck = () => {
|
||||
// バージョンチェック
|
||||
this.getVersion()
|
||||
.then(fetched => {
|
||||
this.log(`Version fetched: ${JSON.stringify(fetched)}`);
|
||||
|
||||
if (this.latest != null && fetched != null) {
|
||||
const serverChanged = this.latest.server !== fetched.server;
|
||||
|
||||
if (serverChanged) {
|
||||
let v = '';
|
||||
v += (serverChanged ? '**' : '') + `${this.latest.server} → ${this.mfmVersion(fetched.server)}\n` + (serverChanged ? '**' : '');
|
||||
|
||||
this.log(`Version changed: ${v}`);
|
||||
|
||||
this.ai.post({ text: `ぼくのおうちが${v}にリフォームされたよ!!` });
|
||||
} else {
|
||||
// 変更なし
|
||||
}
|
||||
}
|
||||
|
||||
this.latest = fetched;
|
||||
})
|
||||
.catch(e => this.log(`warn: ${e}`));
|
||||
};
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.text == null) return false;
|
||||
|
||||
const query = msg.text.match(/サーバーバージョン/);
|
||||
|
||||
if (query == null) return false;
|
||||
|
||||
this.ai
|
||||
.api('meta')
|
||||
.then(meta => {
|
||||
msg.reply(`${this.mfmVersion(meta.version)} みたいだよ!`);
|
||||
})
|
||||
.catch(() => {
|
||||
msg.reply(`取得失敗しちゃった:cry_nullcatchan:`);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* バージョンを取得する
|
||||
*/
|
||||
private getVersion = (): Promise<Version> => {
|
||||
return this.ai.api('meta').then(meta => {
|
||||
return {
|
||||
server: meta.version,
|
||||
client: meta.clientVersion
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
private mfmVersion = (v): string => {
|
||||
if (v == null) return v;
|
||||
return v.match(/^\d+\.\d+\.\d+$/) ? `[${v}](https://github.com/syuilo/misskey/releases/tag/${v})` : v;
|
||||
};
|
||||
|
||||
private wait = (ms: number): Promise<void> => {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
});
|
||||
};
|
||||
}
|
50
src/modules/yarukoto/index.ts
Normal file
50
src/modules/yarukoto/index.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import Message from '@/message';
|
||||
import Module from '@/module';
|
||||
import autobind from 'autobind-decorator';
|
||||
|
||||
const yarukotoList = [
|
||||
'勉強する',
|
||||
'コード書く',
|
||||
'お絵描きする',
|
||||
'とりあえずトイレ行く',
|
||||
'とりあえずお水とってくる',
|
||||
'寝る',
|
||||
'ゲームする',
|
||||
'通話する',
|
||||
'とりあえずAmazon見る',
|
||||
'そんなことより薬飲んだ?',
|
||||
'ご飯食べる',
|
||||
'VRやる',
|
||||
'部屋掃除する',
|
||||
'お風呂入る',
|
||||
'とりあえず今はmisskeyやっとく',
|
||||
'落書きする',
|
||||
'掃除機かける',
|
||||
'ごろごろする',
|
||||
'YouTube見る',
|
||||
'爪切る',
|
||||
'カフェイン飲む'
|
||||
];
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'yarukoto';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.includes(['やる事', 'やること', 'なにしよ', 'なにやろ', 'にゃにしよ', 'にゃにやろ'])) {
|
||||
const yarukoto = yarukotoList[Math.floor(Math.random() * yarukotoList.length)];
|
||||
|
||||
msg.reply(yarukoto);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
77
src/ng-words.ts
Normal file
77
src/ng-words.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import toHiragana from './utils/to-hiragana';
|
||||
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
|
||||
export default class NGWord {
|
||||
private excludedWords: string[] = [];
|
||||
private ngWords: string[] = [];
|
||||
|
||||
constructor() {
|
||||
const rs = fs.createReadStream('ngwords.txt');
|
||||
const rl = readline.createInterface(rs);
|
||||
rl.on('line', line => {
|
||||
const word = toHiragana(line.trim().toLowerCase());
|
||||
if (word.startsWith('#')) return;
|
||||
if (word.startsWith('-')) {
|
||||
if (/な/g.test(word)) this.excludedWords.push(word.substring(1).replace(/な/g, 'にゃ'));
|
||||
this.excludedWords.push(word.substring(1));
|
||||
} else {
|
||||
if (/な/g.test(word)) this.ngWords.push(word.replace(/な/g, 'にゃ'));
|
||||
this.ngWords.push(word);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
excludeAllowedWord(str: string): string {
|
||||
let text = toHiragana(str.toLowerCase());
|
||||
this.excludedWords.forEach(w => {
|
||||
text = text.replace(w, '');
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
public get get(): string[] {
|
||||
return this.ngWords;
|
||||
}
|
||||
|
||||
addNGWord(str: string): boolean {
|
||||
const word = toHiragana(str.trim().toLowerCase());
|
||||
if (this.ngWords.some(ng => word.includes(ng))) {
|
||||
return false;
|
||||
} else {
|
||||
this.ngWords.push(word);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
removeNGWord(str: string): boolean {
|
||||
const word = toHiragana(str.trim().toLowerCase());
|
||||
if (this.ngWords.some(ng => word.includes(ng))) {
|
||||
this.ngWords = this.ngWords.filter(ng => ng !== word);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
addExcludedWord(str: string): boolean {
|
||||
const word = toHiragana(str.trim().toLowerCase());
|
||||
if (this.excludedWords.some(ng => word.includes(ng))) {
|
||||
return false;
|
||||
} else {
|
||||
this.excludedWords.push(word);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
removeExcludedWord(str: string): boolean {
|
||||
const word = toHiragana(str.trim().toLowerCase());
|
||||
if (this.excludedWords.some(ng => word.includes(ng))) {
|
||||
this.excludedWords = this.excludedWords.filter(e => e !== word);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
252
src/serifs.ts
Normal file
252
src/serifs.ts
Normal file
@ -0,0 +1,252 @@
|
||||
// せりふ
|
||||
|
||||
export default {
|
||||
core: {
|
||||
setNameOk: name => `わかった!今度から${name}って呼ぶね!`,
|
||||
|
||||
san: 'さん付けした方がいいかな?',
|
||||
|
||||
yesOrNo: 'ごめんね...僕「うん」か「いいえ」しかわからないんだ...',
|
||||
|
||||
hello: name => (name ? `やっほぉ${name}` : `やっほぉ!`),
|
||||
|
||||
helloNight: name => (name ? `こんばんわ${name}!` : `こんばんわ~!`),
|
||||
|
||||
goodMorning: (tension, name) => (name ? `おはよ${name}!${tension}` : `おはよ!${tension}`),
|
||||
|
||||
/*
|
||||
goodMorning: {
|
||||
normal: (tension, name) => name ? `おはようございます、${name}!${tension}` : `おはようございます!${tension}`,
|
||||
|
||||
hiru: (tension, name) => name ? `おはようございます、${name}!${tension}もうお昼ですよ?${tension}` : `おはようございます!${tension}もうお昼ですよ?${tension}`,
|
||||
},
|
||||
*/
|
||||
|
||||
goodNight: name => (name ? `おやすみ${name}!` : 'おやすみ!'),
|
||||
|
||||
omedeto: name => (name ? `ありがと~${name}!` : 'ありがと~!'),
|
||||
|
||||
erait: {
|
||||
general: name => (name ? [`${name}、今日もえらい!`, `${name}、今日もえらいね!`] : [`今日もえらい!`, `今日もえらいね!`]),
|
||||
|
||||
specify: (thing, name) => (name ? [`${name}、${thing}てえらい!`, `${name}、${thing}てえらいね!`] : [`${thing}てえらい!`, `${thing}てえらいね!`]),
|
||||
|
||||
specify2: (thing, name) => (name ? [`${name}、${thing}でえらい!`, `${name}、${thing}でえらいね!`] : [`${thing}でえらい!`, `${thing}でえらいね!`])
|
||||
},
|
||||
|
||||
okaeri: {
|
||||
love: name => (name ? [`おかえり${name}!`, `おかえりぃ${name}~`] : ['おかえり!', 'おかえりぃ~']),
|
||||
|
||||
love2: name => (name ? `おかえり~~!!${name}今日も偉いね:love_nullcatchan:` : 'おかえり~~!!今日も偉いね:love_nullcatchan:'),
|
||||
|
||||
normal: name => (name ? `おかえり${name}!` : 'おかえり!')
|
||||
},
|
||||
|
||||
itterassyai: {
|
||||
love: name => (name ? `いってらっしゃい${name}!` : 'いってらっしゃい!'),
|
||||
|
||||
normal: name => (name ? `いってらっしゃい${name}!` : 'いってらっしゃい!')
|
||||
},
|
||||
|
||||
tooLong: '長すぎる..',
|
||||
|
||||
invalidName: '発音が難しいよぉ...',
|
||||
|
||||
nadenade: {
|
||||
normal: 'うにゃ…?! びっくりした...',
|
||||
|
||||
love2: ['あぅ… 恥ずかしいよぉ', 'あぅ… 恥ずかしぃ…', 'ふみゃ…!?'],
|
||||
|
||||
love3: ['んへへぇ ありがと:love_nullcatchan:', 'にへぇ~~', 'んみゅっ… ', 'もっともっとぉ...'],
|
||||
|
||||
hate1: 'やめて',
|
||||
|
||||
hate2: '触んないで',
|
||||
|
||||
hate3: 'きもい',
|
||||
|
||||
hate4: '..?'
|
||||
},
|
||||
|
||||
kawaii: {
|
||||
normal: ['そんなことないよ?', 'えへへへうれしい。'],
|
||||
|
||||
love: ['えへへ。うれしいな', 'んむぅ~~...うれしい。'],
|
||||
|
||||
hate: 'は?きも。'
|
||||
},
|
||||
|
||||
suki: {
|
||||
normal: 'えへへ。ありがと~!',
|
||||
|
||||
love: name => `僕も${name}のこと好き!`,
|
||||
|
||||
hate: null
|
||||
},
|
||||
|
||||
hug: {
|
||||
normal: 'ぎゅー...',
|
||||
|
||||
love: 'ぎゅーっ♪',
|
||||
|
||||
hate: '無理...やめて...'
|
||||
},
|
||||
|
||||
humu: {
|
||||
love: 'もふもふ!ふみふみ!',
|
||||
|
||||
normal: 'ふみふみ!',
|
||||
|
||||
hate: '?'
|
||||
},
|
||||
|
||||
batou: {
|
||||
love: 'ば~か♡♡♡',
|
||||
|
||||
normal: 'きっしょ',
|
||||
|
||||
hate: '?'
|
||||
},
|
||||
|
||||
itai: name => (name ? `${name}大丈夫?なでなで` : '大丈夫?なでなで'),
|
||||
|
||||
turai: {
|
||||
love: name => (name ? `${name}なでなで ぽんぽんぎゅ~!` : 'なでなで ぽんぽんぎゅ~!'),
|
||||
|
||||
normal: name => (name ? `${name}なでなで` : 'なでなで'),
|
||||
|
||||
hate: 'ん~。がんばって'
|
||||
},
|
||||
|
||||
kurusii: {
|
||||
love: name => (name ? `${name}なでなで ぽんぽんぎゅ~!` : 'なでなで ぽんぽんぎゅ~!'),
|
||||
|
||||
normal: name => (name ? `${name}なでなで` : 'なでなで'),
|
||||
|
||||
hate: 'ん~。がんばって'
|
||||
},
|
||||
|
||||
ote: {
|
||||
normal: '犬じゃないんだが!!',
|
||||
|
||||
love1: 'にゃ~!ぼくは犬じゃないよぉ',
|
||||
|
||||
love2: 'にゃにゃにゃ!'
|
||||
},
|
||||
|
||||
shutdown: 'ぼくまだ眠くない...',
|
||||
|
||||
transferNeedDm: 'わかった!二人っきりでお話ししたいな',
|
||||
|
||||
transferCode: code => `わかった!\n合言葉は「${code}」だよ!`,
|
||||
|
||||
transferFailed: 'うーん、合言葉違うみたい',
|
||||
|
||||
transferDone: name => (name ? `んみゃ..! おかえり${name}!` : `んみゃ...! おかえりなさい!`)
|
||||
},
|
||||
|
||||
keyword: {
|
||||
learned: (word, reading) => `え~っと...${word}...${reading}...僕覚えた!!!`,
|
||||
|
||||
remembered: word => `${word}`
|
||||
},
|
||||
|
||||
birthday: {
|
||||
happyBirthday: name => (name ? `お誕生日おめでと~~~!!!${name}!!!!!!` : 'お誕生日おめでと~~~~~!!!')
|
||||
},
|
||||
|
||||
/**
|
||||
* 占い
|
||||
*/
|
||||
fortune: {
|
||||
cw: name => (name ? `今日の${name}の運勢を占ったよ!` : '今日のきみの運勢を占ったよ!')
|
||||
},
|
||||
|
||||
/**
|
||||
* タイマー
|
||||
*/
|
||||
timer: {
|
||||
set: 'OK!',
|
||||
|
||||
invalid: 'うむむ?',
|
||||
|
||||
tooLong: '長すぎる…',
|
||||
|
||||
notify: (time, name) => (name ? `${name}!!${time}経ったよ!` : `${time}経ったよ!`)
|
||||
},
|
||||
|
||||
/**
|
||||
* リマインダー
|
||||
*/
|
||||
reminder: {
|
||||
invalid: 'うむむ?',
|
||||
|
||||
reminds: 'やること一覧だよ!',
|
||||
none: 'やることはないよ!',
|
||||
|
||||
notify: name => (name ? `${name}これやった?` : `これやった?`),
|
||||
|
||||
notifyWithThing: (thing, name) => (name ? `${name}「${thing}」やった?` : `「${thing}」やった?`),
|
||||
|
||||
done: name => (name ? [`すごい!!天才!!${name}えらい!!`, `${name}さすがすぎる!!!`, `${name}えらすぎる!!`] : [`すごい!!天才!!えらい!!`, `さすがすぎる!!!`, `えらすぎる!!`]),
|
||||
|
||||
doneFromInvalidUser: 'イタズラしちゃダメ!',
|
||||
|
||||
cancel: `OK!`
|
||||
},
|
||||
|
||||
server: {
|
||||
cpu: 'サーバーざぁこ♡♡♡'
|
||||
},
|
||||
|
||||
/**
|
||||
* ろぐぼ
|
||||
*/
|
||||
rogubo: 'ログボ!!',
|
||||
|
||||
/**
|
||||
* バレンタイン
|
||||
*/
|
||||
valentine: {
|
||||
chocolateForYou: name => (name ? `${name}!チョコあげる!` : 'チョコあげる!')
|
||||
},
|
||||
|
||||
sleepReport: {
|
||||
report: hours => `んぬぁ~、${hours}時間くらいねちゃってたかも`,
|
||||
reportUtatane: 'ぬぁ... '
|
||||
},
|
||||
|
||||
noting: {
|
||||
notes: [
|
||||
'うみゅ',
|
||||
'んぬぁ~',
|
||||
'ねむい',
|
||||
'さみしい',
|
||||
'なでてぇ',
|
||||
'なんもわからん',
|
||||
'う~~~',
|
||||
'ねみゅい',
|
||||
'つらいニダ',
|
||||
'うが~~~',
|
||||
'疲れた',
|
||||
'みゃ~',
|
||||
'うぅ',
|
||||
'ぬるきゃっとちゃんだよ!',
|
||||
'進捗どうですか',
|
||||
'おふとんふわふわ~',
|
||||
'うぐぅ',
|
||||
'ぬぁ~ん',
|
||||
'に゙',
|
||||
'ぎゅ~~~',
|
||||
'むぅ'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function getSerif(variant: string | string[]): string {
|
||||
if (Array.isArray(variant)) {
|
||||
return variant[Math.floor(Math.random() * variant.length)];
|
||||
} else {
|
||||
return variant;
|
||||
}
|
||||
}
|
308
src/stream.ts
Normal file
308
src/stream.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as WebSocket from 'ws';
|
||||
const ReconnectingWebsocket = require('reconnecting-websocket');
|
||||
import config from './config';
|
||||
|
||||
/**
|
||||
* Misskey stream connection
|
||||
*/
|
||||
export default class Stream extends EventEmitter {
|
||||
private stream: any;
|
||||
private state: string;
|
||||
private buffer: any[];
|
||||
private sharedConnectionPools: Pool[] = [];
|
||||
private sharedConnections: SharedConnection[] = [];
|
||||
private nonSharedConnections: NonSharedConnection[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = 'initializing';
|
||||
this.buffer = [];
|
||||
|
||||
this.stream = new ReconnectingWebsocket(`${config.wsUrl}/streaming?i=${config.i}`, [], {
|
||||
WebSocket: WebSocket
|
||||
});
|
||||
this.stream.addEventListener('open', this.onOpen);
|
||||
this.stream.addEventListener('close', this.onClose);
|
||||
this.stream.addEventListener('message', this.onMessage);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public useSharedConnection(channel: string): SharedConnection {
|
||||
let pool = this.sharedConnectionPools.find(p => p.channel === channel);
|
||||
|
||||
if (pool == null) {
|
||||
pool = new Pool(this, channel);
|
||||
this.sharedConnectionPools.push(pool);
|
||||
}
|
||||
|
||||
const connection = new SharedConnection(this, channel, pool);
|
||||
this.sharedConnections.push(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public removeSharedConnection(connection: SharedConnection) {
|
||||
this.sharedConnections = this.sharedConnections.filter(c => c !== connection);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public connectToChannel(channel: string, params?: any): NonSharedConnection {
|
||||
const connection = new NonSharedConnection(this, channel, params);
|
||||
this.nonSharedConnections.push(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public disconnectToChannel(connection: NonSharedConnection) {
|
||||
this.nonSharedConnections = this.nonSharedConnections.filter(c => c !== connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback of when open connection
|
||||
*/
|
||||
@autobind
|
||||
private onOpen() {
|
||||
const isReconnect = this.state == 'reconnecting';
|
||||
|
||||
this.state = 'connected';
|
||||
this.emit('_connected_');
|
||||
|
||||
// バッファーを処理
|
||||
const _buffer = [...this.buffer]; // Shallow copy
|
||||
this.buffer = []; // Clear buffer
|
||||
for (const data of _buffer) {
|
||||
this.send(data); // Resend each buffered messages
|
||||
}
|
||||
|
||||
// チャンネル再接続
|
||||
if (isReconnect) {
|
||||
this.sharedConnectionPools.forEach(p => {
|
||||
p.connect();
|
||||
});
|
||||
this.nonSharedConnections.forEach(c => {
|
||||
c.connect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback of when close connection
|
||||
*/
|
||||
@autobind
|
||||
private onClose() {
|
||||
this.state = 'reconnecting';
|
||||
this.emit('_disconnected_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback of when received a message from connection
|
||||
*/
|
||||
@autobind
|
||||
private onMessage(message) {
|
||||
const { type, body } = JSON.parse(message.data);
|
||||
|
||||
if (type == 'channel') {
|
||||
const id = body.id;
|
||||
|
||||
let connections: (Connection | undefined)[];
|
||||
|
||||
connections = this.sharedConnections.filter(c => c.id === id);
|
||||
|
||||
if (connections.length === 0) {
|
||||
connections = [this.nonSharedConnections.find(c => c.id === id)];
|
||||
}
|
||||
|
||||
for (const c of connections.filter(c => c != null)) {
|
||||
c!.emit(body.type, body.body);
|
||||
c!.emit('*', { type: body.type, body: body.body });
|
||||
}
|
||||
} else {
|
||||
this.emit(type, body);
|
||||
this.emit('*', { type, body });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to connection
|
||||
*/
|
||||
@autobind
|
||||
public send(typeOrPayload, payload?) {
|
||||
const data =
|
||||
payload === undefined
|
||||
? typeOrPayload
|
||||
: {
|
||||
type: typeOrPayload,
|
||||
body: payload
|
||||
};
|
||||
|
||||
// まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する
|
||||
if (this.state != 'connected') {
|
||||
this.buffer.push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
this.stream.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close this connection
|
||||
*/
|
||||
@autobind
|
||||
public close() {
|
||||
this.stream.removeEventListener('open', this.onOpen);
|
||||
this.stream.removeEventListener('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
||||
class Pool {
|
||||
public channel: string;
|
||||
public id: string;
|
||||
protected stream: Stream;
|
||||
private users = 0;
|
||||
private disposeTimerId: any;
|
||||
private isConnected = false;
|
||||
|
||||
constructor(stream: Stream, channel: string) {
|
||||
this.channel = channel;
|
||||
this.stream = stream;
|
||||
|
||||
this.id = Math.random().toString();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public inc() {
|
||||
if (this.users === 0 && !this.isConnected) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
this.users++;
|
||||
|
||||
// タイマー解除
|
||||
if (this.disposeTimerId) {
|
||||
clearTimeout(this.disposeTimerId);
|
||||
this.disposeTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dec() {
|
||||
this.users--;
|
||||
|
||||
// そのコネクションの利用者が誰もいなくなったら
|
||||
if (this.users === 0) {
|
||||
// また直ぐに再利用される可能性があるので、一定時間待ち、
|
||||
// 新たな利用者が現れなければコネクションを切断する
|
||||
this.disposeTimerId = setTimeout(() => {
|
||||
this.disconnect();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public connect() {
|
||||
this.isConnected = true;
|
||||
this.stream.send('connect', {
|
||||
channel: this.channel,
|
||||
id: this.id
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
private disconnect() {
|
||||
this.isConnected = false;
|
||||
this.disposeTimerId = null;
|
||||
this.stream.send('disconnect', { id: this.id });
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Connection extends EventEmitter {
|
||||
public channel: string;
|
||||
protected stream: Stream;
|
||||
public abstract id: string;
|
||||
|
||||
constructor(stream: Stream, channel: string) {
|
||||
super();
|
||||
|
||||
this.stream = stream;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public send(id: string, typeOrPayload, payload?) {
|
||||
const type = payload === undefined ? typeOrPayload.type : typeOrPayload;
|
||||
const body = payload === undefined ? typeOrPayload.body : payload;
|
||||
|
||||
this.stream.send('ch', {
|
||||
id: id,
|
||||
type: type,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
public abstract dispose(): void;
|
||||
}
|
||||
|
||||
class SharedConnection extends Connection {
|
||||
private pool: Pool;
|
||||
|
||||
public get id(): string {
|
||||
return this.pool.id;
|
||||
}
|
||||
|
||||
constructor(stream: Stream, channel: string, pool: Pool) {
|
||||
super(stream, channel);
|
||||
|
||||
this.pool = pool;
|
||||
this.pool.inc();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public send(typeOrPayload, payload?) {
|
||||
super.send(this.pool.id, typeOrPayload, payload);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dispose() {
|
||||
this.pool.dec();
|
||||
this.removeAllListeners();
|
||||
this.stream.removeSharedConnection(this);
|
||||
}
|
||||
}
|
||||
|
||||
class NonSharedConnection extends Connection {
|
||||
public id: string;
|
||||
protected params: any;
|
||||
|
||||
constructor(stream: Stream, channel: string, params?: any) {
|
||||
super(stream, channel);
|
||||
|
||||
this.params = params;
|
||||
this.id = Math.random().toString();
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public connect() {
|
||||
this.stream.send('connect', {
|
||||
channel: this.channel,
|
||||
id: this.id,
|
||||
params: this.params
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
public send(typeOrPayload, payload?) {
|
||||
super.send(this.id, typeOrPayload, payload);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dispose() {
|
||||
this.removeAllListeners();
|
||||
this.stream.send('disconnect', { id: this.id });
|
||||
this.stream.disconnectToChannel(this);
|
||||
}
|
||||
}
|
3
src/utils/acct.ts
Normal file
3
src/utils/acct.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function acct(user: { username: string; host?: string | null }): string {
|
||||
return user.host ? `@${user.username}@${user.host}` : `@${user.username}`;
|
||||
}
|
8
src/utils/get-date.ts
Normal file
8
src/utils/get-date.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export default function (): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
const d = now.getDate();
|
||||
const today = `${y}/${m + 1}/${d}`;
|
||||
return today;
|
||||
}
|
10
src/utils/includes.ts
Normal file
10
src/utils/includes.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { katakanaToHiragana, hankakuToZenkaku } from './japanese';
|
||||
|
||||
export default function (text: string, words: string[]): boolean {
|
||||
if (text == null) return false;
|
||||
|
||||
text = katakanaToHiragana(hankakuToZenkaku(text)).toLowerCase();
|
||||
words = words.map(word => katakanaToHiragana(word).toLowerCase());
|
||||
|
||||
return words.some(word => text.includes(word));
|
||||
}
|
76
src/utils/japanese.ts
Normal file
76
src/utils/japanese.ts
Normal file
@ -0,0 +1,76 @@
|
||||
// Utilities for Japanese
|
||||
|
||||
// prettier-ignore
|
||||
const kanaMap: string[][] = [
|
||||
['ガ', 'ガ'], ['ギ', 'ギ'], ['グ', 'グ'], ['ゲ', 'ゲ'], ['ゴ', 'ゴ'],
|
||||
['ザ', 'ザ'], ['ジ', 'ジ'], ['ズ', 'ズ'], ['ゼ', 'ゼ'], ['ゾ', 'ゾ'],
|
||||
['ダ', 'ダ'], ['ヂ', 'ヂ'], ['ヅ', 'ヅ'], ['デ', 'デ'], ['ド', 'ド'],
|
||||
['バ', 'バ'], ['ビ', 'ビ'], ['ブ', 'ブ'], ['ベ', 'ベ'], ['ボ', 'ボ'],
|
||||
['パ', 'パ'], ['ピ', 'ピ'], ['プ', 'プ'], ['ペ', 'ペ'], ['ポ', 'ポ'],
|
||||
['ヴ', 'ヴ'], ['ヷ', 'ヷ'], ['ヺ', 'ヺ'],
|
||||
['ア', 'ア'], ['イ', 'イ'], ['ウ', 'ウ'], ['エ', 'エ'], ['オ', 'オ'],
|
||||
['カ', 'カ'], ['キ', 'キ'], ['ク', 'ク'], ['ケ', 'ケ'], ['コ', 'コ'],
|
||||
['サ', 'サ'], ['シ', 'シ'], ['ス', 'ス'], ['セ', 'セ'], ['ソ', 'ソ'],
|
||||
['タ', 'タ'], ['チ', 'チ'], ['ツ', 'ツ'], ['テ', 'テ'], ['ト', 'ト'],
|
||||
['ナ', 'ナ'], ['ニ', 'ニ'], ['ヌ', 'ヌ'], ['ネ', 'ネ'], ['ノ', 'ノ'],
|
||||
['ハ', 'ハ'], ['ヒ', 'ヒ'], ['フ', 'フ'], ['ヘ', 'ヘ'], ['ホ', 'ホ'],
|
||||
['マ', 'マ'], ['ミ', 'ミ'], ['ム', 'ム'], ['メ', 'メ'], ['モ', 'モ'],
|
||||
['ヤ', 'ヤ'], ['ユ', 'ユ'], ['ヨ', 'ヨ'],
|
||||
['ラ', 'ラ'], ['リ', 'リ'], ['ル', 'ル'], ['レ', 'レ'], ['ロ', 'ロ'],
|
||||
['ワ', 'ワ'], ['ヲ', 'ヲ'], ['ン', 'ン'],
|
||||
['ァ', 'ァ'], ['ィ', 'ィ'], ['ゥ', 'ゥ'], ['ェ', 'ェ'], ['ォ', 'ォ'],
|
||||
['ッ', 'ッ'], ['ャ', 'ャ'], ['ュ', 'ュ'], ['ョ', 'ョ'],
|
||||
['ー', 'ー']
|
||||
];
|
||||
|
||||
/**
|
||||
* カタカナをひらがなに変換します
|
||||
* @param str カタカナ
|
||||
* @returns ひらがな
|
||||
*/
|
||||
export function katakanaToHiragana(str: string): string {
|
||||
return str.replace(/[\u30a1-\u30f6]/g, match => {
|
||||
const char = match.charCodeAt(0) - 0x60;
|
||||
return String.fromCharCode(char);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ひらがなをカタカナに変換します
|
||||
* @param str ひらがな
|
||||
* @returns カタカナ
|
||||
*/
|
||||
export function hiraganaToKatagana(str: string): string {
|
||||
return str.replace(/[\u3041-\u3096]/g, match => {
|
||||
const char = match.charCodeAt(0) + 0x60;
|
||||
return String.fromCharCode(char);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 全角カタカナを半角カタカナに変換します
|
||||
* @param str 全角カタカナ
|
||||
* @returns 半角カタカナ
|
||||
*/
|
||||
export function zenkakuToHankaku(str: string): string {
|
||||
const reg = new RegExp('(' + kanaMap.map(x => x[0]).join('|') + ')', 'g');
|
||||
|
||||
return str
|
||||
.replace(reg, match => kanaMap.find(x => x[0] == match)![1])
|
||||
.replace(/゛/g, '゙')
|
||||
.replace(/゜/g, '゚');
|
||||
}
|
||||
|
||||
/**
|
||||
* 半角カタカナを全角カタカナに変換します
|
||||
* @param str 半角カタカナ
|
||||
* @returns 全角カタカナ
|
||||
*/
|
||||
export function hankakuToZenkaku(str: string): string {
|
||||
const reg = new RegExp('(' + kanaMap.map(x => x[1]).join('|') + ')', 'g');
|
||||
|
||||
return str
|
||||
.replace(reg, match => kanaMap.find(x => x[1] == match)![0])
|
||||
.replace(/゙/g, '゛')
|
||||
.replace(/゚/g, '゜');
|
||||
}
|
11
src/utils/log.ts
Normal file
11
src/utils/log.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import * as chalk from 'chalk';
|
||||
|
||||
export default function (msg: string) {
|
||||
const now = new Date();
|
||||
const date = `${zeroPad(now.getHours())}:${zeroPad(now.getMinutes())}:${zeroPad(now.getSeconds())}`;
|
||||
console.log(`${chalk.gray(date)} ${msg}`);
|
||||
}
|
||||
|
||||
function zeroPad(num: number, length: number = 2): string {
|
||||
return ('0000000000' + num).slice(-length);
|
||||
}
|
61
src/utils/or.ts
Normal file
61
src/utils/or.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { hankakuToZenkaku, katakanaToHiragana } from './japanese';
|
||||
|
||||
export default function (text: string, words: (string | RegExp)[]): boolean {
|
||||
if (text == null) return false;
|
||||
|
||||
text = katakanaToHiragana(hankakuToZenkaku(text));
|
||||
words = words.map(word => (typeof word == 'string' ? katakanaToHiragana(word) : word));
|
||||
|
||||
return words.some(word => {
|
||||
/**
|
||||
* テキストの余分な部分を取り除く
|
||||
* 例えば「藍ちゃん好き!」のようなテキストを「好き」にする
|
||||
*/
|
||||
function denoise(text: string): string {
|
||||
text = text.trim();
|
||||
|
||||
if (text.startsWith('@')) {
|
||||
text = text.replace(/^@[a-zA-Z0-1\-_]+/, '');
|
||||
text = text.trim();
|
||||
}
|
||||
|
||||
function fn() {
|
||||
text = text.replace(/[!!]+$/, '');
|
||||
text = text.replace(/っ+$/, '');
|
||||
|
||||
// 末尾の ー を除去
|
||||
// 例えば「おはよー」を「おはよ」にする
|
||||
// ただそのままだと「セーラー」などの本来「ー」が含まれているワードも「ー」が除去され
|
||||
// 「セーラ」になり、「セーラー」を期待している場合はマッチしなくなり期待する動作にならなくなるので、
|
||||
// 期待するワードの末尾にもともと「ー」が含まれている場合は(対象のテキストの「ー」をすべて除去した後に)「ー」を付けてあげる
|
||||
text = text.replace(/ー+$/, '') + (typeof word == 'string' && word[word.length - 1] == 'ー' ? 'ー' : '');
|
||||
|
||||
text = text.replace(/。$/, '');
|
||||
text = text.replace(/です$/, '');
|
||||
text = text.replace(/(\.|…)+$/, '');
|
||||
text = text.replace(/[♪♥]+$/, '');
|
||||
text = text.replace(/^藍/, '');
|
||||
text = text.replace(/^ぬるきゃっと/, '');
|
||||
text = text.replace(/^ちゃん/, '');
|
||||
text = text.replace(/、+$/, '');
|
||||
}
|
||||
|
||||
let textBefore = text;
|
||||
let textAfter: string | null = null;
|
||||
|
||||
while (textBefore != textAfter) {
|
||||
textBefore = text;
|
||||
fn();
|
||||
textAfter = text;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
if (typeof word == 'string') {
|
||||
return text == word || denoise(text) == word;
|
||||
} else {
|
||||
return word.test(text) || word.test(denoise(text));
|
||||
}
|
||||
});
|
||||
}
|
5
src/utils/safe-for-interpolate.ts
Normal file
5
src/utils/safe-for-interpolate.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const invalidChars = ['@', '#', '*', ':', '(', ')', '[', ']', ' ', ' '];
|
||||
|
||||
export function safeForInterpolate(text: string): boolean {
|
||||
return !invalidChars.some(c => text.includes(c));
|
||||
}
|
5
src/utils/to-hiragana.ts
Normal file
5
src/utils/to-hiragana.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const moji = require('moji');
|
||||
|
||||
export default function toHiragana(str: string): string {
|
||||
return moji(str).convert('HK', 'ZK').convert('KK', 'HG').toString();
|
||||
}
|
116
src/vocabulary.ts
Normal file
116
src/vocabulary.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import * as seedrandom from 'seedrandom';
|
||||
|
||||
export const itemPrefixes = [
|
||||
'そこらへんの',
|
||||
'使用済み',
|
||||
'壊れた',
|
||||
'市販の',
|
||||
'オーダーメイドの',
|
||||
'業務用の',
|
||||
'Microsoft製',
|
||||
'Apple製',
|
||||
'高級',
|
||||
'腐った',
|
||||
'人工知能搭載',
|
||||
'携帯型',
|
||||
'透明な',
|
||||
'光る',
|
||||
'動く',
|
||||
'USBコネクタ付きの',
|
||||
'いにしえの',
|
||||
'呪われた',
|
||||
'幻の',
|
||||
'仮想的な',
|
||||
'異世界の',
|
||||
'異星の',
|
||||
'謎の',
|
||||
'時空を歪める',
|
||||
'究極の',
|
||||
'異臭を放つ',
|
||||
'得体の知れない',
|
||||
'四角い',
|
||||
'暴れ回る',
|
||||
'夢の',
|
||||
'闇の',
|
||||
'暗黒の',
|
||||
'封印されし',
|
||||
'凍った',
|
||||
'魔の',
|
||||
'禁断の',
|
||||
'ホログラフィックな',
|
||||
'次世代',
|
||||
'3G対応',
|
||||
'消費期限切れ',
|
||||
'消える',
|
||||
'もちもち',
|
||||
'冷やし',
|
||||
'あつあつ',
|
||||
'巨大',
|
||||
'ナノサイズ',
|
||||
'やわらかい',
|
||||
'人の手に負えない',
|
||||
'バグった',
|
||||
'人工',
|
||||
'天然',
|
||||
'超',
|
||||
'中古の',
|
||||
'新品の',
|
||||
'ぷるぷる',
|
||||
'ぐにゃぐにゃ',
|
||||
'多目的',
|
||||
'いい感じ™の',
|
||||
'激辛',
|
||||
'先進的な',
|
||||
'レトロな',
|
||||
'合法',
|
||||
'違法',
|
||||
'プレミア付き',
|
||||
'怪しい',
|
||||
'妖しい',
|
||||
'やばい',
|
||||
'すごい',
|
||||
'かわいい',
|
||||
'デジタル',
|
||||
'アナログ',
|
||||
'100年に一度の',
|
||||
'食用',
|
||||
'THE ',
|
||||
'解き放たれし',
|
||||
'大きな',
|
||||
'小さな'
|
||||
];
|
||||
|
||||
export const items = [
|
||||
'右足',
|
||||
'左足',
|
||||
'お金',
|
||||
'金パブ',
|
||||
'ブロン',
|
||||
'ぬるきゃっとちゃん!',
|
||||
'この世のすべて',
|
||||
'量子コンピューター',
|
||||
'スマホ',
|
||||
'PC',
|
||||
'モンスター',
|
||||
'好きなもの',
|
||||
'ぬいぐるみ',
|
||||
'おふとん',
|
||||
'森羅万象',
|
||||
'めがね'
|
||||
];
|
||||
|
||||
export const and = ['に擬態した', '入りの', 'っぽい', 'に見せかけて', 'を虐げる', 'を侍らせた', 'が上に乗った'];
|
||||
|
||||
export function genItem(seedOrRng?: (() => number) | string | number) {
|
||||
const rng = seedOrRng ? (typeof seedOrRng === 'function' ? seedOrRng : seedrandom(seedOrRng.toString())) : Math.random;
|
||||
|
||||
let item = '';
|
||||
if (Math.floor(rng() * 5) !== 0) item += itemPrefixes[Math.floor(rng() * itemPrefixes.length)];
|
||||
item += items[Math.floor(rng() * items.length)];
|
||||
if (Math.floor(rng() * 10) === 0) {
|
||||
item += and[Math.floor(rng() * and.length)];
|
||||
if (Math.floor(rng() * 5) !== 0) item += itemPrefixes[Math.floor(rng() * itemPrefixes.length)];
|
||||
item += items[Math.floor(rng() * items.length)];
|
||||
}
|
||||
return item;
|
||||
}
|
7
test/__mocks__/account.ts
Normal file
7
test/__mocks__/account.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const account = {
|
||||
id: '0',
|
||||
name: '藍',
|
||||
username: 'ai',
|
||||
host: null,
|
||||
isBot: true,
|
||||
};
|
67
test/__mocks__/misskey.ts
Normal file
67
test/__mocks__/misskey.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import * as http from 'http';
|
||||
import * as Koa from 'koa';
|
||||
import * as websocket from 'websocket';
|
||||
|
||||
export class Misskey {
|
||||
private server: http.Server;
|
||||
private streaming: websocket.connection;
|
||||
|
||||
constructor() {
|
||||
const app = new Koa();
|
||||
|
||||
this.server = http.createServer(app.callback());
|
||||
|
||||
const ws = new websocket.server({
|
||||
httpServer: this.server
|
||||
});
|
||||
|
||||
ws.on('request', async (request) => {
|
||||
const q = request.resourceURL.query as ParsedUrlQuery;
|
||||
|
||||
this.streaming = request.accept();
|
||||
});
|
||||
|
||||
this.server.listen(3000);
|
||||
}
|
||||
|
||||
public waitForStreamingMessage(handler) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onMessage = (data: websocket.IMessage) => {
|
||||
if (data.utf8Data == null) return;
|
||||
const message = JSON.parse(data.utf8Data);
|
||||
const result = handler(message);
|
||||
if (result) {
|
||||
this.streaming.off('message', onMessage);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.streaming.on('message', onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
public async waitForMainChannelConnected() {
|
||||
await this.waitForStreamingMessage(message => {
|
||||
const { type, body } = message;
|
||||
if (type === 'connect') {
|
||||
const { channel, id, params, pong } = body;
|
||||
|
||||
if (channel !== 'main') return;
|
||||
|
||||
if (pong) {
|
||||
this.sendStreamingMessage('connected', {
|
||||
id: id
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public sendStreamingMessage(type: string, payload: any) {
|
||||
this.streaming.send(JSON.stringify({
|
||||
type: type,
|
||||
body: payload
|
||||
}));
|
||||
}
|
||||
}
|
17
test/__mocks__/ws.ts
Normal file
17
test/__mocks__/ws.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import * as websocket from 'websocket';
|
||||
|
||||
export class StreamingApi {
|
||||
private ws: WS;
|
||||
|
||||
constructor() {
|
||||
this.ws = new WS('ws://localhost/streaming');
|
||||
}
|
||||
|
||||
public async waitForMainChannelConnected() {
|
||||
await expect(this.ws).toReceiveMessage("hello");
|
||||
}
|
||||
|
||||
public send(message) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
26
test/__modules__/test.ts
Normal file
26
test/__modules__/test.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import Module from '@/module';
|
||||
import Message from '@/message';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'test';
|
||||
|
||||
@autobind
|
||||
public install() {
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
private async mentionHook(msg: Message) {
|
||||
if (msg.text && msg.text.includes('ping')) {
|
||||
msg.reply('PONG!', {
|
||||
immediate: true
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
20
test/core.ts
Normal file
20
test/core.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import 藍 from '@/ai';
|
||||
import { account } from '#/__mocks__/account';
|
||||
import TestModule from '#/__modules__/test';
|
||||
import { StreamingApi } from '#/__mocks__/ws';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
let ai: 藍;
|
||||
|
||||
beforeEach(() => {
|
||||
ai = new 藍(account, [
|
||||
new TestModule(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('mention hook', async () => {
|
||||
const streaming = new StreamingApi();
|
||||
|
||||
|
||||
});
|
15
test/tsconfig.json
Normal file
15
test/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"rootDir": "../",
|
||||
"paths": {
|
||||
"@/*": ["../src/*"],
|
||||
"#/*": ["./*"]
|
||||
},
|
||||
},
|
||||
"compileOnSave": false,
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
58
torisetu.md
Normal file
58
torisetu.md
Normal file
@ -0,0 +1,58 @@
|
||||
<img src="https://s3.nca10.net/misskey/ffdfadba-f889-4d33-a9ad-b5d9be7226d7.png" align="right" height="320px"/>
|
||||
|
||||
# ぬるきゃっとちゃん!の主な機能
|
||||
|
||||
### フォローする
|
||||
僕に「フォローして」って言ってくれたらフォローするよ
|
||||
|
||||
### お話
|
||||
「おはよう」「おやすみ」などと話しかけると反応するよ
|
||||
|
||||
### リアクション
|
||||
僕が設定されている特定のワードにリアクションするよ
|
||||
|
||||
### 占い
|
||||
僕に「占って」と言うと、あなたの今日の運勢を占うよ
|
||||
|
||||
### タイマー
|
||||
指定した時間、分、秒を経過したら教えてくれるよ「3分40秒」のように単位を混ぜることもできるよ
|
||||
|
||||
### リマインダー
|
||||
`@nullcat todo 寝る` みたいに言ってくれたら1時間置きにリマインドするよ。その飛ばしたメンションか、僕からの催促に「やった」「やめた」など返信するとリマインダー解除されるよ<br>
|
||||
引用Renoteでメンションすることもできるよ<br>
|
||||
リマインダーの一覧は `@nullcat todos` で見れるよ
|
||||
|
||||
### GitHub Status
|
||||
僕に「GitHub」って言ってくれたら今のStatusを教えるよ
|
||||
|
||||
### 怪レい曰本语変換
|
||||
僕に `#怪しい日本語変換` っていうタグ付きで変換してほしい文章をメンションしてくれたら怪レい曰本语に変換するよ
|
||||
|
||||
### やること決める
|
||||
僕に「なにしよ」って言ってくれたらやることを決めるよ
|
||||
|
||||
### 気圧
|
||||
僕に「気圧教えて」って言ってくれたら今の気圧を教えるよ
|
||||
|
||||
### 呼び方を教える
|
||||
僕が君のことをなんて呼べばいいか教えてくれたら、その名前で呼ぶよ!<br>
|
||||
親愛度が一定の値に達している必要があるよ<br>
|
||||
(チャットのみで反応するよ)
|
||||
|
||||
### HappyBirthday
|
||||
誕生日になったら僕が君の誕生日を祝うよ
|
||||
|
||||
### バレンタイン
|
||||
バレンタインになったら仲のいい子に僕がチョコレートをあげるよ
|
||||
|
||||
### ping
|
||||
僕に「ping」って言ってくれたらフォローするよで起きてるとき返信するよ!寝てるときは返信できないかも...
|
||||
|
||||
### 親愛度
|
||||
僕は君に対する親愛度を持っているよ<br>
|
||||
僕にお話ししてくれたりすると、少しずつ上がるよ<br>
|
||||
親愛度によって反応が変化するよ!親愛度がある程度ないとしてくれないこともあるよ<br>
|
||||
たくさん話しかけてね
|
||||
|
||||
|
||||
僕のリポジトリは[ここ](https://github.com/NullCatSlave/NullcatChan)だよ
|
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"strictNullChecks": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": false,
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"removeComments": false,
|
||||
"noLib": false,
|
||||
"outDir": "built",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
},
|
||||
"compileOnSave": false,
|
||||
"include": [
|
||||
"./src/**/*.ts"
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user