Compare commits

..

1 commit

Author SHA1 Message Date
Take-John 6f267b608a
Merge 2f40fd4aa0 into 830c9c2ecd 2024-03-30 00:58:05 +09:00
6 changed files with 128 additions and 104 deletions

115
src/ai.ts
View file

@ -46,21 +46,32 @@ export type ModuleDataDoc<Data = any> = {
/** /**
* *
*/ */
export default class { export default interface extends Ai {
connection: Stream;
lastSleepedAt: number;
friends: loki.Collection<FriendDoc>;
moduleData: loki.Collection<ModuleDataDoc>;
}
/**
*
*/
export class Ai {
public readonly version = pkg._v; public readonly version = pkg._v;
public account: User; public account: User;
public connection: Stream; public connection?: Stream;
public modules: Module[] = []; public modules: Module[] = [];
private mentionHooks: MentionHook[] = []; private mentionHooks: MentionHook[] = [];
private contextHooks: { [moduleName: string]: ContextHook } = {}; private contextHooks: { [moduleName: string]: ContextHook } = {};
private timeoutCallbacks: { [moduleName: string]: TimeoutCallback } = {}; private timeoutCallbacks: { [moduleName: string]: TimeoutCallback } = {};
public installedModules: { [moduleName: string]: InstalledModule } = {}; public installedModules: { [moduleName: string]: InstalledModule } = {};
public db: loki; public db: loki;
public lastSleepedAt: number; public lastSleepedAt?: number;
private meta: loki.Collection<Meta>; private meta?: loki.Collection<Meta>;
private contexts: loki.Collection<{ private contexts?: loki.Collection<{
noteId?: string; noteId?: string;
userId?: string; userId?: string;
module: string; module: string;
@ -68,7 +79,7 @@ export default class 藍 {
data?: any; data?: any;
}>; }>;
private timers: loki.Collection<{ private timers?: loki.Collection<{
id: string; id: string;
module: string; module: string;
insertedAt: number; insertedAt: number;
@ -76,16 +87,20 @@ export default class 藍 {
data?: any; data?: any;
}>; }>;
public friends: loki.Collection<FriendDoc>; public friends?: loki.Collection<FriendDoc>;
public moduleData: loki.Collection<any>; public moduleData?: loki.Collection<any>;
private ready: boolean = false;
/** /**
* *
* @param account 使 * @param account 使
* @param modules * @param modules
*/ */
@bindThis constructor(account: User, modules: Module[]) {
public static start(account: User, modules: Module[]) { this.account = account;
this.modules = modules;
let memoryDir = '.'; let memoryDir = '.';
if (config.memoryDir) { if (config.memoryDir) {
memoryDir = config.memoryDir; memoryDir = config.memoryDir;
@ -94,7 +109,7 @@ export default class 藍 {
this.log(`Lodaing the memory from ${file}...`); this.log(`Lodaing the memory from ${file}...`);
const db = new loki(file, { this.db = new loki(file, {
autoload: true, autoload: true,
autosave: true, autosave: true,
autosaveInterval: 1000, autosaveInterval: 1000,
@ -103,7 +118,7 @@ export default class 藍 {
this.log(chalk.red(`Failed to load the memory: ${err}`)); this.log(chalk.red(`Failed to load the memory: ${err}`));
} else { } else {
this.log(chalk.green('The memory loaded successfully')); this.log(chalk.green('The memory loaded successfully'));
new (account, modules, db); this.run();
} }
} }
}); });
@ -111,19 +126,11 @@ export default class 藍 {
@bindThis @bindThis
public log(msg: string) { public log(msg: string) {
.log(msg);
}
@bindThis
private static log(msg: string) {
log(`[${chalk.magenta('AiOS')}]: ${msg}`); log(`[${chalk.magenta('AiOS')}]: ${msg}`);
} }
private constructor(account: User, modules: Module[], db: loki) { @bindThis
this.account = account; private run() {
this.modules = modules;
this.db = db;
//#region Init DB //#region Init DB
this.meta = this.getCollection('meta', {}); this.meta = this.getCollection('meta', {});
@ -150,6 +157,9 @@ export default class 藍 {
// Init stream // Init stream
this.connection = new Stream(); this.connection = new Stream();
// この時点から藍インスタンスに
this.setReady();
//#region Main stream //#region Main stream
const mainStream = this.connection.useSharedConnection('main'); const mainStream = this.connection.useSharedConnection('main');
@ -217,12 +227,40 @@ export default class 藍 {
this.log(chalk.green.bold('Ai am now running!')); this.log(chalk.green.bold('Ai am now running!'));
} }
/**
*
*/
private setReady(): asserts this is {
// 呼び出すタイミングが正しいか検証
if (
this.connection == null ||
this.lastSleepedAt == null ||
this.meta == null ||
this.contexts == null ||
this.timers == null ||
this.friends == null ||
this.moduleData == null
) {
throw new TypeError('Cannot set ready');
}
this.ready = true;
}
public requireReady(): asserts this is {
if (!this.ready) {
throw new TypeError('Ai am not ready!');
}
}
/** /**
* *
* () * ()
*/ */
@bindThis @bindThis
private async onReceiveMessage(msg: Message): Promise<void> { private async onReceiveMessage(msg: Message): Promise<void> {
this.requireReady();
this.log(chalk.gray(`<<< An message received: ${chalk.underline(msg.id)}`)); this.log(chalk.gray(`<<< An message received: ${chalk.underline(msg.id)}`));
// Ignore message if the user is a bot // Ignore message if the user is a bot
@ -234,7 +272,7 @@ export default class 藍 {
const isNoContext = msg.replyId == null; const isNoContext = msg.replyId == null;
// Look up the context // Look up the context
const context = isNoContext ? null : this.contexts.findOne({ const context = isNoContext ? null : this.contexts!.findOne({
noteId: msg.replyId noteId: msg.replyId
}); });
@ -290,6 +328,8 @@ export default class 藍 {
@bindThis @bindThis
private onNotification(notification: any) { private onNotification(notification: any) {
this.requireReady();
switch (notification.type) { switch (notification.type) {
// リアクションされたら親愛度を少し上げる // リアクションされたら親愛度を少し上げる
// TODO: リアクション取り消しをよしなにハンドリングする // TODO: リアクション取り消しをよしなにハンドリングする
@ -306,12 +346,14 @@ export default class 藍 {
@bindThis @bindThis
private crawleTimer() { private crawleTimer() {
const timers = this.timers.find(); this.requireReady();
const timers = this.timers!.find();
for (const timer of timers) { for (const timer of timers) {
// タイマーが時間切れかどうか // タイマーが時間切れかどうか
if (Date.now() - (timer.insertedAt + timer.delay) >= 0) { if (Date.now() - (timer.insertedAt + timer.delay) >= 0) {
this.log(`Timer expired: ${timer.module} ${timer.id}`); this.log(`Timer expired: ${timer.module} ${timer.id}`);
this.timers.remove(timer); this.timers!.remove(timer);
this.timeoutCallbacks[timer.module](timer.data); this.timeoutCallbacks[timer.module](timer.data);
} }
} }
@ -342,6 +384,8 @@ export default class 藍 {
@bindThis @bindThis
public lookupFriend(userId: User['id']): Friend | null { public lookupFriend(userId: User['id']): Friend | null {
this.requireReady();
const doc = this.friends.findOne({ const doc = this.friends.findOne({
userId: userId userId: userId
}); });
@ -411,7 +455,8 @@ export default class 藍 {
*/ */
@bindThis @bindThis
public subscribeReply(module: Module, key: string | null, id: string, data?: any) { public subscribeReply(module: Module, key: string | null, id: string, data?: any) {
this.contexts.insertOne({ this.requireReady();
this.contexts!.insertOne({
noteId: id, noteId: id,
module: module.name, module: module.name,
key: key, key: key,
@ -426,7 +471,8 @@ export default class 藍 {
*/ */
@bindThis @bindThis
public unsubscribeReply(module: Module, key: string | null) { public unsubscribeReply(module: Module, key: string | null) {
this.contexts.findAndRemove({ this.requireReady();
this.contexts!.findAndRemove({
key: key, key: key,
module: module.name module: module.name
}); });
@ -441,8 +487,10 @@ export default class 藍 {
*/ */
@bindThis @bindThis
public setTimeoutWithPersistence(module: Module, delay: number, data?: any) { public setTimeoutWithPersistence(module: Module, delay: number, data?: any) {
this.requireReady();
const id = uuid(); const id = uuid();
this.timers.insertOne({ this.timers!.insertOne({
id: id, id: id,
module: module.name, module: module.name,
insertedAt: Date.now(), insertedAt: Date.now(),
@ -455,6 +503,10 @@ export default class 藍 {
@bindThis @bindThis
public getMeta() { public getMeta() {
if (this.meta == null) {
throw new TypeError('meta has not been set');
}
const rec = this.meta.findOne(); const rec = this.meta.findOne();
if (rec) { if (rec) {
@ -477,6 +529,11 @@ export default class 藍 {
rec[k] = v; rec[k] = v;
} }
this.meta.update(rec); this.meta!.update(rec);
} }
} }
// FIXME:
// JS にコンパイルされたコードでインターフェイスであるはずの藍がインポートされてしまうので、
// 同名のクラスを定義することで実行時エラーが出ないようにしている
export default class {}

View file

@ -17,13 +17,8 @@ type Config = {
memoryDir?: string; memoryDir?: string;
}; };
import chalk from 'chalk';
import uncheckedConfig from '../config.json' assert { type: 'json' }; import uncheckedConfig from '../config.json' assert { type: 'json' };
import { warn } from '@/utils/log.js'; import log from '@/utils/log.js';
function warnWithPrefix(msg: string): void {
warn(`[Config]: ${chalk.red(msg)}`);
}
class Type<T> { class Type<T> {
public static readonly string = new Type<string>('string'); public static readonly string = new Type<string>('string');
@ -40,44 +35,23 @@ class Type<T> {
} }
} }
class OptionalProperty<K extends keyof Config> { function checkProperty<K extends keyof Config>(config: Object, key: K, type: Type<Config[K]>): config is { [J in K]: Config[K] } {
protected readonly key: K; const result = key in config && type.check(config[key as string]);
protected readonly type: Type<Config[K]> if (!result) {
log(`config.json: Property ${key}: ${type.name} required`);
public constructor(key: K, type: Type<Config[K]>) {
this.key = key;
this.type = type;
} }
return result;
}
check(config: Object): config is { [J in K]?: Config[K] } { function checkOptionalProperty<K extends keyof Config>(config: Object, key: K, type: Type<Config[K]>): config is { [J in K]?: Config[K] } {
const key = this.key;
if (!(key in config)) { if (!(key in config)) {
return true; return true;
} }
const result = this.type.check((config as { [J in K]?: unknown})[key]); const result = type.check(config[key as string]);
if (!result) { if (!result) {
warnWithPrefix(`config.json: The type of property '${key}' must be ${this.type.name}`); log(`config.json: The type of property ${key} must be ${type.name}`);
} }
return result; return result;
}
}
class Property<K extends keyof Config> extends OptionalProperty<K> {
check(config: Object): config is { [J in K]: Config[K] } {
const result = this.key in config && this.type.check((config as { [J in K]?: unknown })[this.key]);
if (!result) {
warnWithPrefix(`config.json: Property '${this.key}': ${this.type.name} required`);
}
return result;
}
}
type Intersection<P extends unknown[]> = P extends [infer Q, ...infer R] ? Q & Intersection<R> : unknown;
function checkProperties<P extends OptionalProperty<keyof Config>[]>(config: Object, ...properties: P):
config is object & Intersection<{ [I in keyof P]: P[I] extends OptionalProperty<infer K> ? { [J in K]: Config[K] } : never }> {
// メッセージを表示するためすべてのプロパティをチェックしてから結果を返す
return properties.map(p => p.check(config)).every(c => c);
} }
function setProperty<K extends keyof Config>(config: Object, key: K, value: Config[K]): asserts config is { [L in K]: Config[K] } { function setProperty<K extends keyof Config>(config: Object, key: K, value: Config[K]): asserts config is { [L in K]: Config[K] } {
@ -86,25 +60,22 @@ function setProperty<K extends keyof Config>(config: Object, key: K, value: Conf
function validate(config: unknown): Config { function validate(config: unknown): Config {
if (!(config instanceof Object)) { if (!(config instanceof Object)) {
warnWithPrefix('config.json: Root object required'); log('config.json: Root object required');
} else if ( } else if (
checkProperties( checkProperty(config, 'host', Type.string) &&
config, checkOptionalProperty(config, 'serverName', Type.string) &&
new Property('host', Type.string), checkProperty(config, 'i', Type.string) &&
new OptionalProperty('serverName', Type.string), checkOptionalProperty(config, 'master', Type.string) &&
new Property('i', Type.string), checkProperty(config, 'keywordEnabled', Type.boolean) &&
new OptionalProperty('master', Type.string), checkProperty(config, 'reversiEnabled', Type.boolean) &&
new Property('keywordEnabled', Type.boolean), checkProperty(config, 'notingEnabled', Type.boolean) &&
new Property('reversiEnabled', Type.boolean), checkProperty(config, 'chartEnabled', Type.boolean) &&
new Property('notingEnabled', Type.boolean), checkProperty(config, 'serverMonitoring', Type.boolean) &&
new Property('chartEnabled', Type.boolean), checkOptionalProperty(config, 'checkEmojisEnabled', Type.boolean) &&
new Property('serverMonitoring', Type.boolean), checkOptionalProperty(config, 'checkEmojisAtOnce', Type.boolean) &&
new OptionalProperty('checkEmojisEnabled', Type.boolean), checkOptionalProperty(config, 'mecab', Type.string) &&
new OptionalProperty('checkEmojisAtOnce', Type.boolean), checkOptionalProperty(config, 'mecabDic', Type.string) &&
new OptionalProperty('mecab', Type.string), checkOptionalProperty(config, 'memoryDir', Type.string)
new OptionalProperty('mecabDic', Type.string),
new OptionalProperty('memoryDir', Type.string)
)
) { ) {
setProperty(config, 'wsUrl', config.host.replace('http', 'ws')); setProperty(config, 'wsUrl', config.host.replace('http', 'ws'));
setProperty(config, 'apiUrl', config.host + '/api'); setProperty(config, 'apiUrl', config.host + '/api');

View file

@ -5,7 +5,7 @@ import chalk from 'chalk';
import got from 'got'; import got from 'got';
import promiseRetry from 'promise-retry'; import promiseRetry from 'promise-retry';
import from './ai.js'; import { Ai } from './ai.js';
import config from './config.js'; import config from './config.js';
import _log from './utils/log.js'; import _log from './utils/log.js';
import pkg from '../package.json' assert { type: 'json' }; import pkg from '../package.json' assert { type: 'json' };
@ -72,7 +72,7 @@ promiseRetry(retry => {
log('Starting AiOS...'); log('Starting AiOS...');
// 藍起動 // 藍起動
.start(account, [ new Ai(account, [
new CoreModule(), new CoreModule(),
new EmojiModule(), new EmojiModule(),
new EmojiReactModule(), new EmojiReactModule(),

View file

@ -1,8 +1,6 @@
export type Note = { export type Note = {
id: string; id: string;
text: string | null; text: string | null;
cw: string | null;
userId: string;
reply: any | null; reply: any | null;
poll?: { poll?: {
choices: { choices: {

View file

@ -5,7 +5,12 @@ import config from '@/config.js';
import serifs from '@/serifs.js'; import serifs from '@/serifs.js';
import { mecab } from './mecab.js'; import { mecab } from './mecab.js';
import from '@/ai.js'; import from '@/ai.js';
import { Note } from '@/misskey/note.js';
type LocalTimeline = {
userId: string;
text: string | null;
cw: string | null;
}[];
function kanaToHira(str: string) { function kanaToHira(str: string) {
return str.replace(/[\u30a1-\u30f6]/g, match => { return str.replace(/[\u30a1-\u30f6]/g, match => {
@ -43,11 +48,11 @@ class Installed extends InstalledModule {
@bindThis @bindThis
private async learn() { private async learn() {
const tl = await this.ai.api<Note[]>('notes/local-timeline', { const tl = await this.ai.api<LocalTimeline>('notes/local-timeline', {
limit: 30 limit: 30
}); });
const interestedNotes = tl.filter((note): note is Note & { text: string } => const interestedNotes = tl.filter(note =>
note.userId !== this.ai.account.id && note.userId !== this.ai.account.id &&
note.text != null && note.text != null &&
note.cw == null); note.cw == null);
@ -55,7 +60,8 @@ class Installed extends InstalledModule {
let keywords: string[][] = []; let keywords: string[][] = [];
for (const note of interestedNotes) { for (const note of interestedNotes) {
const tokens = await mecab(note.text, config.mecab, config.mecabDic); // TODO: note.text に null チェックが必要?
const tokens = await mecab(note.text as string, config.mecab, config.mecabDic);
const keywordsInThisNote = tokens.filter(token => token[2] == '固有名詞' && token[8] != null); const keywordsInThisNote = tokens.filter(token => token[2] == '固有名詞' && token[8] != null);
keywords = keywords.concat(keywordsInThisNote); keywords = keywords.concat(keywordsInThisNote);
} }

View file

@ -1,17 +1,9 @@
import chalk from 'chalk'; import chalk from 'chalk';
export default function(msg: string) { export default function(msg: string) {
console.log(createMessage(msg));
}
export function warn(msg: string) {
console.warn(createMessage(msg));
}
function createMessage(msg: string) {
const now = new Date(); const now = new Date();
const date = `${zeroPad(now.getHours())}:${zeroPad(now.getMinutes())}:${zeroPad(now.getSeconds())}`; const date = `${zeroPad(now.getHours())}:${zeroPad(now.getMinutes())}:${zeroPad(now.getSeconds())}`;
return `${chalk.gray(date)} ${msg}`; console.log(`${chalk.gray(date)} ${msg}`);
} }
function zeroPad(num: number, length: number = 2): string { function zeroPad(num: number, length: number = 2): string {