This commit is contained in:
NullCat 2023-01-29 17:35:28 +09:00
parent ccafd0fb7c
commit f7543dc8bc
69 changed files with 38 additions and 9679 deletions

View File

@ -1,13 +0,0 @@
config.json
font.ttf
nullcat.*
*.md
*.png
Dockerfile
docker-compose.yml
LICENSE
node_modules/
test/
data/
.vscode/

View File

@ -1,10 +0,0 @@
root = true
[*]
indent_style = tab
indent_size = 2
charset = utf-8
insert_final_newline = true
[*.yml]
indent_style = space

7
.gitignore vendored
View File

@ -1,7 +0,0 @@
config.json
built
node_modules
memory.json
font.ttf
.idea
.vscode

5
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,5 @@
# デフォルトの無視対象ファイル
/shelf/
/workspace.xml
# エディターベースの HTTP クライアントリクエスト
/httpRequests/

12
.idea/NullcatChan.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

7
.idea/discord.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/NullcatChan.iml" filepath="$PROJECT_DIR$/.idea/NullcatChan.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

2
.npmrc
View File

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

View File

@ -1,25 +0,0 @@
FROM node:lts-bullseye
RUN apt-get update && apt-get install -y tini
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 --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 . /ai
WORKDIR /ai
RUN npm install && npm run build
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD npm start

View File

@ -1,26 +0,0 @@
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-old /nullcat-chan
WORKDIR /nullcat-chan
RUN npm install && npm run build
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD npm run dev

View File

@ -1,26 +0,0 @@
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
View File

@ -1,21 +0,0 @@
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.

View File

