1
0
Fork 0
mirror of https://github.com/syuilo/ai.git synced 2025-03-25 21:12:56 +00:00

Merge branch 'na2na' into featReminderFix

This commit is contained in:
na2na 2022-04-14 22:19:11 +09:00 committed by GitHub
commit 1c6e8429b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 519 additions and 65 deletions

View file

@ -1,3 +1,8 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib"
}
"typescript.tsdk": "node_modules\\typescript\\lib",
"C_Cpp.errorSquiggles": "Disabled",
"cSpell.words": [
"lokijs",
"todos"
]
}

View file

@ -1,61 +1,19 @@
<h1><p align="center"><img src="./ai.svg" alt="藍" height="200"></p></h1>
<p align="center">An Ai for Misskey. <a href="./torisetu.md">About Ai</a></p>
# フォーク元と違うところ
## これなに
Misskey用の日本語Botです。
- 一部の絵文字リアクション機能
- ねこ召喚(summonCat)
- ランダムカラーピッカー(color)
- ランダムにクックパッドからレシピを引っ張ってくる(menu)
- 強震モニター Extension と連携して震度レポートのノート(earthquake)
## インストール
> Node.js と npm と MeCab (オプション) がインストールされている必要があります。
# メモ
まず適当なディレクトリに `git clone` します。
次にそのディレクトリに `config.json` を作成します。中身は次のようにします:
``` json
{
"host": "https:// + あなたのインスタンスのURL (末尾の / は除く)",
"i": "藍として動かしたいアカウントのアクセストークン",
"master": "管理者のユーザー名(オプション)",
"notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる",
"keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false)",
"chartEnabled": "チャート機能を無効化する場合は false を入れてください",
"reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false)",
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)",
"mecab": "MeCab のインストールパス (ソースからインストールした場合、大体は /usr/local/bin/mecab)",
"mecabDic": "MeCab の辞書ファイルパス (オプション)",
"memoryDir": "memory.jsonの保存先オプション、デフォルトは'.'(レポジトリのルートです))"
}
```
`npm install` して `npm run build` して `npm start` すれば起動できます
## 強震モニターについて
## Dockerで動かす
まず適当なディレクトリに `git clone` します。
次にそのディレクトリに `config.json` を作成します。中身は次のようにします:
MeCabの設定、memoryDirについては触らないでください
``` json
{
"host": "https:// + あなたのインスタンスのURL (末尾の / は除く)",
"i": "藍として動かしたいアカウントのアクセストークン",
"master": "管理者のユーザー名(オプション)",
"notingEnabled": "ランダムにノートを投稿する機能を無効にする場合は false を入れる",
"keywordEnabled": "キーワードを覚える機能 (MeCab が必要) を有効にする場合は true を入れる (無効にする場合は false)",
"chartEnabled": "チャート機能を無効化する場合は false を入れてください",
"reversiEnabled": "藍とリバーシで対局できる機能を有効にする場合は true を入れる (無効にする場合は false)",
"serverMonitoring": "サーバー監視の機能を有効にする場合は true を入れる (無効にする場合は false)",
"mecab": "/usr/bin/mecab",
"mecabDic": "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/",
"memoryDir": "data"
}
```
`docker-compose build` して `docker-compose up` すれば起動できます。
`docker-compose.yml``enable_mecab``0` にすると、MeCabをインストールしないようにもできます。メモリが少ない環境など
http サーバーを起動させて、震度レポートを受け取るような仕組み。
config.json にポート番号を指定、そのポート番号に対して、震度レポートを受け取るようにする。
リバースプロキシなんかを使ってたりします。
## フォント
一部の機能にはフォントが必要です。藍にはフォントは同梱されていないので、ご自身でフォントをインストールディレクトリに`font.ttf`という名前で設置してください。
## ランダムカラーピッカー
## 記憶
藍は記憶の保持にインメモリデータベースを使用しており、藍のインストールディレクトリに `memory.json` という名前で永続化されます。
## ライセンス
MIT
## Awards
<img src="./WorksOnMyMachine.png" alt="Works on my machine" height="120">
ランダムに決定した色の 1px \* 1px の画像をアップロードしてます。

View file

