This commit is contained in:
NullCat 2023-01-29 17:39:28 +09:00
parent f7543dc8bc
commit 06b942eed7
73 changed files with 9809 additions and 0 deletions

13
.dockerignore Normal file
View 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
View 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
View File

@ -0,0 +1,7 @@
config.json
built
node_modules
memory.json
font.ttf
# Intelij-IDEA
/.idea

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
save-exact = true
package-lock = false

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
built
node_modules
package.json
tsconfig.json

7
.prettierrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"arrowParens": "avoid",
"trailingComma": "none",
"semi": true,
"singleQuote": true,
"printWidth": 200
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib"
}

26
Dockerfile_development Normal file
View 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
View 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
View 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
View 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
View 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

View File

@ -0,0 +1,5 @@
version: '3'
services:
app:
build:
dockerfile: Dockerfile_development

184
ngwords.txt Normal file
View File

@ -0,0 +1,184 @@
オーガズム
オルガスムス
メスイキ
ポルノ
-ポルノグラフィティ
にょろり
オナホ
アクメ
淫夢
チンカス
ふたなり
マラ
-ガテマラ
-グァテマラ
TENGA
種付
たねつ
たねづ
アスペ
-アスペクト
クリトリス
dick
セックス
セクロス
-ぱちんこ
-がちんこ
-喉ちんこ
-のどちんこ
ちんこ
ちんぽ
chinko
chinpo
tinko
tinpo
-ビックリマンコラボ
-ウルトラマンコスモス
まんこ
manko
ペニス
penis
vagina
ヴァギナ
バギナ
肉棒
勃起
ぼっき
精子
精液
射精
ザーメン
ザー汁
放射性
金玉
キンタマ
semen
体位
淫乱
アナル
anus
おっぱい
巨乳
貧乳
爆乳
虚乳
普乳
適乳
美乳
豊乳
超乳
魔乳
きょにゅう
きょにゅー
ひんにゅう
ひんにゅー
ばくにゅう
ばくにゅー
ふにゅう
ふにゅー
てきにゅう
てきにゅー
びにゅう
びにゅー
ほうにゅう
ほうにゅー
ちょうにゅう
ちょうにゅー
まにゅう
まにゅー
何カップ
乳首
ちくび
ビーチク
自慰
オナニ
オナ二
オナヌ
マスターベーション
マスタベーション
シコい
シコっ
脱げ
ぬげ
脱いで
ぬいで
脱ごう
ぬごう
喘いで
あえいで
クンニ
-フェラーリ
-カフェラテ
-フェライト
-フェラガモ
-フェラーラ
-フェライニ
-フェラーズ
-フェラリア
フェラ
デリヘル
-姦し
犯す
ヤリマン
ヤリチン
パイパン
中出し
中で出
スカトロ
ケツ
コキ
手マン
潮吹
下乳
横乳
指マン
パイズリ
ペェズリ
-スレイプニル
レイプ
オフパコ
パコる
ドピュ
ブリュ
-ちんちん電車
ちんちん
ぽこちん
マン汁
下の口
コンドーム
ハメ撮り
ちん毛
まん毛
陰毛
インポ
童貞もらって
童貞貰
童貞をもらって
童貞を貰
ケツの穴
糞を出
糞が出
ヨツンヴァイン
しゃぶれ
邪淫
処女
早漏
オナホ
アクメ
淫夢
チンカス
ふたなり
マラ
-ガテマラ
-グァテマラ
TENGA
種付
たねつ
たねづ
アスペ
-アスペクト
クリトリス
dick
おしっこ

90
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View 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;
}
}
}

View 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
});
});
}
}

View 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
View 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);
});
}
}
}

View 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_:');
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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);
}

View 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;
}
}

View 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
View 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;
}
}
}

View 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
});
}
}

View 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());
}
}

View 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;
/**
* 11
*/
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;
}
}

View 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;
}
}
}

View 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
View 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'
};
}
}

View 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
});
}
}
}

View 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;
}
}

View 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
});
});
}
}

View 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);
});
};
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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));
}
});
}

View 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
View 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
View 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;
}

View File

@ -0,0 +1,7 @@
export const account = {
id: '0',
name: '藍',
username: 'ai',
host: null,
isBot: true,
};

67
test/__mocks__/misskey.ts Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": "../",
"paths": {
"@/*": ["../src/*"],
"#/*": ["./*"]
},
},
"compileOnSave": false,
"include": [
"**/*.ts"
]
}

58
torisetu.md Normal file
View 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
View 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"
]
}

4919
yarn.lock Normal file

File diff suppressed because it is too large Load Diff