@ -1,62 +0,0 @@
# これってなに?
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` という名前で永続化されます。

View File

@ -1,15 +0,0 @@
version: '3'
services:
app:
build:
dockerfile: Dockerfile_production
context: ../NullcatChan
args:
- enable_mecab=1
volumes:
- './config.json:/nullcat-chan/config.json:ro'
- './font.ttf:/nullcat-chan/font.ttf:ro'
- './data:/nullcat-chan/data'
restart: always
environment:
TZ: Asia/Tokyo

View File

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

View File

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

View File

@ -1,89 +0,0 @@
{
"version": "2.1.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",
"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.11.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"
}
}
}

View File

@ -1,21 +0,0 @@
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;

View File

@ -1,190 +0,0 @@
import { User } from "./misskey/user"
import IModule from "../../NullcatChan/src/module"
import NullcatChan from "../../NullcatChan/src/nullcat-chan"
import getDate from "../../NullcatChan/src/utils/get-date"
import { genItem } from "../../NullcatChan/src/vocabulary"
import autobind from "autobind-decorator"
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 nullcatChan: NullcatChan
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(nullcatChan: NullcatChan, opts: { user?: User; doc?: FriendDoc }) {
this.nullcatChan = nullcatChan
if (opts.user) {
const exist = this.nullcatChan.friends.findOne({
userId: opts.user.id,
})
if (exist == null) {
const inserted = this.nullcatChan.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.nullcatChan.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.nullcatChan.log(`💢 ${this.userId} -${amount}`)
}
@autobind
public updateName(name: string) {
this.doc.name = name
this.save()
}
@autobind
public save() {
this.nullcatChan.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.nullcatChan.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
}
}

View File

@ -1,107 +0,0 @@
// Nullcat chan! bootstrapper
import * as chalk from "chalk"
import "module-alias/register"
import * as request from "request-promise-native"
import config from "./config"
import BirthdayModule from "../../NullcatChan/src/modules/birthday"
import CoreModule from "../../NullcatChan/src/modules/core"
import EmojiReactModule from "../../NullcatChan/src/modules/emoji-react"
import FeelingModule from "../../NullcatChan/src/modules/feeling"
import FollowModule from "../../NullcatChan/src/modules/follow"
import FortuneModule from "../../NullcatChan/src/modules/fortune"
import GitHubStatusModule from "../../NullcatChan/src/modules/github-status"
import CloudflareStatus from "../../NullcatChan/src/modules/cloudflare-status";
import GomamayoModule from "../../NullcatChan/src/modules/gomamayo"
import JihouModule from "../../NullcatChan/src/modules/jihou"
import KeywordModule from "../../NullcatChan/src/modules/keyword"
import KiatsuModule from "../../NullcatChan/src/modules/kiatsu"
import NotingModule from "../../NullcatChan/src/modules/noting"
import PingModule from "../../NullcatChan/src/modules/ping"
import ReminderModule from "../../NullcatChan/src/modules/reminder"
import ServerModule from "../../NullcatChan/src/modules/server"
import SleepReportModule from "../../NullcatChan/src/modules/sleep-report"
import TalkModule from "../../NullcatChan/src/modules/talk"
import TimerModule from "../../NullcatChan/src/modules/timer"
import TraceMoeModule from "../../NullcatChan/src/modules/trace-moe"
import ValentineModule from "../../NullcatChan/src/modules/valentine"
import WhatModule from "../../NullcatChan/src/modules/what"
import YarukotoModule from "../../NullcatChan/src/modules/yarukoto"
import NullcatChan from "../../NullcatChan/src/nullcat-chan"
import _log from "../../NullcatChan/src/utils/log"
import ShellGeiModule from "../../NullcatChan/src/modules/shellgei"
import SversionModule from "../../NullcatChan/src/modules/version"
import AyashiiModule from "../../NullcatChan/src/modules/ayashii"
const promiseRetry = require("promise-retry")
const pkg = require("../../NullcatChan/package.json")
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 NullcatChan(account, [
new CoreModule(),
new EmojiReactModule(),
new FortuneModule(),
new TimerModule(),
new TalkModule(),
new FollowModule(),
new BirthdayModule(),
new ValentineModule(),
new KeywordModule(),
new SleepReportModule(),
new NotingModule(),
new ReminderModule(),
new GomamayoModule(),
new GitHubStatusModule(),
new CloudflareStatus(),
new YarukotoModule(),
new KiatsuModule(),
new JihouModule(),
new WhatModule(),
new FeelingModule(),
new TraceMoeModule(),
new ServerModule(),
new ShellGeiModule(),
new SversionModule(),
new AyashiiModule(),
new PingModule(),
])
})
.catch((e) => {
log(chalk.red("Failed to fetch the account"))
})

View File

@ -1,142 +0,0 @@
import config from "@/config"
import Friend from "@/friend"
import { User } from "./misskey/user"
import NullcatChan from "../../NullcatChan/src/nullcat-chan"
import includes from "../../NullcatChan/src/utils/includes"
import or from "../../NullcatChan/src/utils/or"
import autobind from "autobind-decorator"
import * as chalk from "chalk"
const delay = require("timeout-as-promise")
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
}
export default class Message {
private nullcatChan: NullcatChan
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 extractedText(): string {
const host = new URL(config.host).host.replace(/\./g, "\\.")
return this.text
.replace(new RegExp(`^@${this.nullcatChan.account.username}@${host}\\s`, "i"), "")
.replace(new RegExp(`^@${this.nullcatChan.account.username}\\s`, "i"), "")
.trim()
}
public get replyId(): string {
return this.messageOrNote.replyId
}
public friend: Friend
constructor(nullcatChan: NullcatChan, messageOrNote: any, isDm: boolean) {
this.nullcatChan = nullcatChan
this.messageOrNote = messageOrNote
this.isDm = isDm
this.friend = new Friend(nullcatChan, { user: this.user })
// メッセージなどに付いているユーザー情報は省略されている場合があるので完全なユーザー情報を持ってくる
this.nullcatChan
.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.nullcatChan.log(`>>> Sending reply to ${chalk.underline(this.id)}`)
if (!opts?.immediate) {
await delay(2000)
}
if (this.isDm) {
return await this.nullcatChan.sendMessage(this.messageOrNote.userId, {
text: text,
fileId: opts?.file?.id,
})
} else {
return await this.nullcatChan.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)
}
}

View File

@ -1,13 +0,0 @@
export type Note = {
id: string;
text: string | null;
reply: any | null;
poll?: {
choices: {
votes: number;
text: string;
}[];
expiredAfter: number;
multiple: boolean;
} | null;
};

View File

@ -1,8 +0,0 @@
export type User = {
id: string;
name: string;
username: string;
host?: string | null;
isFollowing?: boolean;
isBot: boolean;
};

View File

@ -1,74 +0,0 @@
import NullcatChan, { InstallerResult } from "@/nullcat-chan"
import autobind from "autobind-decorator"
export default abstract class Module {
public abstract readonly name: string
protected nullcatChan: NullcatChan
private doc: any
public init(nullcatChan: NullcatChan) {
this.nullcatChan = nullcatChan
this.doc = this.nullcatChan.moduleData.findOne({
module: this.name,
})
if (this.doc == null) {
this.doc = this.nullcatChan.moduleData.insertOne({
module: this.name,
data: {},
})
}
}
public abstract install(): InstallerResult
@autobind
protected log(msg: string) {
this.nullcatChan.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.nullcatChan.subscribeReply(this, key, isDm, id, data)
}
/**
*
* @param key
*/
@autobind
protected unsubscribeReply(key: string | null) {
this.nullcatChan.unsubscribeReply(this, key)
}
/**
*
*
* @param delay
* @param data
*/
@autobind
public setTimeoutWithPersistence(delay: number, data?: any) {
this.nullcatChan.setTimeoutWithPersistence(this, delay, data)
}
@autobind
protected getData() {
return this.doc.data
}
@autobind
protected setData(data: any) {
this.doc.data = data
this.nullcatChan.moduleData.update(this.doc)
}
}

View File

@ -1,28 +0,0 @@
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

@ -1,56 +0,0 @@
import Friend from "@/friend"
import Module from "@/module"
import serifs from "@/serifs"
import autobind from "autobind-decorator"
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.nullcatChan.friends.find({
"user.birthday": { $regex: new RegExp("-" + today + "$") },
} as any)
birthFriends.forEach((f) => {
const friend = new Friend(this.nullcatChan, { 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.nullcatChan.sendMessage(friend.userId, {
text: text,
})
})
}
}

View File

@ -1,61 +0,0 @@
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;
}
}
}

View File

@ -1,153 +0,0 @@
import Message from "@/message"
import Module from "@/module"
import serifs from "@/serifs"
import { safeForInterpolate } from "../../../../NullcatChan/src/utils/safe-for-interpolate"
import autobind from "autobind-decorator"
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.nullcatChan.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.nullcatChan.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

@ -1,69 +0,0 @@
import { Note } from "../../misskey/note"
import Module from "@/module"
import Stream from "@/stream"
import includes from "../../../../NullcatChan/src/utils/includes"
import autobind from "autobind-decorator"
const delay = require("timeout-as-promise")
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.nullcatChan.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.nullcatChan.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

@ -1,31 +0,0 @@
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

@ -1,35 +0,0 @@
import Message from "@/message"
import Module from "@/module"
import autobind from "autobind-decorator"
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.nullcatChan.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

@ -1,36 +0,0 @@
import Message from "@/message"
import Module from "@/module"
import serifs from "@/serifs"
import { genItem } from "@/vocabulary"
import autobind from "autobind-decorator"
import * as seedrandom from "seedrandom"
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

@ -1,90 +0,0 @@
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.nullcatChan.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

@ -1,37 +0,0 @@
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

@ -1,50 +0,0 @@
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.nullcatChan.post({
text: `${hour}時だよ!`,
})
break
case 7:
this.nullcatChan.post({
text: `みんなおはよ!${hour}時だよ!`,
})
break
case 1:
this.nullcatChan.post({
text: `${hour}時だよ!みんなそろそろ寝る時間かな?`,
})
break
case 5:
this.nullcatChan.post({
text: `${hour}時だよ!ログボリセットの時間だよ!!`,
})
break
}
}
}

View File

@ -1,101 +0,0 @@
import config from "@/config"
import Message from "@/message"
import Module from "@/module"
import serifs from "@/serifs"
import NGWord from "../../ng-words";
import autobind from "autobind-decorator"
import * as loki from "lokijs"
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.nullcatChan.getCollection("_keyword_learnedKeywords", {
indices: ["userId"],
})
setInterval(this.learn, 1000 * 60 * 45)
return {
mentionHook: this.mentionHook,
}
}
@autobind
private async learn() {
const tl = await this.nullcatChan.api("notes/hybrid-timeline", {
limit: 30,
})
const interestedNotes = tl.filter((note) => note.userId !== this.nullcatChan.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.nullcatChan.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

@ -1,45 +0,0 @@
import { spawn } from "child_process"
import * as memoryStreams from "memory-streams"
import { EOL } from "os"
import * as stream from "stream"
import * as util from "util"
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

@ -1,96 +0,0 @@
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.nullcatChan.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

@ -1,34 +0,0 @@
import config from "@/config"
import Module from "@/module"
import serifs from "@/serifs"
import autobind from "autobind-decorator"
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.nullcatChan.post({
text: note,
})
}
}

View File

@ -1,26 +0,0 @@
import Message from "@/message"
import Module from "@/module"
import autobind from "autobind-decorator"
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

@ -1,172 +0,0 @@
import config from "@/config"
import Message from "@/message"
import Module from "@/module"
import serifs, { getSerif } from "@/serifs"
import { acct } from "../../../../NullcatChan/src/utils/acct"
import autobind from "autobind-decorator"
import * as loki from "lokijs"
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.nullcatChan.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.reply(serifs.reminder.invalid)
return 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: ":ok:",
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(["やめる", "やめた", "キャンセル"])
if (done || cancel) {
this.unsubscribeReply(key)
this.reminds.remove(remind)
msg.reply(done ? getSerif(serifs.reminder.done(msg.friend.name)) : serifs.reminder.cancel)
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.nullcatChan.lookupFriend(remind.userId)
if (friend == null) return // 処理の流れ上、実際にnullになることは無さそうだけど一応
let reply
if (remind.isDm) {
this.nullcatChan.sendMessage(friend.userId, {
text: serifs.reminder.notifyWithThing(remind.thing, friend.name),
})
} else {
try {
reply = await this.nullcatChan.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) {
// TODO: renote対象が消されていたらリマインダー解除
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

@ -1,79 +0,0 @@
import config from "@/config"
import Module from "@/module"
import serifs from "@/serifs"
import autobind from "autobind-decorator"
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.nullcatChan.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.nullcatChan.post({
text: serifs.server.cpu,
})
this.warned = true
}
}

View File

@ -1,74 +0,0 @@
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

@ -1,35 +0,0 @@
import Module from "@/module"
import serifs from "@/serifs"
import autobind from "autobind-decorator"
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.nullcatChan.lastSleepedAt
const sleepHours = sleepTime / 1000 / 60 / 60
if (sleepHours < 0.1) return
if (sleepHours >= 1) {
this.nullcatChan.post({
text: serifs.sleepReport.report(Math.round(sleepHours)),
})
} else {
this.nullcatChan.post({
text: serifs.sleepReport.reportUtatane,
})
}
}
}

View File

@ -1,343 +0,0 @@
import Message from "@/message"
import Module from "@/module"
import { HandlerResult } from "@/nullcat-chan"
import serifs, { getSerif } from "@/serifs"
import getDate from "../../../../NullcatChan/src/utils/get-date"
import autobind from "autobind-decorator"
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(["痛い", "いたい"])) 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

@ -1,72 +0,0 @@
import Message from "@/message"
import Module from "@/module"
import serifs from "@/serifs"
import autobind from "autobind-decorator"
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.nullcatChan.lookupFriend(data.userId)
if (friend == null) return // 処理の流れ上、実際にnullになることは無さそうだけど一応
const text = serifs.timer.notify(data.time, friend.name)
if (data.isDm) {
this.nullcatChan.sendMessage(friend.userId, {
text: text,
})
} else {
this.nullcatChan.post({
replyId: data.msgId,
text: text,
})
}
}
}

View File

@ -1,157 +0,0 @@
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

@ -1,51 +0,0 @@
import Friend from "@/friend"
import Module from "@/module"
import serifs from "@/serifs"
import autobind from "autobind-decorator"
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.nullcatChan.friends.find({} as any)
friends.forEach((f) => {
const friend = new Friend(this.nullcatChan, { doc: f })
// 親愛度が7以上必要
if (friend.love < 7) 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.nullcatChan.sendMessage(friend.userId, {
text: text,
})
})
}
}

View File

@ -1,101 +0,0 @@
import autobind from 'autobind-decorator';
import Module from '@/module';
import Message from '@/message';
/**
*
*/
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.nullcatChan.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.nullcatChan
.api('meta')
.then(meta => {
msg.reply(`${this.mfmVersion(meta.version)} みたいだよ!`);
})
.catch(() => {
msg.reply(`取得失敗しちゃった:cry_nullcatchan:`);
});
return true;
}
/**
*
*/
private getVersion = (): Promise<Version> => {
return this.nullcatChan.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

@ -1,27 +0,0 @@
import Message from "@/message"
import Module from "@/module"
import autobind from "autobind-decorator"
export default class extends Module {
public readonly name = "what"
@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

@ -1,50 +0,0 @@
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
}
}
}

View File

@ -1,77 +0,0 @@
import toHiragana from '../../NullcatChan/src/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;
}
}
}

View File

@ -1,522 +0,0 @@
// NULLCAT-CHAN CORE
import config from "./config"
import Friend, { FriendDoc } from "./friend"
import Message from "./message"
import { User } from "./misskey/user"
import Module from "../../NullcatChan/src/module"
import Stream from "./stream"
import log from "../../NullcatChan/src/utils/log"
import autobind from "autobind-decorator"
import * as chalk from "chalk"
import * as fs from "fs"
import * as loki from "lokijs"
import * as request from "request-promise-native"
import { v4 as uuid } from "uuid"
const delay = require("timeout-as-promise")
const pkg = require("../../NullcatChan/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 NullcatChan {
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)
}
}

View File

@ -1,283 +0,0 @@
// せりふ
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}えらすぎる!!`,
] : [
`すごい!!天才!!えらい!!`,
`さすがすぎる!!!`,
`えらすぎる!!`,
],
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;
}
}

