mirror of
https://github.com/syuilo/ai.git
synced 2024-11-12 17:08:00 +00:00
InstalledModule抽象クラス作成
This commit is contained in:
parent
1c842a4b95
commit
c485a5b1ed
12
src/ai.ts
12
src/ai.ts
|
@ -9,7 +9,7 @@ import chalk from 'chalk';
|
|||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import config from '@/config.js';
|
||||
import Module from '@/module.js';
|
||||
import Module, { InstalledModule } from '@/module.js';
|
||||
import Message from '@/message.js';
|
||||
import Friend, { FriendDoc } from '@/friend.js';
|
||||
import type { User } from '@/misskey/user.js';
|
||||
|
@ -38,6 +38,11 @@ export type Meta = {
|
|||
lastWakingAt: number;
|
||||
};
|
||||
|
||||
export type ModuleDataDoc<Data = any> = {
|
||||
module: string;
|
||||
data: Data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 藍
|
||||
*/
|
||||
|
@ -46,7 +51,7 @@ export default interface 藍 extends Ai {
|
|||
lastSleepedAt: number;
|
||||
|
||||
friends: loki.Collection<FriendDoc>;
|
||||
moduleData: loki.Collection<any>;
|
||||
moduleData: loki.Collection<ModuleDataDoc>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -60,6 +65,7 @@ export class Ai {
|
|||
private mentionHooks: MentionHook[] = [];
|
||||
private contextHooks: { [moduleName: string]: ContextHook } = {};
|
||||
private timeoutCallbacks: { [moduleName: string]: TimeoutCallback } = {};
|
||||
public installedModules: { [moduleName: string]: InstalledModule } = {};
|
||||
public db: loki;
|
||||
public lastSleepedAt?: number;
|
||||
|
||||
|
@ -204,7 +210,7 @@ export class Ai {
|
|||
this.modules.forEach(m => {
|
||||
this.log(`Installing ${chalk.cyan.italic(m.name)}\tmodule...`);
|
||||
m.init(this);
|
||||
const res = m.install();
|
||||
const res = m.install(this);
|
||||
if (res != null) {
|
||||
if (res.mentionHook) this.mentionHooks.push(res.mentionHook);
|
||||
if (res.contextHook) this.contextHooks[m.name] = res.contextHook;
|
||||
|
|
144
src/module.ts
144
src/module.ts
|
@ -1,29 +1,26 @@
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import 藍, { InstallerResult } from '@/ai.js';
|
||||
import 藍, { HandlerResult, InstallerResult, ModuleDataDoc } from '@/ai.js';
|
||||
import Message from '@/message.js';
|
||||
|
||||
export default abstract class Module {
|
||||
public abstract readonly name: string;
|
||||
|
||||
private maybeAi?: 藍;
|
||||
private doc: any;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public installed?: InstalledModule;
|
||||
|
||||
public init(ai: 藍) {
|
||||
this.maybeAi = ai;
|
||||
|
||||
this.doc = this.ai.moduleData.findOne({
|
||||
module: this.name
|
||||
});
|
||||
|
||||
if (this.doc == null) {
|
||||
this.doc = this.ai.moduleData.insertOne({
|
||||
module: this.name,
|
||||
data: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public abstract install(): InstallerResult;
|
||||
public abstract install(ai: 藍): InstallerResult;
|
||||
|
||||
/**
|
||||
* @deprecated {@link Module#install} の引数を使用すること
|
||||
*/
|
||||
protected get ai(): 藍 {
|
||||
if (this.maybeAi == null) {
|
||||
throw new TypeError('This module has not been initialized');
|
||||
|
@ -31,6 +28,9 @@ export default abstract class Module {
|
|||
return this.maybeAi;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated {@link InstalledModule#log} を使用すること
|
||||
*/
|
||||
@bindThis
|
||||
protected log(msg: string) {
|
||||
this.ai.log(`[${this.name}]: ${msg}`);
|
||||
|
@ -41,6 +41,7 @@ export default abstract class Module {
|
|||
* @param key コンテキストを識別するためのキー
|
||||
* @param id トークメッセージ上のコンテキストならばトーク相手のID、そうでないなら待ち受ける投稿のID
|
||||
* @param data コンテキストに保存するオプションのデータ
|
||||
* @deprecated {@link InstalledModule#subscribeReply} を使用すること
|
||||
*/
|
||||
@bindThis
|
||||
protected subscribeReply(key: string | null, id: string, data?: any) {
|
||||
|
@ -50,6 +51,7 @@ export default abstract class Module {
|
|||
/**
|
||||
* 返信の待ち受けを解除します
|
||||
* @param key コンテキストを識別するためのキー
|
||||
* @deprecated {@link InstalledModule#unsubscribeReply} を使用すること
|
||||
*/
|
||||
@bindThis
|
||||
protected unsubscribeReply(key: string | null) {
|
||||
|
@ -61,20 +63,132 @@ export default abstract class Module {
|
|||
* このタイマーは記憶に永続化されるので、途中でプロセスを再起動しても有効です。
|
||||
* @param delay ミリ秒
|
||||
* @param data オプションのデータ
|
||||
* @deprecated {@link InstalledModule#setTimeoutWithPersistence} を使用すること
|
||||
*/
|
||||
@bindThis
|
||||
public setTimeoutWithPersistence(delay: number, data?: any) {
|
||||
this.ai.setTimeoutWithPersistence(this, delay, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated {@link InstalledModule#getData} を使用すること
|
||||
*/
|
||||
@bindThis
|
||||
protected getData() {
|
||||
const doc = this.ai.moduleData.findOne({
|
||||
module: this.name
|
||||
});
|
||||
return doc?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated {@link InstalledModule#setData} を使用すること
|
||||
*/
|
||||
@bindThis
|
||||
protected setData(data: any) {
|
||||
const doc = this.ai.moduleData.findOne({
|
||||
module: this.name
|
||||
});
|
||||
if (doc == null) {
|
||||
return;
|
||||
}
|
||||
doc.data = data;
|
||||
this.ai.moduleData.update(doc);
|
||||
if (this.installed != null) {
|
||||
this.installed.updateDoc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class InstalledModule<M extends Module = Module, Data = any> implements InstallerResult {
|
||||
protected readonly module: M;
|
||||
|
||||
protected readonly ai: 藍;
|
||||
|
||||
private doc: ModuleDataDoc<Data>;
|
||||
|
||||
constructor(module: M, ai: 藍, initialData: any = {}) {
|
||||
this.module = module;
|
||||
this.ai = ai;
|
||||
|
||||
const doc = this.ai.moduleData.findOne({
|
||||
module: module.name
|
||||
});
|
||||
|
||||
if (doc == null) {
|
||||
this.doc = this.ai.moduleData.insertOne({
|
||||
module: module.name,
|
||||
data: initialData
|
||||
}) as ModuleDataDoc<Data>;
|
||||
} else {
|
||||
this.doc = doc;
|
||||
}
|
||||
|
||||
ai.installedModules[module.name] = this;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
protected log(msg: string) {
|
||||
this.ai.log(`[${this.module.name}]: ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* コンテキストを生成し、ユーザーからの返信を待ち受けます
|
||||
* @param key コンテキストを識別するためのキー
|
||||
* @param id トークメッセージ上のコンテキストならばトーク相手のID、そうでないなら待ち受ける投稿のID
|
||||
* @param data コンテキストに保存するオプションのデータ
|
||||
*/
|
||||
@bindThis
|
||||
protected subscribeReply(key: string | null, id: string, data?: any) {
|
||||
this.ai.subscribeReply(this.module, key, id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返信の待ち受けを解除します
|
||||
* @param key コンテキストを識別するためのキー
|
||||
*/
|
||||
@bindThis
|
||||
protected unsubscribeReply(key: string | null) {
|
||||
this.ai.unsubscribeReply(this.module, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定したミリ秒経過後に、タイムアウトコールバックを呼び出します。
|
||||
* このタイマーは記憶に永続化されるので、途中でプロセスを再起動しても有効です。
|
||||
* @param delay ミリ秒
|
||||
* @param data オプションのデータ
|
||||
*/
|
||||
@bindThis
|
||||
public setTimeoutWithPersistence(delay: number, data?: any) {
|
||||
this.ai.setTimeoutWithPersistence(this.module, delay, data);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
protected getData(): Data {
|
||||
return this.doc.data;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
protected setData(data: any) {
|
||||
protected setData(data: Data) {
|
||||
this.doc.data = data;
|
||||
this.ai.moduleData.update(this.doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public updateDoc() {
|
||||
const doc = this.ai.moduleData.findOne({
|
||||
module: this.module.name
|
||||
});
|
||||
if (doc != null) {
|
||||
this.doc = doc;
|
||||
}
|
||||
}
|
||||
|
||||
mentionHook?(msg: Message): Promise<boolean | HandlerResult>;
|
||||
|
||||
contextHook?(key: any, msg: Message, data?: any): Promise<void | boolean | HandlerResult>;
|
||||
|
||||
timeoutCallback?(data?: any): void;
|
||||
}
|
||||
|
|
|
@ -1,31 +1,35 @@
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import loki from 'lokijs';
|
||||
import Module from '@/module.js';
|
||||
import Module, { InstalledModule } from '@/module.js';
|
||||
import serifs from '@/serifs.js';
|
||||
import config from '@/config.js';
|
||||
import Message from '@/message.js';
|
||||
import 藍 from '@/ai.js';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'checkCustomEmojis';
|
||||
|
||||
@bindThis
|
||||
public install(ai: 藍) {
|
||||
if (!config.checkEmojisEnabled) return {};
|
||||
return new Installed(this, ai);
|
||||
}
|
||||
}
|
||||
|
||||
class Installed extends InstalledModule {
|
||||
private lastEmoji: loki.Collection<{
|
||||
id: string;
|
||||
updatedAt: number;
|
||||
}>;
|
||||
|
||||
@bindThis
|
||||
public install() {
|
||||
if (!config.checkEmojisEnabled) return {};
|
||||
constructor(module: Module, ai: 藍) {
|
||||
super(module, ai);
|
||||
this.lastEmoji = this.ai.getCollection('lastEmoji', {
|
||||
indices: ['id']
|
||||
});
|
||||
|
||||
this.timeCheck();
|
||||
setInterval(this.timeCheck, 1000 * 60 * 3);
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -141,7 +145,7 @@ export default class extends Module {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async mentionHook(msg: Message) {
|
||||
public async mentionHook(msg: Message) {
|
||||
if (!msg.includes(['カスタムえもじチェック','カスタムえもじを調べて','カスタムえもじを確認'])) {
|
||||
return false;
|
||||
} else {
|
||||
|
|
|
@ -3,19 +3,16 @@ import { parse } from 'twemoji-parser';
|
|||
|
||||
import type { Note } from '@/misskey/note.js';
|
||||
import Module from '@/module.js';
|
||||
import Stream from '@/stream.js';
|
||||
import includes from '@/utils/includes.js';
|
||||
import { sleep } from '@/utils/sleep.js';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'emoji-react';
|
||||
|
||||
private htl: ReturnType<Stream['useSharedConnection']>;
|
||||
|
||||
@bindThis
|
||||
public install() {
|
||||
this.htl = this.ai.connection.useSharedConnection('homeTimeline');
|
||||
this.htl.on('note', this.onNote);
|
||||
const htl = this.ai.connection.useSharedConnection('homeTimeline');
|
||||
htl.on('note', this.onNote);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -1,35 +1,42 @@
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import loki from 'lokijs';
|
||||
import Module from '@/module.js';
|
||||
import Module, { InstalledModule } from '@/module.js';
|
||||
import Message from '@/message.js';
|
||||
import serifs from '@/serifs.js';
|
||||
import 藍, { InstallerResult } from '@/ai.js';
|
||||
|
||||
type Guesses = loki.Collection<{
|
||||
userId: string;
|
||||
secret: number;
|
||||
tries: number[];
|
||||
isEnded: boolean;
|
||||
startedAt: number;
|
||||
endedAt: number | null;
|
||||
}>
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'guessingGame';
|
||||
|
||||
private guesses: loki.Collection<{
|
||||
userId: string;
|
||||
secret: number;
|
||||
tries: number[];
|
||||
isEnded: boolean;
|
||||
startedAt: number;
|
||||
endedAt: number | null;
|
||||
}>;
|
||||
|
||||
@bindThis
|
||||
public install() {
|
||||
this.guesses = this.ai.getCollection('guessingGame', {
|
||||
public install(ai: 藍) {
|
||||
const guesses = ai.getCollection('guessingGame', {
|
||||
indices: ['userId']
|
||||
});
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook,
|
||||
contextHook: this.contextHook
|
||||
};
|
||||
return new Installed(this, ai, guesses);
|
||||
}
|
||||
}
|
||||
|
||||
class Installed extends InstalledModule implements InstallerResult {
|
||||
private guesses: Guesses;
|
||||
|
||||
constructor(module: Module, ai: 藍, guesses: Guesses) {
|
||||
super(module, ai);
|
||||
this.guesses = guesses;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async mentionHook(msg: Message) {
|
||||
public async mentionHook(msg: Message) {
|
||||
if (!msg.includes(['数当て', '数あて'])) return false;
|
||||
|
||||
const exist = this.guesses.findOne({
|
||||
|
@ -56,7 +63,7 @@ export default class extends Module {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async contextHook(key: any, msg: Message) {
|
||||
public async contextHook(key: any, msg: Message) {
|
||||
if (msg.text == null) return;
|
||||
|
||||
const exist = this.guesses.findOne({
|
||||
|
@ -114,14 +121,14 @@ export default class extends Module {
|
|||
if (end) {
|
||||
exist.isEnded = true;
|
||||
exist.endedAt = Date.now();
|
||||
this.unsubscribeReply(key);
|
||||
this.ai.unsubscribeReply(this.module, key);
|
||||
}
|
||||
|
||||
this.guesses.update(exist);
|
||||
|
||||
msg.reply(text).then(reply => {
|
||||
if (!end) {
|
||||
this.subscribeReply(msg.userId, reply.id);
|
||||
this.ai.subscribeReply(this.module, msg.userId, reply.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import loki from 'lokijs';
|
||||
import Module from '@/module.js';
|
||||
import Module, { InstalledModule } from '@/module.js';
|
||||
import Message from '@/message.js';
|
||||
import serifs from '@/serifs.js';
|
||||
import type { User } from '@/misskey/user.js';
|
||||
import { acct } from '@/utils/acct.js';
|
||||
import 藍, { InstallerResult } from '@/ai.js';
|
||||
|
||||
type Game = {
|
||||
votes: {
|
||||
|
@ -25,23 +26,28 @@ const limitMinutes = 10;
|
|||
export default class extends Module {
|
||||
public readonly name = 'kazutori';
|
||||
|
||||
@bindThis
|
||||
public install(ai: 藍) {
|
||||
return new Installed(this, ai);
|
||||
}
|
||||
}
|
||||
|
||||
class Installed extends InstalledModule {
|
||||
|
||||
private games: loki.Collection<Game>;
|
||||
|
||||
@bindThis
|
||||
public install() {
|
||||
constructor(module: Module, ai: 藍) {
|
||||
super(module, ai);
|
||||
this.games = this.ai.getCollection('kazutori');
|
||||
|
||||
this.crawleGameEnd();
|
||||
setInterval(this.crawleGameEnd, 1000);
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook,
|
||||
contextHook: this.contextHook
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async mentionHook(msg: Message) {
|
||||
public async mentionHook(msg: Message) {
|
||||
if (!msg.includes(['数取り'])) return false;
|
||||
|
||||
const games = this.games.find({});
|
||||
|
@ -83,7 +89,7 @@ export default class extends Module {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async contextHook(key: any, msg: Message) {
|
||||
public async contextHook(key: any, msg: Message) {
|
||||
if (msg.text == null) return {
|
||||
reaction: 'hmm'
|
||||
};
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import loki from 'lokijs';
|
||||
import Module from '@/module.js';
|
||||
import Module, { InstalledModule } from '@/module.js';
|
||||
import config from '@/config.js';
|
||||
import serifs from '@/serifs.js';
|
||||
import { mecab } from './mecab.js';
|
||||
import 藍 from '@/ai.js';
|
||||
|
||||
type LocalTimeline = {
|
||||
userId: string;
|
||||
|
@ -21,22 +22,28 @@ function kanaToHira(str: string) {
|
|||
export default class extends Module {
|
||||
public readonly name = 'keyword';
|
||||
|
||||
@bindThis
|
||||
public install(ai: 藍) {
|
||||
if (config.keywordEnabled) {
|
||||
new Installed(this, ai);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
class Installed extends InstalledModule {
|
||||
private learnedKeywords: loki.Collection<{
|
||||
keyword: string;
|
||||
learnedAt: number;
|
||||
}>;
|
||||
|
||||
@bindThis
|
||||
public install() {
|
||||
if (!config.keywordEnabled) return {};
|
||||
|
||||
constructor(module: Module, ai: 藍) {
|
||||
super(module, ai);
|
||||
this.learnedKeywords = this.ai.getCollection('_keyword_learnedKeywords', {
|
||||
indices: ['userId']
|
||||
});
|
||||
|
||||
setInterval(this.learn, 1000 * 60 * 60);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -1,16 +1,24 @@
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import loki from 'lokijs';
|
||||
import Module from '@/module.js';
|
||||
import Module, { InstalledModule } from '@/module.js';
|
||||
import Message from '@/message.js';
|
||||
import serifs, { getSerif } from '@/serifs.js';
|
||||
import { acct } from '@/utils/acct.js';
|
||||
import config from '@/config.js';
|
||||
import 藍 from '@/ai.js';
|
||||
|
||||
const NOTIFY_INTERVAL = 1000 * 60 * 60 * 12;
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'reminder';
|
||||
|
||||
@bindThis
|
||||
public install(ai: 藍) {
|
||||
return new Installed(this, ai);
|
||||
}
|
||||
}
|
||||
|
||||
class Installed extends InstalledModule {
|
||||
private reminds: loki.Collection<{
|
||||
userId: string;
|
||||
id: string;
|
||||
|
@ -20,21 +28,15 @@ export default class extends Module {
|
|||
createdAt: number;
|
||||
}>;
|
||||
|
||||
@bindThis
|
||||
public install() {
|
||||
constructor(module: Module, ai: 藍) {
|
||||
super(module, ai);
|
||||
this.reminds = this.ai.getCollection('reminds', {
|
||||
indices: ['userId', 'id']
|
||||
});
|
||||
|
||||
return {
|
||||
mentionHook: this.mentionHook,
|
||||
contextHook: this.contextHook,
|
||||
timeoutCallback: this.timeoutCallback,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async mentionHook(msg: Message) {
|
||||
public async mentionHook(msg: Message) {
|
||||
let text = msg.extractedText.toLowerCase();
|
||||
if (!text.startsWith('remind') && !text.startsWith('todo')) return false;
|
||||
|
||||
|
@ -99,7 +101,7 @@ export default class extends Module {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async contextHook(key: any, msg: Message, data: any) {
|
||||
public async contextHook(key: any, msg: Message, data: any) {
|
||||
if (msg.text == null) return;
|
||||
|
||||
const remind = this.reminds.findOne({
|
||||
|
@ -129,7 +131,7 @@ export default class extends Module {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async timeoutCallback(data) {
|
||||
public async timeoutCallback(data) {
|
||||
const remind = this.reminds.findOne({
|
||||
id: data.id
|
||||
});
|
||||
|
@ -147,7 +149,7 @@ export default class extends Module {
|
|||
renoteId: remind.thing == null && remind.quoteId ? remind.quoteId : remind.id,
|
||||
text: acct(friend.doc.user) + ' ' + serifs.reminder.notify(friend.name)
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
// renote対象が消されていたらリマインダー解除
|
||||
if (err.statusCode === 400) {
|
||||
this.unsubscribeReply(remind.thing == null && remind.quoteId ? remind.quoteId : remind.id);
|
||||
|
|
|
@ -1,24 +1,34 @@
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import Module from '@/module.js';
|
||||
import Module, { InstalledModule } from '@/module.js';
|
||||
import serifs from '@/serifs.js';
|
||||
import config from '@/config.js';
|
||||
import 藍 from '@/ai.js';
|
||||
|
||||
export default class extends Module {
|
||||
public readonly name = 'server';
|
||||
|
||||
@bindThis
|
||||
public install(ai: 藍) {
|
||||
if (config.serverMonitoring) {
|
||||
new Installed(this, ai);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
class Installed extends InstalledModule {
|
||||
private connection?: any;
|
||||
private recentStat: any;
|
||||
private warned = false;
|
||||
private lastWarnedAt: number;
|
||||
private lastWarnedAt?: number;
|
||||
|
||||
/**
|
||||
* 1秒毎のログ1分間分
|
||||
*/
|
||||
private statsLogs: any[] = [];
|
||||
|
||||
@bindThis
|
||||
public install() {
|
||||
if (!config.serverMonitoring) return {};
|
||||
constructor(module: Module, ai: 藍) {
|
||||
super(module, ai);
|
||||
|
||||
this.connection = this.ai.connection.useSharedConnection('serverStats');
|
||||
this.connection.on('stats', this.onStats);
|
||||
|
@ -31,8 +41,6 @@ export default class extends Module {
|
|||
setInterval(() => {
|
||||
this.check();
|
||||
}, 3000);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
Loading…
Reference in a new issue