@ -20,10 +20,12 @@
"autobind-decorator": "2.4.0",
"canvas": "2.8.0",
"chalk": "4.1.1",
"jsdom": "19.0.0",
"lokijs": "1.5.12",
"memory-streams": "0.1.3",
"misskey-reversi": "0.0.5",
"module-alias": "2.2.2",
"node-fetch": "2.6.7",
"promise-retry": "2.0.1",
"random-seed": "0.3.0",
"reconnecting-websocket": "4.4.0",
@ -40,8 +42,10 @@
"devDependencies": {
"@koa/router": "9.4.0",
"@types/jest": "26.0.23",
"@types/jsdom": "16.2.14",
"@types/koa": "2.13.1",
"@types/koa__router": "8.0.4",
"@types/node-fetch": "3.0.3",
"@types/websocket": "1.0.2",
"jest": "26.6.3",
"koa": "2.13.1",

View file

@ -12,6 +12,7 @@ type Config = {
mecab?: string;
mecabDic?: string;
memoryDir?: string;
earthQuakeMonitorPort?: number;
};
const config = require('../config.json');

View file

@ -15,6 +15,7 @@ import CoreModule from './modules/core';
import TalkModule from './modules/talk';
import BirthdayModule from './modules/birthday';
import ReversiModule from './modules/reversi';
import summonCat from './modules/summonCat';
import PingModule from './modules/ping';
import EmojiModule from './modules/emoji';
import EmojiReactModule from './modules/emoji-react';
@ -32,8 +33,12 @@ import MazeModule from './modules/maze';
import ChartModule from './modules/chart';
import SleepReportModule from './modules/sleep-report';
import NotingModule from './modules/noting';
import PollModule from './modules/poll';
// import PollModule from './modules/poll';
import ReminderModule from './modules/reminder';
import earthquake from './modules/earthquake';
import DicModule from './modules/dic';
import menuModule from './modules/menu';
import GetColorModule from './modules/color';
console.log(' __ ____ _____ ___ ');
console.log(' /__\\ (_ _)( _ )/ __)');
@ -66,6 +71,7 @@ promiseRetry(retry => {
// 藍起動
new (account, [
new CoreModule(),
new summonCat(),
new EmojiModule(),
new EmojiReactModule(),
new FortuneModule(),
@ -86,8 +92,12 @@ promiseRetry(retry => {
new ChartModule(),
new SleepReportModule(),
new NotingModule(),
new PollModule(),
// new PollModule(),
new ReminderModule(),
new DicModule(),
new menuModule(),
new GetColorModule(),
new earthquake(),
]);
}).catch(e => {
log(chalk.red('Failed to fetch the account'));

View file

@ -0,0 +1,52 @@
import autobind from 'autobind-decorator';
import Module from '@/module';
import Message from '@/message';
import { generateColorSample } from './render';
export default class extends Module {
public readonly name = 'color';
@autobind
public install() {
return {
mentionHook: this.mentionHook
};
}
@autobind
private async mentionHook(msg: Message) {
if (msg.text && msg.text.includes('色決めて')) {
// rgbをそれぞれ乱数で生成する
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// rgbをhexに変換する
const hex = `${r.toString(16)}${g.toString(16)}${b.toString(16)}`;
const message = `RGB: ${r}, ${g}, ${b} \`(#${hex})\`とかどう?`
setTimeout(async () => {
const file = await this.getColorSampleFile(r,g,b);
this.log('Replying...');
msg.reply(message, { file });
}, 500);
return {
reaction: '🎨'
};
} else {
return false;
}
}
@autobind
private async getColorSampleFile(r,g,b): Promise<any> {
const colorSample = generateColorSample(r,g,b);
this.log('Image uploading...');
const file = await this.ai.upload(colorSample, {
filename: 'color.png',
contentType: 'image/png'
});
return file;
}
}

View file

@ -0,0 +1,17 @@
import { createCanvas } from 'canvas';
const imageSize = 1; //px
export function generateColorSample(r: string, g: string, b: string) {
const canvas = createCanvas(imageSize, imageSize);
const ctx = canvas.getContext('2d');
ctx.antialias = 'none';
// 引数で渡されたrgb値を基準に、色を塗りつぶす
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.fillRect(0, 0, imageSize, imageSize);
// canvas.toBuffer()をreturn
return canvas.toBuffer();
}

32
src/modules/dic/index.ts Normal file
View file

@ -0,0 +1,32 @@
import autobind from 'autobind-decorator';
import Module from '@/module';
import Message from '@/message';
export default class extends Module {
public readonly name = 'dic';
@autobind
public install() {
return {
mentionHook: this.mentionHook
};
}
@autobind
private async mentionHook(msg: Message) {
if (msg.text && msg.text.includes('って何')) {
// msg.textのうち、「の意味は」の直前で、「@ai」よりも後の物を抽出
const dic_prefix = "https://www.weblio.jp/content/";
const raw_word = msg.text.split('って何')[0].split('@ai')[1].trim();
// スペースがある場合は、半角スペースを除去
const word = raw_word.replace(/\s/g, '');
const url = dic_prefix + encodeURIComponent(word);
msg.reply(`こんな意味っぽい?> [${word}](${url})`, {
immediate: true
});
return true;
} else {
return false;
}
}
}

View file

@ -0,0 +1,165 @@
import autobind from "autobind-decorator";
import Module from "@/module";
import config from "@/config";
import Message from "@/message";
import * as http from "http";
// 基本的に生データはstringばっかり。都合のいい形に加工済みの状態の型定義を書いています。
// ここでいくらか言及されてる(https://bultar.bbs.fc2.com/?act=reply&tid=5645851);
interface {
type: "eew";
time: Date;
report: string; // 第n報 最終報はstringで'final'となるので、とりあえずstring型
epicenter: string; // 震源地
depth: string; // 震源の深さ
magnitude: string; // 地震の規模を示すマグニチュード
latitude: string; // 緯度らしいが謎
longitude: string; // 経度らしいが謎
intensity: string; // 地震の強さ
index: number; // 謎
}
interface {
type: "pga_alert_cancel";
time: Date;
}
interface {
type: "intensity_report";
time: string;
max_index: number;
intensity_list: {
intensity: string;
index: number;
region_list: string[];
}[];
}
interface {
type: "pga_alert";
time: Date;
max_pga: number;
new: boolean;
estimated_intensity: number;
region_list: string[];
}
export default class extends Module {
public readonly name = "earthquake";
private message: string = "";
private thresholdVal = 3; // 下の配列の添え字に相当する値。しきい値以上のものについて通知を出す。 普段は3(震度2)
private earthquakeIntensityIndex: string[] = [
"0未満",
"0",
"1",
"2",
"3",
"4",
"5弱",
"5強",
"6弱",
"6強",
"7",
];
@autobind
public install() {
this.createListenServer();
return {};
}
@autobind
private async createListenServer() {
http.createServer(async (req, res) => {
const buffers: Buffer[] = [];
for await (const chunk of req) {
buffers.push(chunk);
}
const rawDataString = Buffer.concat(buffers).toString();
// rawDataString について、Unicodeエスケープシーケンスが含まれていたら通常の文字列に変換する
// JSONでなければreturn falseする
if (rawDataString.match(/\\u[0-9a-f]{4}/)) {
const rawDataJSON = JSON.parse(
rawDataString.replace(/\\u([\d\w]{4})/g, (match, p1) => {
return String.fromCharCode(parseInt(p1, 16));
}),
);
if (rawDataJSON.type == "intensity_report") {
if (rawDataJSON.max_index >= this.thresholdVal - 1) {
// 日付時刻は、yyyy-mm-dd hh:mm:ss
const time = new Date(parseInt(rawDataJSON.time));
const timeString = `${time.getFullYear()}-${(time.getMonth() +
1).toString().padStart(2, '0')}-${time.getDate().toString().padStart(2, '0')} ${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}:${time.getSeconds().toString().padStart(2, '0')}`;
const data: 震度レポート = {
type: rawDataJSON.type,
time: timeString,
max_index: rawDataJSON.max_index,
intensity_list: rawDataJSON.intensity_list,
};
this.message =
`地震かも?\n\`\`\`\n震度レポート\n${data.time}\n最大震度: ${
this.earthquakeIntensityIndex[data.max_index + 1]
}\n\n${
data.intensity_list.map((intensity) =>
`震度${this.earthquakeIntensityIndex[intensity.index + 1]}: ${
intensity.region_list.join(" ")
}`
).join("\n")
}\n\`\`\``;
}
}
if (rawDataJSON.type == "eew" && false) { // これ使わなさそうだしとりあえず入らないようにした
const data: 緊急地震速報 = {
type: rawDataJSON.type,
time: new Date(parseInt(rawDataJSON.time)),
report: rawDataJSON.report,
epicenter: rawDataJSON.epicenter,
depth: rawDataJSON.depth,
magnitude: rawDataJSON.magnitude,
latitude: rawDataJSON.latitude,
longitude: rawDataJSON.longitude,
intensity: rawDataJSON.intensity,
index: rawDataJSON.index,
};
if (data.report == "1") {
this.message =
`**TEST TEST TEST TEST**\n地震かも\n\n緊急地震速報\n${data.time.toLocaleString()}\n\n第${data.report}\n震源地: ${data.epicenter}\n震源の深さ: ${data.depth}\n地震の規模(M): ${data.magnitude}\n緯度: ${data.latitude}\n経度: ${data.longitude}\n予想される最大震度(): ${data.intensity}\n`;
}
}
console.table(rawDataJSON); // デバッグ用
if (rawDataJSON.type == 'intensity_report') {
console.table(rawDataJSON.intensity_list); // デバッグ用
}
this.returnResponse(res, "ok");
if (this.message) {
this.ai.post({
cw: "試験運用中!!!!!",
visibility: "home",
text: this.message,
});
}
} else {
this.ai.post({
cw: "試験運用中!!!!!",
visibility: "home",
text: 'eq:デボビゲゴ',
});
this.returnResponse(res, "debobigego");
}
}).listen(config.earthQuakeMonitorPort || 9999);
}
@autobind
private returnResponse(res: http.ServerResponse, text: string) {
res.writeHead(200, {
"Content-Type": "text/plain",
});
res.end(text);
}
}

View file

@ -0,0 +1,38 @@
# 緊急地震速報
{"type":"eew",
"time":long,
"report":int,
"epicenter":String,
"depth":String,
"magnitude":String,
"latitude":String,
"longitude":String,
"intensity":String,
"index":int
}
# 地震検知
{"type":"pga_alert",
"time":long,
"max_pga":float,
"new":boolean,
"estimated_intensity":int,
"region_list":[String,String,,,]
}
# 地震検知キャンセル
{"type":"pga_alert_cancel", "time":long }
# 震度レポート
{"type":"intensity_report",
"time":long,
"max_index":int,
"intensity_list":[
{"intensity":String,
"index":int,
"region_list":[String,String,,,,]},
{"intensity":String,
"index":int,
"region_list":[String,String,,,,]},
,,]
}

View file

@ -0,0 +1,4 @@
# テスト用生データ
{"type":"pga_alert","time":"1649085285968","max_pga":-0.531,"new":true,"estimated_intensity":0,"region_list":["\u8328\u57ce"]}
{"type":"intensity_report","time":"1649085285968","max_index":-1,"intensity_list":[{"intensity":"0\u672a\u6e80","index":-1,"region_list":["\u8328\u57ce"]}]}
{"type": "eew","report": "1","epicenter": "伊予灘","depth": "60km","magnitude": 3.5,"latitude": 33.8,"longitude": 132.1,"intensity": "2","index": 2}

View file

@ -28,7 +28,7 @@ export default class extends Module {
const react = async (reaction: string, immediate = false) => {
if (!immediate) {
await delay(1500);
await delay(2500);
}
this.ai.api('notes/reactions/create', {
noteId: note.id,
@ -36,6 +36,28 @@ export default class extends Module {
});
};
// /う[〜|ー]*んこ/g]にマッチしたときの処理
if (note.text.match(/う[〜|ー]*んこ/g) || includes(note.text, ['unko'])) {
return await react(':anataima_unkotte_iimashitane:');
}
if (note.text.match(/う[〜|ー]*んち/g)) {
return await react(':erait:');
}
if (includes(note.text, ['いい']) && (includes(note.text, ["?"]) || includes(note.text, [""]))) {
// 50%の確率で":dame:"または":yattare:"を返す
if (Math.random() < 0.5) {
return react(':dame:');
} else {
return react(':yattare:');
}
}
if (includes(note.text, ['どこ'])) {
return await react(':t_ofuton:');
}
const customEmojis = note.text.match(/:([^\n:]+?):/g);
if (customEmojis) {
// カスタム絵文字が複数種類ある場合はキャンセル
@ -68,6 +90,17 @@ export default class extends Module {
if (includes(note.text, ['ぷりん'])) return react('🍮');
if (includes(note.text, ['寿司', 'sushi']) || note.text === 'すし') return react('🍣');
if (includes(note.text, ['藍'])) return react('🙌');
if (includes(note.text, ['ずなず']) || includes(note.text, ['ずにゃず'])) return react('🙌');
if (includes(note.text, ['なず']) || includes(note.text, ['にゃず'])) return react(':google_hart:');
const gameReact = [
':ysvi:',
':ysf:',
':yso:'
]
if (includes(note.text, ['おゲームするかしら'])){
// gameReactの中からランダムに選択
return react(gameReact[Math.floor(Math.random() * gameReact.length)]);
}
}
}

61
src/modules/menu/index.ts Normal file
View file

@ -0,0 +1,61 @@
import autobind from 'autobind-decorator';
import Module from '@/module';
import Message from '@/message';
import fetch from 'node-fetch';
import { JSDOM } from 'jsdom';
export default class extends Module {
public readonly name = 'menu';
@autobind
public install() {
return {
mentionHook: this.mentionHook
};
}
@autobind
private async mentionHook(msg: Message) {
if (msg.text && msg.text.includes('ごはん')) {
// 1~2535111の適当な数字を取得
const random_number = Math.floor(Math.random() * 2535111) + 1;
const url = `https://cookpad.com/recipe/${random_number}`;
//testUrlして、200以外なら再取得
const res = await fetch(url);
if (res.status !== 200) {
return this.mentionHook(msg);
} else {
//jsdomを利用してレシピのタイトルを取得
const dom = new JSDOM(await res.text());
//@ts-ignore
let title = dom.window.document.querySelector('h1.recipe-title').textContent;
// titleから改行を除去
title = title!.replace(/\n/g, '');
msg.reply(`こんなのどう?> [${title}](${url})`, {
immediate: true
});
return true;
}
} else {
return false;
}
}
}
function testUrl(url: string) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.onload = () => {
if (xhr.status === 200) {
resolve(true)
} else {
reject(false)
}
}
xhr.onerror = () => {
reject(false)
}
xhr.send()
})
}

View file

@ -14,10 +14,16 @@ export default class extends Module {
@autobind
private async mentionHook(msg: Message) {
if (msg.text && msg.text.includes('ping')) {
msg.reply('PONG!', {
immediate: true
});
if (msg.text && (msg.text.includes('ping') || msg.text.includes('おい'))) {
if (msg.text.includes('おい')) {
msg.reply('はい。。。', {
immediate: true
});
} else {
msg.reply('PONG!', {
immediate: true
});
}
return true;
} else {
return false;

View file

@ -67,6 +67,14 @@ export default class extends Module {
};
}
if (msg.visibility === 'followers') {
msg.reply(serifs.reminder.invalidVisibility);
return {
reaction: '🆖',
immediate: true,
};
}
const remind = this.reminds.insertOne({
id: msg.id,
userId: msg.userId,

View file

@ -0,0 +1,58 @@
import autobind from 'autobind-decorator';
import Module from '@/module';
import Message from '@/message';
import fetch from 'node-fetch';
import { ReadStream } from 'fs';
export default class extends Module {
public readonly name = 'summonCat';
@autobind
public install() {
return {
mentionHook: this.mentionHook
};
}
@autobind
private async mentionHook(msg: Message) {
// cat/Cat/ねこ/ネコ/にゃん
console.log(msg.text)
if (msg.text && (msg.text.match(/(cat|Cat|ねこ|ネコ|にゃ[〜|ー]*ん)/g))) {
const message = "にゃ~ん!";
setTimeout(async () => {
const file = await this.getCatImage();
this.log(file);
this.log('Replying...');
msg.reply(message, { file });
}, 500);
return {
reaction: ':blobcatmeltnomblobcatmelt:'
};
} else {
return false;
}
}
@autobind
private async getCatImage(): Promise<any> {
// https://aws.random.cat/meowにGETリクエストを送る
// fileに画像URLが返ってくる
const res = await fetch('https://aws.random.cat/meow');
const json = await res.json();
console.table(json);
const fileUri = json.file;
// 拡張子を取り除く
const fileName = fileUri.split('/').pop().split('.')[0];
const rawFile = await fetch(fileUri);
const imgBuffer = await rawFile.buffer();
// 拡張子とcontentTypeを判断する
const ext = fileUri.split('.').pop();
const file = await this.ai.upload(imgBuffer, {
filename: `${fileName}.${ext}`,
});
return file;
}
}

View file

@ -341,6 +341,8 @@ export default {
doneFromInvalidUser: 'イタズラはめっですよ!',
invalidVisibility: "公開範囲の指定を変えてみて",
reminds: 'やること一覧です!',
notify: (name) => name ? `${name}、これやりましたか?` : `これやりましたか?`,