View File

@ -1,308 +0,0 @@
import autobind from "autobind-decorator"
import { EventEmitter } from "events"
import * as WebSocket from "ws"
import config from "./config"
const ReconnectingWebsocket = require("reconnecting-websocket")
/**
* 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)
}
}

View File

@ -1,5 +0,0 @@
export function acct(user: { username: string; host?: string | null; }): string {
return user.host
? `@${user.username}@${user.host}`
: `@${user.username}`;
}

View File

@ -1,8 +0,0 @@
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;
}

View File

@ -1,10 +0,0 @@
import { hankakuToZenkaku, katakanaToHiragana } 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))
}

View File

@ -1,79 +0,0 @@
// Utilities for Japanese
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, '゜');
};

View File

@ -1,11 +0,0 @@
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);
}

View File

@ -1,61 +0,0 @@
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(/、+$/, '');
}
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

@ -1,16 +0,0 @@
const invalidChars = [
'@',
'#',
'*',
':',
'(',
')',
'[',
']',
' ',
' ',
];
export function safeForInterpolate(text: string): boolean {
return !invalidChars.some(c => text.includes(c));
}

View File

@ -1,5 +0,0 @@
const moji = require('moji');
export default function toHiragana(str: string): string {
return moji(str).convert('HK', 'ZK').convert('KK', 'HG').toString();
}

View File

@ -1,131 +0,0 @@
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

@ -1,5 +0,0 @@
const moji = require('moji');
export default function toHiragana(str: string): string {
return moji(str).convert('HK', 'ZK').convert('KK', 'HG').toString();
}

View File

@ -1,64 +0,0 @@
<img src="https://usercontent.misskey.online/xb4786y7/235f8ff2-1e18-4f84-8ee2-80490eacc3a6.png" align="right" height="320px"/>
# ぬるきゃっとちゃん!の主な機能
### フォローする
僕に「フォローして」って言ってくれたらフォローするよ
### お話
「おはよう」「おやすみ」などと話しかけると反応するよ
### リアクション
僕が設定されている特定のワードにリアクションするよ
### 占い
僕に「占って」と言うと、あなたの今日の運勢を占うよ
### タイマー
指定した時間、分、秒を経過したら教えてくれるよ「3分40秒」のように単位を混ぜることもできるよ
### リマインダー
`@nullcat todo(リマインド、これやる) 寝る` みたいに言ってくれたら1時間置きにリマインドするよ。その飛ばしたメンションか、僕からの催促に「やった」「やめた」など返信するとリマインダー解除されるよ<br>
引用Renoteでメンションすることもできるよ<br>
リマインダーの一覧は `@nullcat todos` で見れるよ
### GitHub Status
僕に「GitHub」って言ってくれたら今のStatusを教えるよ
### Cloudflare Status
僕に「Cloudflare」って言ってくれたら今のStatusを教えるよ
### シェル芸機能
僕に #シェル芸#shellge をつけてコマンドを送ってくれたら実行結果を返すよ
### 怪レい曰本语変換
僕に `#怪しい日本語変換` っていうタグ付きで変換してほしい文章をメンションしてくれたら怪レい曰本语に変換するよ
### やること決める
僕に「なにしよ」って言ってくれたらやることを決めるよ
### 気圧
僕に「気圧教えて」って言ってくれたら今の気圧を教えるよ
### 呼び方を教える
僕が君のことをなんて呼べばいいか教えてくれたら、その名前で呼ぶよ!<br>
親愛度が一定の値に達している必要があるよ<br>
(チャットのみで反応するよ)
### HappyBirthday
誕生日になったら僕が君の誕生日を祝うよ
### バレンタイン
バレンタインになったら仲のいい子に僕がチョコレートをあげるよ
### ping
僕に「ping」って言ってくれたらフォローするよで起きてるとき返信するよ寝てるときは返信できないかも...
### 親愛度
僕は君に対する親愛度を持っているよ<br>
僕にお話ししてくれたりすると、少しずつ上がるよ<br>
親愛度によって反応が変化するよ!親愛度がある程度ないとしてくれないこともあるよ<br>
たくさん話しかけてね
僕のリポジトリは[ここ](https://github.com/nullnyat/NullcatChan)だよ

View File

@ -1,26 +0,0 @@
{
"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": "../NullcatChan",
"paths": {
"@/*": ["src/*"]
}
},
"compileOnSave": false,
"include": [
"./src/**/*.ts"
]
}

4919
yarn.lock

File diff suppressed because it is too large Load Diff