From 839438bc5d151aeacdc181f76740f6ff7554c6fc Mon Sep 17 00:00:00 2001 From: Hotaru Date: Tue, 6 Aug 2024 21:26:39 +0800 Subject: [PATCH] feat: webui --- TODO.md | 4 +- modules/adapter-cmd/src/adapter.ts | 13 +- modules/adapter-discord/README.md | 2 +- modules/adapter-mail/README.md | 11 +- modules/adapter-mail/locales/ja_JP.json | 16 + modules/adapter-mail/locales/zh_CN.json | 16 + modules/adapter-mail/src/adapter.ts | 135 +++--- modules/adapter-qq/README.md | 19 + modules/adapter-slack/README.md | 2 +- modules/adapter-telegram/README.md | 2 +- modules/core/locales/en_US.json | 2 +- modules/core/locales/zh_CN.json | 2 +- modules/core/src/index.ts | 12 +- modules/filter/src/index.ts | 26 +- modules/requester/src/index.ts | 6 +- modules/testing/src/index.ts | 12 +- .../{src/routers/api => assets}/avatar.svg | 0 modules/webui/package.json | 21 + modules/webui/package1.json | 37 -- modules/webui/src/index.ts | 57 +-- modules/webui/src/plugin/index.ts | 9 +- modules/webui/src/routers/api/accounts.ts | 38 +- modules/webui/src/routers/api/config.ts | 113 ++--- modules/webui/src/routers/api/data.ts | 144 +----- modules/webui/src/routers/api/demo.ts | 10 +- modules/webui/src/routers/index.ts | 40 +- modules/webui/src/routers/router.ts | 16 +- modules/webui/src/service/index.ts | 456 +++++++++++++----- modules/webui/src/types/index.ts | 41 +- modules/webui/src/utils/autoSave.ts | 24 + modules/webui/src/utils/common.ts | 69 +-- modules/webui/src/utils/observer.ts | 15 + modules/webui/src/utils/transport.ts | 11 +- modules/webui/src/ws/index.ts | 14 +- modules/webui/tsconfig.json | 3 + packages/core/src/app/config.ts | 5 +- packages/core/src/types/filter.ts | 24 + packages/core/src/utils/factory.ts | 1 + packages/loader/src/loader/loader.ts | 61 +-- packages/loader/src/service/database.ts | 68 ++- packages/tools/src/common/function.ts | 4 + pnpm-lock.yaml | 12 + 42 files changed, 898 insertions(+), 675 deletions(-) create mode 100644 modules/adapter-mail/locales/ja_JP.json create mode 100644 modules/adapter-mail/locales/zh_CN.json rename modules/webui/{src/routers/api => assets}/avatar.svg (100%) create mode 100644 modules/webui/package.json delete mode 100644 modules/webui/package1.json create mode 100644 modules/webui/src/utils/autoSave.ts create mode 100644 modules/webui/src/utils/observer.ts diff --git a/TODO.md b/TODO.md index 69fe0632..c77a83d5 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,7 @@ - [x] @kotori-bot/webui - [x] @kotori-bot/kotori-plugin-webui - [x] @kotori-bot/kotori-plugin-adapter-sandbox -- [X] @kotori-bot/kotori-plugin-adapter-mail (adapter and plugin) +- [x] @kotori-bot/kotori-plugin-adapter-mail (adapter and plugin) - [x] @kotori-bot/kotori-plugin-adapter-telegram - [x] @kotori-bot/kotori-plugin-adapter-discord - [x] kotori-plugin-adapter-minecraft @@ -43,5 +43,5 @@ - [x] Module Version with core tips - [x] Kotori cli start - [x] kotori-plugin-request: onGroupMsg and onPrivateMsg -- [ ] webui load tips twice +- [x] webui load tips twice - [x] symbols props inject and reality context diff --git a/modules/adapter-cmd/src/adapter.ts b/modules/adapter-cmd/src/adapter.ts index 78368616..c821f349 100644 --- a/modules/adapter-cmd/src/adapter.ts +++ b/modules/adapter-cmd/src/adapter.ts @@ -3,12 +3,17 @@ * @Blog: https://hotaru.icu * @Date: 2023-09-29 14:31:09 * @LastEditors: Hotaru biyuehuya@gmail.com - * @LastEditTime: 2024-08-04 12:08:31 + * @LastEditTime: 2024-08-06 11:12:35 */ -import { Adapter, type AdapterConfig, type Context, MessageScope, Tsu } from 'kotori-bot' +import { Adapter, type AdapterConfig, type Context, MessageScope, Tsu, type LoggerData } from 'kotori-bot' import CmdApi from './api' import CmdElements from './elements' +declare module 'kotori-bot' { + interface EventsMapping { + console_output(data: LoggerData | { msg: string }): void + } +} export const config = Tsu.Object({ nickname: Tsu.String().default('Kotarou').describe("User's nickname"), 'self-nickname': Tsu.String().default('KotoriO').describe("Bot's nickname"), @@ -79,7 +84,9 @@ export class CmdAdapter extends Adapter { if (this.status.value !== 'online' || action !== 'send_private_msg' || !params) return if (typeof (params as { message: string }).message !== 'string') return if ((params as { user_id: unknown }).user_id !== this.config.master) return - process.stdout.write(`${this.config['self-nickname']} > ${(params as { message: string }).message} \r\n`) + const msg = `${this.config['self-nickname']} > ${(params as { message: string }).message} \r\n` + process.stdout.write(msg) + this.ctx.emit('console_output', { msg }) this.messageId += 1 } } diff --git a/modules/adapter-discord/README.md b/modules/adapter-discord/README.md index 8950a39e..88abfafd 100644 --- a/modules/adapter-discord/README.md +++ b/modules/adapter-discord/README.md @@ -25,7 +25,7 @@ export const config = Tsu.Object({ - text -## Todo +## TODO Support more standard api... diff --git a/modules/adapter-mail/README.md b/modules/adapter-mail/README.md index 0d606830..557fa1bb 100644 --- a/modules/adapter-mail/README.md +++ b/modules/adapter-mail/README.md @@ -7,11 +7,18 @@ Supports for email. Such as `Google Mail`, `QQ Mail`, `163 Mail` and more... ```typescript export const config = Tsu.Object({ title: Tsu.String().domain().default('Love from kotori bot mailer').describe('Mail default title'), + commandEnable: Tsu.Boolean() + .default(true) + .describe("Whether to enable command, other bot's master can send mail by the command, please set at top mail bot"), + forward: Tsu.Array(Tsu.String()) + .default([]) + .describe("bots' identity, will forward to the bot's master on receiving mail, please set at top mail bot"), user: Tsu.String().describe('Email address'), + interval: Tsu.Number().default(60).describe('Check mail interval (seconds)'), password: Tsu.String().describe('Email password'), - imapHost: Tsu.String().domain().describe('IMAP server host'), + imapHost: Tsu.String().describe('IMAP server host'), imapPort: Tsu.Number().describe('IMAP server port'), - smtpHost: Tsu.String().domain().describe('SMTP server host'), + smtpHost: Tsu.String().describe('SMTP server host'), smtpPort: Tsu.Number().describe('SMTP server port') }) ``` diff --git a/modules/adapter-mail/locales/ja_JP.json b/modules/adapter-mail/locales/ja_JP.json new file mode 100644 index 00000000..107ea25a --- /dev/null +++ b/modules/adapter-mail/locales/ja_JP.json @@ -0,0 +1,16 @@ +{ + "adapter_mail.descr.mail": "指定メールアドレスにメールを送信", + "adapter_mail.msg.mail.input_target": "受信メールアドレスを入力してください", + "adapter_mail.msg.mail.input_title": "メールの件名を入力してください", + "adapter_mail.msg.mail.input_content": "メールの内容を入力してください", + "adapter_mail.msg.mail.sure": "件名:{0}\n内容:{1}\n-------------\n{2}にメールを送信しますか?(「1」で送信、他のキーでキャンセル)", + "adapter_mail.msg.mail.cancel": "送信をキャンセルしました", + "adapter_mail.msg.mail.success": "{0}へのメール送信に成功しました", + "adapter_mail.msg.mail.fail": "メール送信に失敗しました。メール設定を確認してください。詳細:{0}", + "adapter_mail.msg.mail.fail.2": "送信メール{0}が見つかりません。「{0}mail --list」で利用可能なメールリストを表示できます", + "adapter_mail.msg.mail.fail.3": "送信失敗。宛先メールアドレスの形式が正しくありません!", + "adapter_mail.msg.mail.item": "\n<=========>\n名前:{0}\nメール:{1}", + "adapter_mail.msg.mail.list": "利用可能なメールリスト:{0}", + "adapter_mail.option.mail.list": "利用可能なメールリストを表示", + "adapter_mail.forward": "新着メールがあります!\n差出人:{0}\n件名:{1}\n内容:{2}\n日付:{3}" +} diff --git a/modules/adapter-mail/locales/zh_CN.json b/modules/adapter-mail/locales/zh_CN.json new file mode 100644 index 00000000..6954c66f --- /dev/null +++ b/modules/adapter-mail/locales/zh_CN.json @@ -0,0 +1,16 @@ +{ + "adapter_mail.descr.mail": "通过目标邮箱发送邮件", + "adapter_mail.msg.mail.input_target": "请输入接收邮箱", + "adapter_mail.msg.mail.input_title": "请输入邮件标题", + "adapter_mail.msg.mail.input_content": "请输入邮件内容", + "adapter_mail.msg.mail.sure": "标题:{0}\n内容:{1}\n-------------\n确定发送邮件至{2}?(输入\"1\"发送,其他键取消)", + "adapter_mail.msg.mail.cancel": "成功取消发送", + "adapter_mail.msg.mail.success": "成功发送邮件至{0}", + "adapter_mail.msg.mail.fail": "发送邮件失败,请检查邮件配置,详情:{0}", + "adapter_mail.msg.mail.fail.2": "找不到发送邮箱{0},您可以输入\"{0}mail --list\"查看可用邮箱列表", + "adapter_mail.msg.mail.fail.3": "发送失败,目标邮箱格式错误!", + "adapter_mail.msg.mail.item": "\n<=========>\n名称:{0}\n邮箱:{1}", + "adapter_mail.msg.mail.list": "可用邮箱列表:{0}", + "adapter_mail.option.mail.list": "查看可用邮箱列表", + "adapter_mail.forward": "收到新邮件!\n来自:{0}\n标题:{1}\n内容:{2}\n日期:{3}" +} diff --git a/modules/adapter-mail/src/adapter.ts b/modules/adapter-mail/src/adapter.ts index 3de30896..8dac36c9 100644 --- a/modules/adapter-mail/src/adapter.ts +++ b/modules/adapter-mail/src/adapter.ts @@ -6,7 +6,9 @@ import { KotoriError, Adapter, Symbols, - formatFactory + formatFactory, + sleep, + type Api } from 'kotori-bot' import MailApi from './api' import MailElements from './elements' @@ -23,6 +25,7 @@ export const config = Tsu.Object({ .default([]) .describe("bots' identity, will forward to the bot's master on receiving mail, please set at top mail bot"), user: Tsu.String().describe('Email address'), + interval: Tsu.Number().default(60).describe('Check mail interval (seconds)'), password: Tsu.String().describe('Email password'), imapHost: Tsu.String().describe('IMAP server host'), imapPort: Tsu.Number().describe('IMAP server port'), @@ -35,6 +38,8 @@ type MailConfig = Tsu.infer & AdapterConfig let isLoaded = false export class MailAdapter extends Adapter { + private timerId?: NodeJS.Timeout + public readonly config: MailConfig public readonly elements: MailElements = new MailElements(this) @@ -60,6 +65,7 @@ export class MailAdapter extends Adapter { } }) // Loading plugin + this.ctx.inject('db') if (isLoaded) return this.ctx.load({ ...require('./plugin'), @@ -68,7 +74,69 @@ export class MailAdapter extends Adapter { isLoaded = true } - public handle() {} + public handle() { + const apis: Api[] = [] + for (const bots of this.ctx[Symbols.bot].values()) apis.push(...bots) + + this.imapConnection?.openBox('INBOX').then(async () => { + const fetchOptions = { + bodies: ['HEADER', 'TEXT'], + markSeen: false + } + + const list = await this.ctx.db.get('read', []) + const results = ((await this.imapConnection?.search(['UNSEEN'], fetchOptions)) ?? []).filter( + (item) => !list.includes(item.attributes.uid) + ) + + if (results.length === 0) return + this.ctx.logger.record(/* html */ `${results.length} new email(s) received`) + for await (const item of results) { + const all = item.parts.filter((part) => part.which === 'TEXT') + const id = item.attributes.uid + list.push(id) + + const idHeader = `Imap-Id: ${id}\r\n` + for await (const part of all) { + const email = await simpleParser(idHeader + part.body) + if (!email.text) continue + + for (const identity of this.config.forward) { + const api = apis.find((api) => api.adapter.identity === identity) + if (!api) { + this.ctx.emit( + 'error', + KotoriError.from(`Failed to forward mail, cant not find ${identity} bot`, this.ctx.identity?.toString()) + ) + continue + } + api.sendPrivateMsg( + formatFactory(api.adapter.ctx.i18n)('adapter_mail.forward', [ + email.from?.text, + email.subject, + email.text, + email.date ? api.adapter.ctx.i18n.date(email.date) : '' + ]), + api.adapter.config.master + ) + } + this.session('on_message', { + type: MessageScope.PRIVATE, + userId: email.from?.text ?? '', + message: email.text ?? email.subject ?? '', + messageAlt: email.text ?? email.subject ?? '', + messageId: id.toString(), + sender: { + nickname: email.from?.text || '' + }, + time: email.date?.getTime() || Date.now() + }) + } + } + + await this.ctx.db.put('read', list) + }) + } public async start() { try { @@ -81,66 +149,6 @@ export class MailAdapter extends Adapter { tls: true } }) - - await this.imapConnection.openBox('INBOX') - - this.imapConnection.on('mail', async (numNewMails: number) => { - console.log(`${numNewMails} new email(s) received`) - - const fetchOptions = { - bodies: ['HEADER', 'TEXT'], - markSeen: false - } - - const results = (await this.imapConnection?.search(['UNSEEN'], fetchOptions)) ?? [] - - for (const item of results) { - const all = item.parts.filter((part) => part.which === 'TEXT') - const id = item.attributes.uid - const idHeader = `Imap-Id: ${id}\r\n` - - for (const part of all) { - const email = await simpleParser(idHeader + part.body) - - for (const identity of this.config.forward) { - const api = Array.from(this.ctx[Symbols.bot].values()).map((bots) => - Array.from(bots.values()).find((bot) => bot.adapter.identity === identity) - )[0] - if (!api) { - this.ctx.emit( - 'error', - KotoriError.from( - `Failed to forward mail, cant not find ${identity} bot`, - this.ctx.identity?.toString() - ) - ) - continue - } - api.sendPrivateMsg( - formatFactory(api.adapter.ctx.i18n)('adapter_mail.forward', [ - email.from?.text, - email.subject, - email.text, - email.date ? api.adapter.ctx.i18n.date(email.date) : '' - ]), - api.adapter.config.master - ) - } - this.session('on_message', { - type: MessageScope.PRIVATE, - userId: email.from?.text ?? '', - message: email.text ?? email.subject ?? '', - messageAlt: email.text ?? email.subject ?? '', - messageId: id.toString(), - sender: { - nickname: email.from?.text || '' - }, - time: email.date?.getTime() || Date.now() - }) - } - } - }) - this.ctx.emit('connect', { type: 'connect', mode: 'other', @@ -148,6 +156,8 @@ export class MailAdapter extends Adapter { normal: true, address: `imap://${this.config.imapHost}:${this.config.imapPort}` }) + await sleep(10 * 1000) + this.timerId = setInterval(() => this.handle(), this.config.interval * 1000) } catch (error) { this.ctx.emit('error', KotoriError.from(error, this.ctx.identity?.toString())) } @@ -155,6 +165,7 @@ export class MailAdapter extends Adapter { public stop() { this.imapConnection?.end() + clearInterval(this.timerId) this.ctx.emit('connect', { type: 'disconnect', mode: 'other', diff --git a/modules/adapter-qq/README.md b/modules/adapter-qq/README.md index d2d2498d..8c3afd0a 100644 --- a/modules/adapter-qq/README.md +++ b/modules/adapter-qq/README.md @@ -12,6 +12,25 @@ export const config = Tsu.Object({ }) ``` +## Supports + +### Events + +- on_message (exclude `MessageScope.PRIVATE`) + +### Api + +- sendGroupMsg +- sendChannelMsg + +### Elements + +- text +- image +- voice +- mention +- video + ## Reference - [Kotori Docs](https://kotori.js.org/) diff --git a/modules/adapter-slack/README.md b/modules/adapter-slack/README.md index 0c9ff691..f77580f4 100644 --- a/modules/adapter-slack/README.md +++ b/modules/adapter-slack/README.md @@ -31,7 +31,7 @@ export const config = Tsu.Object({ - voice - video -## Todo +## TODO Support more standard api... diff --git a/modules/adapter-telegram/README.md b/modules/adapter-telegram/README.md index e1628274..7f93d715 100644 --- a/modules/adapter-telegram/README.md +++ b/modules/adapter-telegram/README.md @@ -31,7 +31,7 @@ export const config = Tsu.Object({ - video - location -## Todo +## TODO Support more standard api... diff --git a/modules/core/locales/en_US.json b/modules/core/locales/en_US.json index 6d90c522..7d70f592 100644 --- a/modules/core/locales/en_US.json +++ b/modules/core/locales/en_US.json @@ -31,7 +31,7 @@ "core.msg.core": "Global language: %lang%\nInstance directory: %root%\nRunning mode: %mode%\nNumber of modules: %modules%\nNumber of services: %services%\nNumber of bot instances: %bots%\nNumber of middlewares: %midwares%\nNumber of commands: %commands%\nNumber of regular expressions: %regexps%", "core.msg.bots": "Instance list: %list%", "core.msg.bots.list": "\n----------\nID: %identity%\nLanguage: %lang%\nPlatform: %platform%\nStatus: %status%", - "core.msg.about": "Kotori version: %version%\nLicense: %license%\nNodeJS version: %node_version%", + "core.msg.about": "Kotori version: %version%\nCore version: %core_version%\n%Loader version: %loader_version%%\nLicense: %license%\nNodeJS version: %node_version%", "core.msg.locale": "Successfully set the display language for the current instance to: %lang%", "core.msg.locale.global": "Successfully set the global display language to: %lang%", "core.msg.locale.invalid": "Parameter is invalid, must be one of the following values: en_US, ja_JP, zh_CN, zh_TW", diff --git a/modules/core/locales/zh_CN.json b/modules/core/locales/zh_CN.json index d37e8709..39a43d8d 100644 --- a/modules/core/locales/zh_CN.json +++ b/modules/core/locales/zh_CN.json @@ -31,7 +31,7 @@ "core.msg.core": "全局语言:%lang%\n实例目录:%root%\n运行模式:%mode%\n模块数量:%modules%\n服务数量:%services%\nbot 实例数量:%bots%\n中间件数量:%midwares%\n指令数量:%commands%\n正则数量:%regexps%", "core.msg.bots": "实例列表:%list%", "core.msg.bots.list": "\n----------\nID:%identity%\n语言:%lang%\n平台:%platform%\n状态:%status%", - "core.msg.about": "Kotori 版本:%version%\n协议:%license%\nNodeJS 版本:%node_version%", + "core.msg.about": "Kotori 版本:%version%\n核心版本:%core_version%\n加载器版本:%loader_version%\n协议:%license%\nNodeJS 版本:%node_version%", "core.msg.locale": "成功将当前实例显示语言设置为:%lang%", "core.msg.locale.global": "成功将全局显示语言设置为:%lang%", "core.msg.locale.invalid": "参数无效,必须是以下中的一个值:en_US、ja_JP、zh_CN、zh_TW", diff --git a/modules/core/src/index.ts b/modules/core/src/index.ts index ede2f7f4..b1ff9283 100644 --- a/modules/core/src/index.ts +++ b/modules/core/src/index.ts @@ -3,7 +3,7 @@ * @Blog: https://hotaru.icu * @Date: 2023-07-11 14:18:27 * @LastEditors: Hotaru biyuehuya@gmail.com - * @LastEditTime: 2024-08-03 10:59:04 + * @LastEditTime: 2024-08-06 11:18:36 */ import { @@ -183,8 +183,14 @@ export function main(ctx: Context) { .shortcut(['小鸟', '小鳥', 'ことり', 'kotori', 'Kotori']) .hide() .action((_, session) => { - const { version, license } = session.api.adapter.ctx.meta - return session.format('core.msg.about', { version, license, node_version: process.version }) + const { version, license, coreVersion, loaderVersion } = session.api.adapter.ctx.meta + return session.format('core.msg.about', { + version, + license, + core_version: coreVersion, + loader_version: loaderVersion, + node_version: process.version + }) }) ctx diff --git a/modules/filter/src/index.ts b/modules/filter/src/index.ts index 41966ab1..8745ce2d 100644 --- a/modules/filter/src/index.ts +++ b/modules/filter/src/index.ts @@ -5,10 +5,10 @@ import { Symbols, CORE_MODULES, KotoriPlugin, - FilterTestList, type FilterOption, Filter, - KotoriError + KotoriError, + filterOptionSchema } from 'kotori-bot' import pm from 'picomatch' @@ -20,28 +20,6 @@ declare module 'kotori-bot' { const plugin = plugins([__dirname, '../']) -export const filterOptionBaseSchema = Tsu.Object({ - test: Tsu.Custom( - (value) => typeof value === 'string' && Object.values(FilterTestList).includes(value as FilterTestList) - ).describe('Testing item'), - operator: Tsu.Union( - Tsu.Literal('=='), - Tsu.Literal('!='), - Tsu.Literal('>'), - Tsu.Literal('<'), - Tsu.Literal('>='), - Tsu.Literal('<=') - ).describe('Testing operation'), - value: Tsu.Union(Tsu.String(), Tsu.Number(), Tsu.Boolean()).describe('Expect value') -}) - -export const filterOptionGroupSchema = Tsu.Object({ - type: Tsu.Union(Tsu.Literal('all_of'), Tsu.Literal('any_of'), Tsu.Literal('none_of')), - filters: Tsu.Array(filterOptionBaseSchema) -}) - -export const filterOptionSchema = Tsu.Union(filterOptionBaseSchema, filterOptionGroupSchema) - @plugin.import export class FilterPlugin extends KotoriPlugin> { @plugin.schema diff --git a/modules/requester/src/index.ts b/modules/requester/src/index.ts index cf777b9f..56568253 100644 --- a/modules/requester/src/index.ts +++ b/modules/requester/src/index.ts @@ -55,7 +55,7 @@ export function main(ctx: Context, cfg: Tsu.infer) { session.api.adapter.config.master ) log( - session.api.adapter.identity, + `${session.api.adapter.platform}/${session.api.adapter.identity}`, isString ? session.i18n.locale(msg.replace('.msg.', '.log.')) : session.format(msg[0].replace('.msg.', '.log.'), msg[1]).toString() @@ -147,7 +147,7 @@ export function main(ctx: Context, cfg: Tsu.infer) { if (cfg.onCommand) { ctx.on('command', ({ session, raw }) => log( - session.api.adapter.identity, + `${session.api.adapter.platform}/${session.api.adapter.identity}`, session.format(`requester.log.command.${session.type === MessageScope.GROUP ? 'group' : 'private'}`, [ session.userId, raw, @@ -160,7 +160,7 @@ export function main(ctx: Context, cfg: Tsu.infer) { if (cfg.onRegexp) { ctx.on('regexp', ({ session, raw }) => log( - session.api.adapter.identity, + `${session.api.adapter.platform}/${session.api.adapter.identity}`, session.format(`requester.log.regexp.${session.type === MessageScope.GROUP ? 'group' : 'private'}`, [ session.userId, raw, diff --git a/modules/testing/src/index.ts b/modules/testing/src/index.ts index 135defcf..92f61bbf 100644 --- a/modules/testing/src/index.ts +++ b/modules/testing/src/index.ts @@ -15,9 +15,7 @@ export class TestingPlugin extends KotoriPlugin void, session: SessionMsg) { @@ -36,11 +34,13 @@ export class TestingPlugin extends KotoriPlugin', access: UserAccess.ADMIN }) - public eval({ args }: Parameters[0], session: Session) { + @plugin.command({ template: 'eval [...code]', access: UserAccess.ADMIN }) + public async eval({ args }: Parameters[0], session: Session) { + let code = args.join(' ') + if (!code.trim()) code = (await session.prompt('Input the code:')).toString() try { // biome-ignore lint: - const result = eval(args.join(' ')) + const result = eval(code) return session.format('eval result:~\n{0}', [result]) } catch (error) { return session.format('eval error:~\n{0}', [error instanceof Error ? error.message : String(error)]) diff --git a/modules/webui/src/routers/api/avatar.svg b/modules/webui/assets/avatar.svg similarity index 100% rename from modules/webui/src/routers/api/avatar.svg rename to modules/webui/assets/avatar.svg diff --git a/modules/webui/package.json b/modules/webui/package.json new file mode 100644 index 00000000..2260cd35 --- /dev/null +++ b/modules/webui/package.json @@ -0,0 +1,21 @@ +{ + "name": "@kotori-bot/kotori-plugin-webui", + "version": "1.3.0", + "description": "webui plugin", + "main": "lib/index.js", + "keywords": ["kotori", "chatbot", "kotori-plugin"], + "license": "GPL-3.0", + "files": ["lib", "dist", "locales", "LICENSE", "assets", "README.md"], + "author": "Hotaru ", + "peerDependencies": { + "kotori-bot": "workspace:^" + }, + "kotori": { + "meta": { + "language": ["en_US", "ja_JP", "zh_TW", "zh_CN"] + } + }, + "dependencies": { + "@kotori-bot/kotori-plugin-filter": "workspace:^" + } +} diff --git a/modules/webui/package1.json b/modules/webui/package1.json deleted file mode 100644 index 7494c7e2..00000000 --- a/modules/webui/package1.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@kotori-bot/kotori-plugin-webui", - "version": "1.2.0", - "description": "webui plugin", - "main": "lib/index.js", - "keywords": [ - "kotori", - "chatbot", - "kotori-plugin" - ], - "license": "GPL-3.0", - "files": [ - "lib", - "dist", - "locales", - "LICENSE", - "README.md" - ], - "author": "Hotaru ", - "peerDependencies": { - "kotori-bot": "workspace:^" - }, - "kotori": { - "meta": { - "language": [ - "en_US", - "ja_JP", - "zh_TW", - "zh_CN" - ] - } - }, - "dependencies": { - "@types/date-fns": "^2.6.0", - "date-fns": "^3.6.0" - } -} diff --git a/modules/webui/src/index.ts b/modules/webui/src/index.ts index fa01f918..a5344d16 100644 --- a/modules/webui/src/index.ts +++ b/modules/webui/src/index.ts @@ -1,40 +1,41 @@ -import { Tsu, loadConfig } from 'kotori-bot'; -import path, { resolve } from 'node:path'; -import { Context } from './types'; -import { Webui, config } from './service'; -import routers from './routers'; -import wsHandler from './ws'; -import plugin from './plugin'; +import type { Tsu, Context } from 'kotori-bot' +import pkg from '../package.json' +import { resolve } from 'node:path' +import { Webui, type config } from './service' +import routers from './routers' +import wsHandler from './ws' +import plugin from './plugin' -export const inject = ['server', 'file', 'cache']; +export const inject = ['server', 'cache', 'database'] -export const lang = [__dirname, '../locales']; +export const lang = [__dirname, '../locales'] -export { config } from './service'; +export * from './service' export function main(ctx: Context, cfg: Tsu.infer) { - /* Starts webui service */ - ctx.service('webui', new Webui(ctx, cfg)); - ctx.inject('webui'); + // Starts webui service + ctx.service('webui', new Webui(ctx, cfg)) + ctx.inject('webui') ctx.on('ready', () => { - ctx.server.wss('/webui', (ws) => { - wsHandler(ctx, ws); - }); - }); + ctx.server.wss('/webui/:token', (ws, { params: { token } }) => { + if (!ctx.webui.checkToken(token)) return ws.close(1002) + wsHandler(ctx, ws) + }) + }) - /* Sets up routes */ - const app = ctx.server; - app.use(app.static(path.resolve(__dirname, '../dist'))); - app.use(app.json()); - app.use(app.urlencoded({ extended: true })); - app.use('/', routers(ctx, app)); + // Sets up routes + const app = ctx.server + app.use(app.static(resolve(__dirname, '../dist'))) + app.use(app.json()) + app.use(app.urlencoded({ extended: true })) + app.use('/', routers(ctx, app)) - /* Register plugin */ + // Register plugin ctx.load({ - /* extends parent plugin's package name and share the same data files area */ - name: loadConfig(resolve(__dirname, '../package.json')).name as string, + // extends parent plugin's package name and share the same data files area + name: pkg.name, main: (subCtx) => { - plugin(subCtx); + plugin(subCtx) } - }); + }) } diff --git a/modules/webui/src/plugin/index.ts b/modules/webui/src/plugin/index.ts index 5f498235..2663d137 100644 --- a/modules/webui/src/plugin/index.ts +++ b/modules/webui/src/plugin/index.ts @@ -1,11 +1,12 @@ import '../types' import { UserAccess, type Context, MessageScope, formatFactory, FilterTestList } from 'kotori-bot' +import type { AccountData } from '../types' export default (ctx: Context) => { - ctx.on('status', ({ status, adapter }) => { + ctx.on('status', async ({ status, adapter }) => { + if ((await ctx.webui.getVerifyHash())?.salt) return if (status !== 'online') return if (adapter.platform !== 'cmd') return - if (ctx.webui.getVerifySalt()) return adapter.api.sendPrivateMsg( formatFactory(adapter.ctx.i18n)('webui.msg.webui.uninitialized', [adapter.config.commandPrefix]), adapter.config.master @@ -21,11 +22,11 @@ export default (ctx: Context) => { .option('R', 'reset:boolean - webui.option.webui') .action(async ({ options: { reset } }, session) => { if (reset) { - ctx.file.save('salt', '') + ctx.db.put('account_data', { hash: '', salt: '' }) return 'webui.msg.webui.reset' } if (session.api.adapter.platform !== 'cmd') return 'webui.msg.webui.error' - if (!ctx.webui.getVerifySalt()) { + if (!(await ctx.webui.getVerifyHash()).salt) { ctx.webui.setVerifyHash( (await session.prompt('webui.msg.webui.prompt.user')).toString(), (await session.prompt('webui.msg.webui.prompt.pwd')).toString() diff --git a/modules/webui/src/routers/api/accounts.ts b/modules/webui/src/routers/api/accounts.ts index f073677d..6fbf8e25 100644 --- a/modules/webui/src/routers/api/accounts.ts +++ b/modules/webui/src/routers/api/accounts.ts @@ -1,28 +1,26 @@ -import { Context } from '../../types'; +import type { Context } from 'kotori-bot' export default (ctx: Context, app: Context['server']) => { - const router = app.router(); + const router = app.router() - router.post('/login', (req, res) => { - const { username, password } = req.body; - const loginStats = ctx.webui.getLoginStats(); + router.post('/login', async (req, res) => { + const { username, password } = req.body + const token = await ctx.webui.accountLogin(username, password) - if (ctx.webui.checkVerifyHash(username, password)) { - ctx.logger.label('server').record('user login successful'); - loginStats.success += 1; - ctx.webui.setLoginStats(loginStats); - return res.json({ token: ctx.webui.addToken() }); + if (token) { + ctx.logger.label('webui').record('user login successful') + res.json({ token }) + return } - ctx.logger.label('server').error('user login failed'); - loginStats.failed += 1; - ctx.webui.setLoginStats(loginStats); - return res.status(401).json({ message: 'Invalid username or password' }); - }); + + ctx.logger.label('webui').error('user login failed') + res.status(401).json({ message: 'Invalid username or password' }) + }) router.post('/logout', (req, res) => { - ctx.webui.removeToken(req.headers.authorization ?? ''); - res.sendStatus(204); - }); + ctx.webui.accountLogout(req.headers.authorization ?? '') + res.sendStatus(204) + }) - return router; -}; + return router +} diff --git a/modules/webui/src/routers/api/config.ts b/modules/webui/src/routers/api/config.ts index f5ae3ec7..fc481d02 100644 --- a/modules/webui/src/routers/api/config.ts +++ b/modules/webui/src/routers/api/config.ts @@ -1,73 +1,60 @@ -import { PLUGIN_PREFIX, stringRightSplit } from 'kotori-bot'; -import { Context } from '../../types'; +import type { Context } from 'kotori-bot' export default (ctx: Context, app: Context['server']) => { - const getPluginConfig = () => - Object.entries(ctx.config.plugin).map(([name, origin]) => ({ name, origin, schema: {} })); - const getBotConfig = () => Object.entries(ctx.config.adapter).map(([id, origin]) => ({ id, origin, schema: {} })); - const router = app.router(); + const router = app.router() router.get('/plugins/:scope?/:name?', (req, res) => { - const { scope, name } = req.params; - if (!scope) return res.json(getPluginConfig()); - - const pluginName = stringRightSplit(name ?? scope, PLUGIN_PREFIX); - const pluginConfig = getPluginConfig().find((plugin) => plugin.name === pluginName); - return pluginConfig ? res.json(pluginConfig) : res.status(404).json({ message: 'Plugin not found' }); - }); - - router.put('/plugins/:scope?/:name?', (req, res) => { - const { scope, name } = req.params; - const { body } = req; - if (!scope || !name) return res.status(400).json({ message: 'Invalid plugin scope' }); - if (typeof body !== 'object') return res.status(400).json({ message: 'Invalid body' }); - - const pluginName = stringRightSplit(name ?? scope, PLUGIN_PREFIX); - const pluginConfig = getPluginConfig().find((plugin) => plugin.name === pluginName); - if (!pluginConfig) return res.status(404).json({ message: 'Plugin not found' }); - - Object.keys(body).forEach((key) => { - if (key in pluginConfig) pluginConfig[key as keyof typeof pluginConfig] = body[key]; - }); - return res.sendStatus(204); - }); + const { scope, name } = req.params + const result = ctx.webui.configPluginsGet(scope, name) + return result ? res.json(result) : res.status(404).json({ message: 'Plugin not found' }) + }) + + router.put('/plugins/:scope/:name?', (req, res) => { + const { scope, name } = req.params + const { body } = req + if (typeof body !== 'object') return res.status(400).json({ message: 'Invalid body' }) + const result = ctx.webui.configPluginUpdate(body, scope, name) + return result ? res.sendStatus(204) : res.status(404).json({ message: 'Plugin not found' }) + }) router.get('/bots/:name?', (req, res) => { - const { name } = req.params; - if (!name) return res.json(getBotConfig()); - - const botConfig = getBotConfig().find((bot) => bot.id === name); - return botConfig ? res.json(botConfig) : res.status(404).json({ message: 'Bot not found' }); - }); - - router.put('/bots/:name?', (req, res) => { - const { name } = req.params; - const { body } = req; - if (!name) return res.status(400).json({ message: 'Invalid bot name' }); - if (typeof body !== 'object') return res.status(400).json({ message: 'Invalid body' }); - - const botConfig = ctx.config.adapter[name]; - if (!botConfig) return res.status(404).json({ message: 'Bot not found' }); - - Object.keys(body).forEach((key) => { - if (key in botConfig) botConfig[key as keyof typeof botConfig] = body[key]; - }); - return res.sendStatus(204); - }); + const { name } = req.params + const result = ctx.webui.configBotsGet(name) + return result ? res.json(result) : res.status(404).json({ message: 'Bot not found' }) + }) + + router.put('/bots/:name', (req, res) => { + const { name } = req.params + const { body } = req + if (typeof body !== 'object') return res.status(400).json({ message: 'Invalid body' }) + const result = ctx.webui.configBotsUpdate(body, name) + return result ? res.sendStatus(204) : res.status(404).json({ message: 'Bot not found' }) + }) router.get('/global', (_, res) => { - res.json(ctx.config.global); - }); + res.json(ctx.config.global) + }) router.put('/global', (req, res) => { - const { body } = req; - if (typeof body !== 'object') return res.status(400).json({ message: 'Invalid body' }); - - Object.keys(body).forEach((key) => { - if (key in ctx.config.global) ctx.config.global[key as 'lang'] = body[key]; - }); - return res.sendStatus(204); - }); - - return router; -}; + const { body } = req + if (typeof body !== 'object') return res.status(400).json({ message: 'Invalid body' }) + ctx.webui.configGlobalUpdate(body) + return res.sendStatus(204) + }) + + router.get('/commands/:name?', async (req, res) => { + const { name } = req.params + const result = await ctx.webui.configCommandsGet(name) + return result ? res.json(result) : res.status(404).json({ message: 'Command not found' }) + }) + + router.put('/commands/:name', async (req, res) => { + const { name } = req.params + const { body } = req + if (typeof body !== 'object') return res.status(400).json({ message: 'Invalid body' }) + const result = await ctx.webui.configCommandsUpdate(body, name) + return result ? res.sendStatus(204) : res.status(404).json({ message: 'Command not found' }) + }) + + return router +} diff --git a/modules/webui/src/routers/api/data.ts b/modules/webui/src/routers/api/data.ts index 211bdd21..48855b75 100644 --- a/modules/webui/src/routers/api/data.ts +++ b/modules/webui/src/routers/api/data.ts @@ -1,157 +1,31 @@ -import os from 'node:os' -import { Adapter, Symbols, loadConfig } from 'kotori-bot' -import { resolve } from 'node:path' -import { Context } from '../../types' -import { calcGrandRecord } from '../../utils/common' - -interface ModulePackage { - name: string -} - -interface BotData { - status: Adapter['status'] - platform: string - identity: string - id: string - lang: string -} - -const AVATAR_COLOR_LIST = [ - ['#64FFDA', '#00B0FF', '#FFFFFF'], // default - ['#FFD700', '#FF8C00', '#000000'], // gold - ['#EF9A9A', '#F44336', '#FFFFFF'], // red - ['#03A9F4', '#0D47A1', '#212121'], // blue - ['#A5D6A7', '#4CAF50', '#FFFFFF'], // green - ['#CE93D8', '#9C27B0', '#FFFFFF'], // purple - ['#BCAAA4', '#795548', '#FFFFFF'], // shit - ['#FFC0CB', '#FF69B4', '#FFFFFF'], // pink - ['#78909C', '#546E7A', '#FFFFFF'] // grey -] +import type { Context } from 'kotori-bot' export default (ctx: Context, app: Context['server']) => { - const getModuleData = () => { - const list: ModulePackage[] = [] - ctx[Symbols.modules].forEach((module) => list.push(module[0].pkg)) - return list - } - - const getBotData = () => { - const list: BotData[] = [] - ctx[Symbols.bot].forEach((bot) => - bot.forEach((api) => - list.push({ - status: api.adapter.status, - platform: api.adapter.platform, - identity: api.adapter.identity, - id: String(api.adapter.selfId), - lang: api.adapter.config.lang - }) - ) - ) - return list - } - const router = app.router() router.get('/modules/:scope?/:name?', (req, res) => { const { scope, name } = req.params - if (!scope) return res.json(getModuleData()) - - const moduleName = name ? `${scope}/${name}` : scope - const moduleData = getModuleData().find((module) => module.name === moduleName) - return moduleData ? res.json(moduleData) : res.status(404).json({ message: 'Modules not found' }) + const result = ctx.webui.dataModules(scope, name) + return result ? res.json(result) : res.status(404).json({ message: 'Modules not found' }) }) - router.get('/bots/:name', (req, res) => { + router.get('/bots/:name?', (req, res) => { const { name } = req.params - if (!name) return res.json(getBotData()) - - const botData = getBotData().find((bot) => bot.identity === name) - return botData ? res.json(botData) : res.status(404).json({ message: 'Bot not found' }) + const result = ctx.webui.dataBots(name) + return result ? res.json(result) : res.status(404).json({ message: 'Bot not found' }) }) router.get('/stats', async (_, res) => { - const botsStatus = getBotData().map((bot) => bot.status.value === 'online') - const msgTotal = calcGrandRecord(ctx.webui.getMsgTotal().origin) - const { success: loginSuccess, failed: loginFailed } = ctx.webui.getLoginStats() - const chats: Record<'received' | 'sent', number[]> = { received: [], sent: [] } - ;[0, 1, 2, 3, 4, 5, 6, 7].forEach((day) => { - const { received, sent } = calcGrandRecord(ctx.webui.getMsgDay(day).origin) - chats.received.push(received || 0) - chats.sent.push(sent || 0) - }) - - res.json({ - chats, - count: { - midwares: ctx[Symbols.midware].size, - commands: ctx[Symbols.command].size, - regexps: ctx[Symbols.regexp].size, - bots: ctx[Symbols.bot].size, - adapters: ctx[Symbols.adapter].size, - modules: ctx[Symbols.modules].size - }, - system: { - type: os.type(), - arch: os.arch(), - uptime: os.uptime(), - hostname: os.hostname(), - homedir: os.homedir(), - node: process.version - }, - info: { - message: `${msgTotal.received}:${msgTotal.sent}`, - bots: `${botsStatus.filter((status) => status).length}/${botsStatus.length}`, - login: `${loginSuccess}:${loginFailed}`, - memory: `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)} MB` - } - }) + res.json(await ctx.webui.dataStats()) }) router.get('/status', (_, res) => { - res.json({ - ...ctx.webui.getStats(), - mode: ctx.options.mode, - // TODO: add more version info - core: ctx.pkg.version - }) + res.json(ctx.webui.dataStatus()) }) router.get('/avatar/:scope?/:name?', (req, res) => { - const DEFAULT_NAME = 'Kotori Plugin' - const DEFAULT_COLOR = AVATAR_COLOR_LIST[0] - const DEFAULT_FONT_SIZE = 75 - - /* Handle plugin name */ const { scope, name } = req.params - let pluginName = DEFAULT_NAME - if (scope) { - pluginName = name ?? scope - if (pluginName.startsWith('kotori-plugin-')) pluginName = pluginName.slice(14) - pluginName = pluginName - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - } - - /* Get index of avatar color based on plugin name */ - const hash = pluginName.split('').reduce((hash, char) => hash * 31 + char.charCodeAt(0), 0) - const index = hash % 9 - const color = AVATAR_COLOR_LIST[index >= 0 ? index : index + 9] - - /* Complete font size */ - const fontSize = Math.ceil(108 * (9 / pluginName.length)) - - /* Load avatar image and replace data */ - let imageData = loadConfig(resolve(__dirname, './avatar.svg'), 'text') - imageData = imageData.replace(DEFAULT_COLOR[0], color[0]) - imageData = imageData.replace(DEFAULT_COLOR[1], color[1]) - imageData = imageData.replace(DEFAULT_COLOR[2], color[2]) - imageData = imageData.replace(DEFAULT_NAME, pluginName) - imageData = imageData.replace(DEFAULT_FONT_SIZE.toString(), fontSize.toString()) - - /* Send image data */ - res.type('image/svg+xml').send(imageData) + res.type('image/svg+xml').send(ctx.webui.dataAvatar(scope, name)) }) return router diff --git a/modules/webui/src/routers/api/demo.ts b/modules/webui/src/routers/api/demo.ts index 7f15d1d2..211b8d97 100644 --- a/modules/webui/src/routers/api/demo.ts +++ b/modules/webui/src/routers/api/demo.ts @@ -1,7 +1,7 @@ -import { Context } from '../../types'; +import type { Context } from 'kotori-bot' -export default (ctx: Context, app: Context['server']) => { - const router = app.router(); +export default (_: Context, app: Context['server']) => { + const router = app.router() - return router; -}; + return router +} diff --git a/modules/webui/src/routers/index.ts b/modules/webui/src/routers/index.ts index a1ff3a8b..25f409ab 100644 --- a/modules/webui/src/routers/index.ts +++ b/modules/webui/src/routers/index.ts @@ -1,33 +1,31 @@ -import { Context } from '../types'; -import RouterConfig from './router'; +import type { Context } from 'kotori-bot' +import RouterConfig from './router' -const NO_VERIFY = ['/api/accounts/login', '/api/data/avatar/']; +const NO_VERIFY = ['/api/accounts/login', '/api/data/avatar/'] export default (ctx: Context, app: Context['server']) => { - const router = app.router(); + const router = app.router() router.all('*', (req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Headers', 'Content-Type,Authorization'); - res.header('Access-Control-Allow-Methods', '*'); - res.header('Content-Type', 'application/json;charset=utf-8'); + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Headers', 'Content-Type,Authorization') + res.header('Access-Control-Allow-Methods', '*') + res.header('Content-Type', 'application/json;charset=utf-8') - if (req.method === 'OPTIONS') return res.sendStatus(200); - ctx.logger.label(req.method).trace(req.path); + if (req.method === 'OPTIONS') return res.sendStatus(200) + ctx.logger.label(req.method).trace(req.path) if (!RouterConfig.find((item) => item.path === req.path || req.path.startsWith(item.path))) - return res.sendStatus(404); + return res.sendStatus(404) if ( NO_VERIFY.filter((item) => req.path.startsWith(item)).length > 0 || ctx.webui.checkToken(req.headers.authorization) - ) - return next(); - return res.sendStatus(401); - }); + ) { + return next() + } + return res.sendStatus(401) + }) - RouterConfig.forEach((page) => { - router.use(page.path, page.handler(ctx, app)); - }); - - return router; -}; + for (const page of RouterConfig) router.use(page.path, page.handler(ctx, app)) + return router +} diff --git a/modules/webui/src/routers/router.ts b/modules/webui/src/routers/router.ts index 5d3da3d0..0fa98e39 100644 --- a/modules/webui/src/routers/router.ts +++ b/modules/webui/src/routers/router.ts @@ -1,15 +1,15 @@ -import { Context, HttpRoutes } from 'kotori-bot'; -import account from './api/accounts'; -import config from './api/config'; -import data from './api/data'; +import type { Context, HttpRoutes } from 'kotori-bot' +import account from './api/accounts' +import config from './api/config' +import data from './api/data' interface RouterRecord { - path: string; - handler: (ctx: Context, app: Context['server']) => HttpRoutes; + path: string + handler: (ctx: Context, app: Context['server']) => HttpRoutes } function defineRouter(config: RouterRecord[]) { - return config; + return config } export default defineRouter([ @@ -25,4 +25,4 @@ export default defineRouter([ path: '/api/data', handler: data } -]); +]) diff --git a/modules/webui/src/service/index.ts b/modules/webui/src/service/index.ts index 7672322d..5afd8e05 100644 --- a/modules/webui/src/service/index.ts +++ b/modules/webui/src/service/index.ts @@ -1,147 +1,377 @@ -import { Context, Service, Symbols, Transport, Tsu, none } from 'kotori-bot'; -import { generateToken, generateVerifyHash, getCpuData, getDate, getRamData } from '../utils/common'; -import { KEY, LoginStats, MsgRecordDay, MsgRecordTotal } from '../types'; -import WebuiTransport from '../utils/transport'; +import os from 'node:os' +import { + type Context, + Service, + Symbols, + type Transport, + Tsu, + loadConfig, + PLUGIN_PREFIX, + adapterConfigSchemaFactory, + filterOptionSchema +} from 'kotori-bot' +import { getStatusStats } from '../utils/common' +import type { AccountData, BotStats, BotStatsDay, CommandSettings, LoginStats } from '../types' +import WebuiTransport from '../utils/transport' +import { parse, resolve } from 'node:path' +import { createHash, randomUUID } from 'node:crypto' +import observer from '../utils/observer' +import createAutoSave from '../utils/autoSave' + +function handlePluginName(scope: string, name?: string) { + return `${scope.startsWith('@') && scope !== '@kotori-bot' ? `${scope.slice(1)}/` : ''}${(name ?? scope).replace(PLUGIN_PREFIX, '')}` +} +function generateToken() { + return randomUUID().replaceAll('-', '') +} + +function generateVerifyHash(username: string, password: string, salt: string) { + return createHash('sha256').update(`${username}${password}${salt}`).digest('hex') +} + +function getToday() { + return new Date(new Date().toLocaleDateString()).getTime() +} export const config = Tsu.Object({ - interval: Tsu.Number().range(60 * 1, 60 * 60 * 12).default(60 * 5) -}); + interval: Tsu.Number() + .range(60 * 1, 60 * 60 * 12) + .default(60 * 5) +}) export class Webui extends Service> { - private timer?: NodeJS.Timer; - public constructor(ctx: Context, cfg: Tsu.infer) { - super(ctx, cfg, 'webui'); - } - - public start() { - /* Add webui transport to logger */ - const logger = this.ctx.get('logger') as { options: { transports: Transport[] } }; - logger.options.transports.push(new WebuiTransport({})); - - /* Load records from file to cache and memory */ - const msgTotal = this.ctx.file.load(`${KEY.MSG_TOTAL}.json`, 'json', {}); - const msgDay = this.ctx.file.load(`${KEY.MSG_DAY}${getDate()}.json`, 'json', {}); - this.setMsgTotal({ origin: msgTotal }); - this.setMsgDay({ day: getDate(), origin: msgDay }); - this.ctx[Symbols.bot].forEach((bot) => - bot.forEach((api) => { - const { adapter } = api; - const { identity } = adapter; - if (!(identity in msgTotal)) return; - adapter.status.sentMsg = msgTotal[identity].sent; - adapter.status.receivedMsg = msgTotal[identity].received; - }) - ); - - /* Auto save records from cache to file */ - this.timer = setInterval(() => { - this.ctx.file.save(`${KEY.MSG_TOTAL}.json`, this.getMsgTotal().origin, 'json'); - const msgDay = this.getMsgDay(); - const currentDay = getDate(); - if (msgDay.day === currentDay) { - this.ctx.file.save(`${KEY.MSG_DAY}${msgDay.day}.json`, msgDay.origin, 'json'); - } else { - this.setMsgDay({ day: currentDay, origin: {} }); + super(ctx, cfg, 'webui') + } + + public async start() { + // Add webui transport to logger + const logger = this.ctx.get('logger') as { options: { transports: Transport[] } } + logger.options.transports.push(new (WebuiTransport(this.ctx))({})) + + // Initialize database and update command settings + await this.ctx.db.get('login_stats', { success: 0, failed: 0 }) + await this.ctx.db.get('account_data', { hash: '', salt: '' }) + await this.ctx.db.get('bot_stats', {}) + const settings = await this.ctx.db.get('command_settings', {}) + if (Object.keys(settings).length === 0) { + for await (const command of this.ctx[Symbols.command].values()) { + const { hide, shortcut, alias, scope, access } = command.meta + settings[command.meta.root] = { hide, shortcut, alias, scope, access } + } + } else { + for await (const [root, setting] of Object.entries(settings)) { + const command = Array.from(this.ctx[Symbols.command]).find((command) => command.meta.root === root) + if (command) { + ;(command as { meta: object }).meta = { ...command.meta, ...setting } + } else { + delete settings[root] + } } - }, this.config.interval * 1000); - - /* Update records */ - this.ctx.on('send', (data) => { - const { identity } = data.api.adapter; - const { origin: dataTotal } = this.getMsgTotal(); - const { origin: dataDay } = this.getMsgDay(); - if (!(identity in dataTotal)) dataTotal[identity] = { sent: 0, received: 0 }; - if (!(identity in dataDay)) dataDay[identity] = { sent: 0, received: 0 }; - dataTotal[identity].sent = (dataTotal[identity].sent || 0) + 1; - dataDay[identity].sent = (dataDay[identity].sent || 0) + 1; - }); - this.ctx.midware((next, session) => { - next(); - const { identity } = session.api.adapter; - const { origin: dataTotal } = this.getMsgTotal(); - const { origin: dataDay } = this.getMsgDay(); - if (!(identity in dataTotal)) dataTotal[identity] = { sent: 0, received: 0 }; - if (!(identity in dataDay)) dataDay[identity] = { sent: 0, received: 0 }; - dataTotal[identity].received = (dataTotal[identity].received || 0) + 1; - dataDay[identity].received = (dataDay[identity].received || 0) + 1; - }, 10); - } - - public stop() { - if (this.timer) clearInterval(Number(this.timer)); - } - - public getVerifySalt() { - return this.ctx.file.load('salt', 'text'); + } + await this.ctx.db.put('command_settings', settings) + + // Listen data change + ;(this.ctx as { config: object }).config = createAutoSave( + this.ctx.config, + resolve(this.ctx.baseDir.config), + parse(this.ctx.baseDir.config).ext.slice(1) as 'json', + this.ctx.config + ) + for await (const bots of this.ctx[Symbols.bot].values()) { + for await (const { adapter } of bots) { + const { identity } = adapter + const botStats = await this.ctx.db.get('bot_stats') + if (botStats[identity]) { + ;(adapter as { status: object }).status = { + ...botStats[identity], + createTime: new Date(botStats[identity].createTime), + lastMsgTime: botStats[identity].lastMsgTime ? new Date(botStats[identity].lastMsgTime as number) : null + } + } else { + botStats[identity] = { + ...adapter.status, + createTime: new Date(adapter.status.createTime).getTime(), + lastMsgTime: adapter.status.lastMsgTime ? new Date(adapter.status.lastMsgTime).getTime() : null + } + } + await this.ctx.db.put('bot_stats', botStats) + ;(adapter as { status: object }).status = observer(adapter.status, async (_, prop) => { + const botStats = await this.ctx.db.get('bot_stats') + const botStatsToday = await this.ctx.db.get(`bot_stats:${getToday()}`, {}) + const isNumberProp = ['offlineTimes', 'receivedMsg', 'sentMsg'].includes(prop) + + if (botStats[identity] && isNumberProp) { + botStats[identity][prop as 'sentMsg'] += 1 + } else { + botStats[identity] = { + ...adapter.status, + createTime: new Date(adapter.status.createTime).getTime(), + lastMsgTime: adapter.status.lastMsgTime ? new Date(adapter.status.lastMsgTime).getTime() : null + } + } + if (botStatsToday[identity] && isNumberProp) { + botStats[identity][prop as 'sentMsg'] += 1 + } else if (!botStatsToday[identity]) { + botStatsToday[identity] = { sentMsg: 0, receivedMsg: 0, offlineTimes: 0 } + } + + await this.ctx.db.put('bot_stats', botStats) + await this.ctx.db.put(`bot_stats:${getToday()}`, botStatsToday) + }) + } + } } public setVerifyHash(username: string, password: string) { - const salt = generateToken(); - this.ctx.file.save('salt', salt); - this.ctx.file.save('hash', generateVerifyHash(username, password, salt)); + const salt = generateToken() + this.ctx.db.put('account_data', { + salt, + hash: generateVerifyHash(username, password, salt) + }) } - public checkVerifyHash(username: string, password: string) { - const salt = this.getVerifySalt(); - return !!salt && generateVerifyHash(username, password, salt) === this.ctx.file.load('hash', 'text'); + public getVerifyHash() { + return this.ctx.db.get('account_data') } - public addToken() { - const list = this.ctx.cache.get(KEY.TOKENS) ?? []; - const token = generateToken(); - list.push(token); - this.ctx.cache.set(KEY.TOKENS, list); - return token; + public checkToken(authorization?: string) { + return ( + !!authorization && (this.ctx.cache.get('tokens') ?? []).includes(authorization.replace('Bearer ', '')) + ) } - public removeToken(authorization?: string) { - const list = this.ctx.cache.get(KEY.TOKENS) ?? []; - if (!list) return; - const token = authorization?.replace('Bearer ', ''); - this.ctx.cache.set(KEY.TOKENS, token !== undefined ? list.filter((t) => t !== token) : []); + public async accountLogin(username: string, password: string) { + const loginStats = await this.ctx.db.get('login_stats') + const { salt, hash } = await this.getVerifyHash() + + if (generateVerifyHash(username, password, salt) !== hash) { + loginStats.failed += 1 + await this.ctx.db.put('login_stats', loginStats) + return undefined + } + + loginStats.success += 1 + await this.ctx.db.put('login_stats', loginStats) + const list = this.ctx.cache.get('tokens') ?? [] + const token = generateToken() + list.push(token) + return token } - public checkToken(authorization?: string) { - return ( - authorization && (this.ctx.cache.get(KEY.TOKENS) ?? []).includes(authorization.replace('Bearer ', '')) - ); + public accountLogout(authorization: string) { + const list = this.ctx.cache.get('tokens') ?? [] + const token = authorization?.replace('Bearer ', '') + this.ctx.cache.set( + 'tokens', + list.filter((t) => t !== token) + ) } - public getStats() { - none(this); + public dataModules(scope?: string, name?: string) { + const modulesData = Array.from(this.ctx[Symbols.modules].values()).map(([moduleMeta]) => moduleMeta.pkg) + if (!scope) return modulesData + const moduleName = name ? `${scope}/${name}` : scope + return modulesData.find((module) => module.name === moduleName) as object | undefined + } + + public dataBots(name?: string) { + const botsData = [] + for (const bot of this.ctx[Symbols.bot].values()) { + for (const api of bot) { + botsData.push({ + status: api.adapter.status, + platform: api.adapter.platform, + identity: api.adapter.identity, + id: String(api.adapter.selfId), + lang: api.adapter.config.lang + }) + } + } + if (!name) return botsData + return botsData.find((bot) => bot.identity === name) as object | undefined + } + + public async dataStats() { + function calcBotStatMsg(data: BotStatsDay) { + return Object.values(data).reduce( + (acc, cur) => ({ + received: acc.received + cur.receivedMsg, + sent: acc.sent + cur.sentMsg + }), + { received: 0, sent: 0 } + ) + } + + const botsStatus = (this.dataBots() as { status: { value: string } }[]).map((bot) => bot.status.value === 'online') + const { success: loginSuccess, failed: loginFailed } = await this.ctx.db.get('login_stats') + const botStats = calcBotStatMsg((await this.ctx.db.get('bot_stats')) ?? {}) + const botStatDays: Record<'received' | 'sent', number[]> = { received: [], sent: [] } + const ONE_DAY = new Date('2013-07-20').getTime() - new Date('2013-07-19').getTime() + + for await (const day of [0, 1, 2, 3, 4, 5, 6]) { + const key = `bot_stats:${getToday() - day * ONE_DAY}` + const { received, sent } = calcBotStatMsg((await this.ctx.db.get(key)) ?? {}) + botStatDays.received.push(received || 0) + botStatDays.sent.push(sent || 0) + } + + return { + chats: botStatDays, + count: { + midwares: this.ctx[Symbols.midware].size, + commands: this.ctx[Symbols.command].size, + regexps: this.ctx[Symbols.regexp].size, + bots: this.ctx[Symbols.bot].size, + adapters: this.ctx[Symbols.adapter].size, + modules: this.ctx[Symbols.modules].size + }, + system: { + type: os.type(), + arch: os.arch(), + uptime: os.uptime(), + hostname: os.hostname(), + homedir: os.homedir(), + node: process.version + }, + info: { + message: `${botStats.received}:${botStats.sent}`, + bots: `${botsStatus.filter((status) => status).length}/${botsStatus.length}`, + login: `${loginSuccess}:${loginFailed}`, + memory: `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)} MB` + } + } + } + + public dataStatus() { return { - ram: getRamData(), - cpu: getCpuData() - }; + ...getStatusStats(), + mode: this.ctx.options.mode, + // TODO: add more version info + main: this.ctx.meta.version, + core: this.ctx.meta.coreVersion, + loader: this.ctx.meta.loaderVersion + } + } + + public dataAvatar(scope?: string, name?: string) { + const AVATAR_COLOR_LIST = [ + ['#64FFDA', '#00B0FF', '#FFFFFF'], // default + ['#FFD700', '#FF8C00', '#000000'], // gold + ['#EF9A9A', '#F44336', '#FFFFFF'], // red + ['#03A9F4', '#0D47A1', '#212121'], // blue + ['#A5D6A7', '#4CAF50', '#FFFFFF'], // green + ['#CE93D8', '#9C27B0', '#FFFFFF'], // purple + ['#BCAAA4', '#795548', '#FFFFFF'], // shit + ['#FFC0CB', '#FF69B4', '#FFFFFF'], // pink + ['#78909C', '#546E7A', '#FFFFFF'] // grey + ] + + const DEFAULT_NAME = 'Kotori Plugin' + const DEFAULT_COLOR = AVATAR_COLOR_LIST[0] + const DEFAULT_FONT_SIZE = 75 + + /* Handle plugin name */ + let pluginName = DEFAULT_NAME + if (scope) { + pluginName = name ?? scope + if (pluginName.startsWith('kotori-plugin-')) pluginName = pluginName.slice(14) + pluginName = pluginName + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + } + + /* Get index of avatar color based on plugin name */ + const hash = pluginName.split('').reduce((hash, char) => hash * 31 + char.charCodeAt(0), 0) + const index = hash % 9 + const color = AVATAR_COLOR_LIST[index >= 0 ? index : index + 9] + + /* Complete font size */ + const fontSize = Math.ceil(108 * (9 / pluginName.length)) + + /* Load avatar image and replace data */ + let imageData = loadConfig(resolve(__dirname, '../../assets/avatar.svg'), 'text') + imageData = imageData.replace(DEFAULT_COLOR[0], color[0]) + imageData = imageData.replace(DEFAULT_COLOR[1], color[1]) + imageData = imageData.replace(DEFAULT_COLOR[2], color[2]) + imageData = imageData.replace(DEFAULT_NAME, pluginName) + imageData = imageData.replace(DEFAULT_FONT_SIZE.toString(), fontSize.toString()) + + /* Send image data */ + return imageData + } + + public configPluginsGet(scope?: string, name?: string) { + const pluginsConfig = Object.entries(this.ctx.config.plugin).map(([name, origin]) => ({ + name, + origin, + schema: Tsu.Intersection( + Tsu.Object({ filter: filterOptionSchema }), + this.ctx[Symbols.modules].get(name)?.[2] ?? Tsu.Object({}) + ).schema() + })) + + if (!scope) return pluginsConfig + const pluginName = handlePluginName(scope, name) + return pluginsConfig.find(({ name }) => pluginName === name) } - public getMsgTotal() { - return this.ctx.cache.get(KEY.MSG_TOTAL); + public configPluginUpdate(data: object, scope: string, name?: string) { + const pluginName = handlePluginName(scope, name) + const pluginConfig = this.ctx.config.plugin[pluginName] + if (!pluginConfig) return false + for (const key in data) { + if (key in pluginConfig) pluginConfig[key as keyof typeof pluginConfig] = data[key as keyof typeof data] + } + return true } - public setMsgTotal(data: MsgRecordTotal) { - this.ctx.cache.set(KEY.MSG_TOTAL, data); + public configBotsGet(name?: string) { + const botsConfig = Object.entries(this.ctx.config.adapter).map(([id, origin]) => ({ + id, + origin, + schema: Tsu.Intersection( + adapterConfigSchemaFactory(this.ctx.config.global.lang, this.ctx.config.global.commandPrefix), + this.ctx[Symbols.adapter].get(id)?.[1] ?? Tsu.Object({}) + ).schema() + })) + if (!name) return botsConfig + return botsConfig.find(({ id }) => id === name) } - public getMsgDay(days: number = 0): MsgRecordDay { - if (!days) return this.ctx.cache.get(KEY.MSG_DAY); - const dateString = getDate(days); - return this.ctx.file.load(`${KEY.MSG_DAY}${dateString}.json`, 'json', { - day: dateString, - origin: {} - }); + public configBotsUpdate(data: object, name: string) { + const botConfig = this.ctx.config.adapter[name] + if (!botConfig) return false + for (const key in data) { + if (key in botConfig) botConfig[key as keyof typeof botConfig] = data[key as keyof typeof data] + } + return true } - public setMsgDay(data: MsgRecordDay) { - this.ctx.cache.set(KEY.MSG_DAY, data); + public configGlobalUpdate(data: object) { + for (const key in data) { + if (key in this.ctx.config.global) + this.ctx.config.global[key as keyof typeof this.ctx.config.global] = data[key as keyof typeof data] + } } - public getLoginStats() { - return this.ctx.file.load(`${KEY.LOGIN_STATS}.json`, 'json', { success: 0, failed: 0 }); + public async configCommandsGet(name?: string) { + const CommandSettings = Object.entries(await this.ctx.db.get('command_settings')).map( + ([name, data]) => ({ name, data }) + ) as { name: string }[] + if (!name) return CommandSettings + return CommandSettings.find(({ name: targetName }) => name === targetName) } - public setLoginStats(stats: LoginStats) { - this.ctx.file.save(`${KEY.LOGIN_STATS}.json`, stats, 'json'); + public async configCommandsUpdate(data: object, name: string) { + const commandSettings = await this.ctx.db.get('command_settings') + const setting = commandSettings[name] + if (!setting) return false + for (const key in data) { + if (key in setting) setting[key as keyof typeof setting] = data[key as keyof typeof data] + } + await this.ctx.db.put('command_settings', commandSettings) + return true } } diff --git a/modules/webui/src/types/index.ts b/modules/webui/src/types/index.ts index 447489bf..a162319b 100644 --- a/modules/webui/src/types/index.ts +++ b/modules/webui/src/types/index.ts @@ -1,40 +1,31 @@ -import { LoggerData } from 'kotori-bot'; -import type { Webui } from '../service'; +import type { Adapter, Command, LoggerData } from 'kotori-bot' +import type { Webui } from '../service' declare module 'kotori-bot' { interface Context { - webui: Webui; + webui: Webui } interface EventsMapping { - console_output(data: LoggerData | { msg: string }): void; + console_output(data: LoggerData | { msg: string }): void } } -export { Context } from 'kotori-bot'; +export type BotStats = Record< + string, + Omit & { createTime: number; lastMsgTime: number | null } +> -export const enum KEY { - MSG_TOTAL = 'msg-total', - MSG_DAY = 'msg-day', - LOGIN_STATS = 'login-stats', - TOKENS = 'tokens' -} - -export interface MsgRecord { - received: number; - sent: number; -} +export type BotStatsDay = Record> -export interface MsgRecordDay { - day: string; - origin: Record; -} +export type CommandSettings = Record> -export interface MsgRecordTotal { - origin: Record; +export interface LoginStats { + success: number + failed: number } -export interface LoginStats { - success: number; - failed: number; +export interface AccountData { + salt: string + hash: string } diff --git a/modules/webui/src/utils/autoSave.ts b/modules/webui/src/utils/autoSave.ts new file mode 100644 index 00000000..87de9e6f --- /dev/null +++ b/modules/webui/src/utils/autoSave.ts @@ -0,0 +1,24 @@ +import { saveConfig } from 'kotori-bot' + +export function createAutoSave( + target: T, + file: string, + type?: 'json' | 'toml' | 'yaml', + master?: object +): T { + return new Proxy(target, { + get: (target, prop, receiver) => { + const value = Reflect.get(target, prop, receiver) + return value && (typeof value === 'object' || Array.isArray(value)) + ? createAutoSave(value, file, type, master ?? target) + : value + }, + set: (target, prop, newValue, receiver) => { + const result = Reflect.set(target, prop, newValue, receiver) + saveConfig(file, master ?? target, type ?? 'json') + return result + } + }) +} + +export default createAutoSave diff --git a/modules/webui/src/utils/common.ts b/modules/webui/src/utils/common.ts index 77f4ff74..69275dc3 100644 --- a/modules/webui/src/utils/common.ts +++ b/modules/webui/src/utils/common.ts @@ -1,50 +1,27 @@ -import os from 'node:os'; -import { createHash, randomUUID } from 'node:crypto'; -import { MsgRecord } from '../types'; -import { addDays, format } from 'date-fns'; +import os from 'node:os' -export function generateToken() { - return randomUUID().replaceAll('-', ''); -} - -export function generateVerifyHash(username: string, password: string, salt: string) { - return createHash('sha256').update(`${username}${password}${salt}`).digest('hex'); -} - -export function getRamData() { - const total = os.totalmem() / 1024 / 1024 / 1024; - const unused = os.freemem() / 1024 / 1024 / 1024; - const used = total - unused; - const rate = (used / total) * 100; - return { total, unused, used, rate }; -} +export function getStatusStats() { + function getRamData() { + const total = os.totalmem() / 1024 / 1024 / 1024 + const unused = os.freemem() / 1024 / 1024 / 1024 + const used = total - unused + const rate = (used / total) * 100 + return { total, unused, used, rate } + } -export function getCpuData() { - const cpuData = os.cpus(); - let rate = 0; - let speed = 0; - cpuData.forEach((value) => { - const { times, speed: spd } = value; - rate += (1 - times.idle / (times.idle + times.user + times.nice + times.sys + times.irq)) * 100; - speed += spd / cpuData.length; - }); - return { rate, speed }; -} - -export function generateMessage(type: string, data: object | string) { - return JSON.stringify(type === 'error' ? { type, message: data } : { type, data }); -} - -export function getDate(days = 0) { - return format(days ? addDays(new Date(), -days) : new Date(), 'yyyy-M-d') -} + function getCpuData() { + const cpuData = os.cpus() + let rate = 0 + let speed = 0 + for (const { times, speed: spd } of cpuData) { + rate += (1 - times.idle / (times.idle + times.user + times.nice + times.sys + times.irq)) * 100 + speed += spd / cpuData.length + } + return { rate, speed } + } -export function calcGrandRecord(data: Record) { - return Object.values(data).reduce( - (acc, cur) => ({ - received: acc.received + cur.received, - sent: acc.sent + cur.sent - }), - { received: 0, sent: 0 } - ); + return { + ram: getRamData(), + cpu: getCpuData() + } } diff --git a/modules/webui/src/utils/observer.ts b/modules/webui/src/utils/observer.ts new file mode 100644 index 00000000..17f1d909 --- /dev/null +++ b/modules/webui/src/utils/observer.ts @@ -0,0 +1,15 @@ +export function observer( + target: T, + // biome-ignore lint: + callback: (target: T, prop: keyof T, newValue: any) => void +): T { + return new Proxy(target, { + set: (target, prop, newValue, receiver) => { + const result = Reflect.set(target, prop, newValue, receiver) + callback(target, prop as keyof T, newValue) + return result + } + }) +} + +export default observer diff --git a/modules/webui/src/utils/transport.ts b/modules/webui/src/utils/transport.ts index 642bd131..d67e918a 100644 --- a/modules/webui/src/utils/transport.ts +++ b/modules/webui/src/utils/transport.ts @@ -1,9 +1,10 @@ import '../types' -import { LoggerData, Transport, none } from 'kotori-bot' +import { type LoggerData, Transport, type Context } from 'kotori-bot' -export default class WebuiTransport extends Transport { - public handle(data: LoggerData) { - none(this) - ctx.emit('console_output', data) +export default function (ctx: Context) { + return class WebuiTransport extends Transport { + public handle(data: LoggerData) { + ctx.emit('console_output', data) + } } } diff --git a/modules/webui/src/ws/index.ts b/modules/webui/src/ws/index.ts index e130f1bb..a5a92ca3 100644 --- a/modules/webui/src/ws/index.ts +++ b/modules/webui/src/ws/index.ts @@ -1,9 +1,13 @@ import '../types' import type { Context } from 'kotori-bot' -import { generateMessage } from '../utils/common' +import { getStatusStats } from '../utils/common' type Callback = Parameters[1]> +function generateMessage(type: string, data: object | string) { + return JSON.stringify(type === 'error' ? { type, message: data } : { type, data }) +} + export default (ctx: Context, ws: Callback[0]) => { const interval = ctx.webui.config.interval * 1000 const timer = setInterval(() => { @@ -11,13 +15,13 @@ export default (ctx: Context, ws: Callback[0]) => { clearInterval(timer) return } - ws.send(generateMessage('stats', ctx.webui.getStats())) + ws.send(generateMessage('stats', getStatusStats())) }, interval) ctx.on('dispose', () => clearInterval(timer)) - /* Listen for messages from client */ + // Listen for messages from client ws.on('message', (message) => { - let data + let data: { action: string; command?: string } try { data = JSON.parse(message.toString()) } catch { @@ -36,6 +40,6 @@ export default (ctx: Context, ws: Callback[0]) => { } }) - /* Listen for console output from server */ + // Listen for console output from server ctx.on('console_output', (data) => ws.send(generateMessage('console_output', data))) } diff --git a/modules/webui/tsconfig.json b/modules/webui/tsconfig.json index dd52e9f3..b5dafed2 100644 --- a/modules/webui/tsconfig.json +++ b/modules/webui/tsconfig.json @@ -7,6 +7,9 @@ "references": [ { "path": "../../packages/kotori" + }, + { + "path": "../filter" } ] } diff --git a/packages/core/src/app/config.ts b/packages/core/src/app/config.ts index fadd9d23..104f8c97 100644 --- a/packages/core/src/app/config.ts +++ b/packages/core/src/app/config.ts @@ -32,8 +32,8 @@ export class Config { public readonly meta: MetaInfo public constructor(config: Omit, 'global'> & { global?: Partial } = {}) { - this.config = Object.assign(DEFAULT_CORE_CONFIG, config) - this.config.global = Object.assign(DEFAULT_CORE_CONFIG.global, this.config.global) + this.config = { ...DEFAULT_CORE_CONFIG, ...config } as CoreConfig + this.config.global = { ...DEFAULT_CORE_CONFIG.global, ...this.config.global } this.meta = { name: pkg.name, description: pkg.description, @@ -42,6 +42,7 @@ export class Config { author: pkg.author, coreVersion: pkg.version } + ;(globalThis as unknown as { kotori: object }).kotori = this.meta } } diff --git a/packages/core/src/types/filter.ts b/packages/core/src/types/filter.ts index 45bdb90f..99650348 100644 --- a/packages/core/src/types/filter.ts +++ b/packages/core/src/types/filter.ts @@ -1,3 +1,5 @@ +import Tsu from 'tsukiko' + export enum FilterTestList { PLATFORM = 'platform', USER_ID = 'userId', @@ -30,3 +32,25 @@ export interface FilterOptionGroup { /** Filters list */ filters: FilterOption[] } + +export const filterOptionBaseSchema = Tsu.Object({ + test: Tsu.Custom( + (value) => typeof value === 'string' && Object.values(FilterTestList).includes(value as FilterTestList) + ).describe('Testing item'), + operator: Tsu.Union( + Tsu.Literal('=='), + Tsu.Literal('!='), + Tsu.Literal('>'), + Tsu.Literal('<'), + Tsu.Literal('>='), + Tsu.Literal('<=') + ).describe('Testing operation'), + value: Tsu.Union(Tsu.String(), Tsu.Number(), Tsu.Boolean()).describe('Expect value') +}) + +export const filterOptionGroupSchema = Tsu.Object({ + type: Tsu.Union(Tsu.Literal('all_of'), Tsu.Literal('any_of'), Tsu.Literal('none_of')), + filters: Tsu.Array(filterOptionBaseSchema) +}) + +export const filterOptionSchema = Tsu.Union(filterOptionBaseSchema, filterOptionGroupSchema) diff --git a/packages/core/src/utils/factory.ts b/packages/core/src/utils/factory.ts index 44d2a6c6..fb8d65c5 100644 --- a/packages/core/src/utils/factory.ts +++ b/packages/core/src/utils/factory.ts @@ -48,6 +48,7 @@ export function formatFactory(i18n: I18n) { } const index = Number.parseInt(part.slice(1, -1), 10) const value = data[index] + if (value === undefined || value === null) continue if (value instanceof MessageList || value instanceof MessageSingle) { if (currentString) { diff --git a/packages/loader/src/loader/loader.ts b/packages/loader/src/loader/loader.ts index 2e90655c..eb7b4fe6 100644 --- a/packages/loader/src/loader/loader.ts +++ b/packages/loader/src/loader/loader.ts @@ -3,7 +3,7 @@ * @Blog: https://hotaru.icu * @Date: 2023-06-24 15:12:55 * @LastEditors: Hotaru biyuehuya@gmail.com - * @LastEditTime: 2024-08-05 21:11:08 + * @LastEditTime: 2024-08-06 20:03:24 */ // import '@kotori-bot/core/src/utils/internal' import { @@ -164,15 +164,7 @@ function getConfig(baseDir: BaseDir, loaderOptions?: LoaderOptions) { if (!ext || ext === 'txt' || !configFileType.includes(ext as 'json')) ext = 'json' const result = Tsu.Object({ - global: Tsu.Object({ - lang: localeTypeSchema.default(DEFAULT_CORE_CONFIG.global.lang), - commandPrefix: Tsu.String().default(DEFAULT_CORE_CONFIG.global.commandPrefix), - dirs: Tsu.Array(Tsu.String()).default(DEFAULT_LOADER_CONFIG.dirs), - level: Tsu.Number().default(DEFAULT_LOADER_CONFIG.level), - port: Tsu.Number().default(DEFAULT_LOADER_CONFIG.port), - dbPrefix: Tsu.String().default(DEFAULT_LOADER_CONFIG.dbPrefix), - noColor: Tsu.Boolean().default(DEFAULT_LOADER_CONFIG.noColor) - }).default(Object.assign(DEFAULT_CORE_CONFIG.global, DEFAULT_LOADER_CONFIG)), + global: globalLoaderConfigSchema, plugin: Tsu.Object({}).index(Tsu.Object({}).default({})).default(DEFAULT_CORE_CONFIG.plugin) }) .default({ @@ -195,14 +187,7 @@ function getConfig(baseDir: BaseDir, loaderOptions?: LoaderOptions) { return Tsu.Object({ adapter: Tsu.Object({}) - .index( - Tsu.Object({ - extends: Tsu.String(), - master: Tsu.Union(Tsu.Number(), Tsu.String()), - lang: localeTypeSchema.default(result.global.lang), - commandPrefix: Tsu.String().default(result.global.commandPrefix) - }) - ) + .index(adapterConfigSchemaFactory(result.global.lang, result.global.commandPrefix)) .default(DEFAULT_CORE_CONFIG.adapter) }).parse(result) as CoreConfig } catch (err) { @@ -221,9 +206,27 @@ function moduleLoaderOrder(pkg: ModulePackage) { return 6 } -const localeTypeSchema = Tsu.Union(Tsu.Literal('en_US'), Tsu.Literal('ja_JP'), Tsu.Literal('zh_TW'), Tsu.Any()) +export const localeTypeSchema = Tsu.Union(Tsu.Literal('en_US'), Tsu.Literal('ja_JP'), Tsu.Literal('zh_TW'), Tsu.Any()) + +export const globalLoaderConfigSchema = Tsu.Object({ + lang: localeTypeSchema.default(DEFAULT_CORE_CONFIG.global.lang), + commandPrefix: Tsu.String().default(DEFAULT_CORE_CONFIG.global.commandPrefix), + dirs: Tsu.Array(Tsu.String()).default(DEFAULT_LOADER_CONFIG.dirs), + level: Tsu.Number().default(DEFAULT_LOADER_CONFIG.level), + port: Tsu.Number().default(DEFAULT_LOADER_CONFIG.port), + dbPrefix: Tsu.String().default(DEFAULT_LOADER_CONFIG.dbPrefix), + noColor: Tsu.Boolean().default(DEFAULT_LOADER_CONFIG.noColor) +}).default(Object.assign(DEFAULT_CORE_CONFIG.global, DEFAULT_LOADER_CONFIG)) + +export const adapterConfigSchemaFactory = (lang: Tsu.infer, commandPrefix: string) => + Tsu.Object({ + extends: Tsu.String(), + master: Tsu.Union(Tsu.Number(), Tsu.String()), + lang: localeTypeSchema.default(lang), + commandPrefix: Tsu.String().default(commandPrefix) + }) -const modulePackageSchema = Tsu.Object({ +export const modulePackageSchema = Tsu.Object({ name: Tsu.Custom((input) => { if (typeof input !== 'string') return false /* package name must prefix with 'kotori-plugin-' if don't have scope */ @@ -253,7 +256,7 @@ const modulePackageSchema = Tsu.Object({ }) export class Loader extends Core { - private loadCount = 0 + private loadRecord = new Set() private isDev: boolean @@ -265,7 +268,7 @@ export class Loader extends Core { public readonly options: Options - public readonly [Symbols.modules]: Map = new Map() + public readonly [Symbols.modules]: Map?]> = new Map() public constructor(loaderOptions?: LoaderOptions) { const baseDir = getBaseDir(loaderOptions?.config || CONFIG_NAME, loaderOptions?.dir) @@ -372,8 +375,9 @@ export class Loader extends Core { const pkg = data.instance.name ? this[Symbols.modules].get(data.instance.name) : undefined if (!pkg) return - this.loadCount += 1 const { name, version, author } = pkg[0].pkg + if (this.loadRecord.has(name)) return + this.loadRecord.add(name) this.logger.info( this.format('loader.modules.load', [name, version, Array.isArray(author) ? author.join(',') : author]) ) @@ -451,6 +455,7 @@ export class Loader extends Core { } const parsed = (schema: Parser) => { + this[Symbols.modules].set(pkg.name, [instance, origin, schema]) const result = (schema as Parser).parseSafe(config) if (!result.value) throw new ModuleError(`config format of module ${pkg.name} is error: ${result.error.message}`) return result.data @@ -506,7 +511,7 @@ export class Loader extends Core { const handleModules = Array.from(this[Symbols.modules].values()).sort( (m1, m2) => moduleLoaderOrder(m1[0].pkg) - moduleLoaderOrder(m2[0].pkg) ) - for (const el of handleModules) this.loadEx(...el) + for (const el of handleModules) this.loadEx(el[0], el[1]) if (this.isDev) { for (const data of this[Symbols.modules].values()) { @@ -514,15 +519,15 @@ export class Loader extends Core { fs.watchFile(file, () => { this.logger.debug(this.format('loader.debug.reload', [data[0].pkg.name])) this.unloadEx(data[0]) - this.loadEx(...data) + this.loadEx(data[0], data[1]) }) } } } - const failLoadCount = this[Symbols.modules].size - this.loadCount + const failLoadCount = this[Symbols.modules].size - this.loadRecord.size this.logger.info( - this.format(`loader.modules.all${failLoadCount > 0 ? '.failed' : ''}`, [this.loadCount, failLoadCount]) + this.format(`loader.modules.all${failLoadCount > 0 ? '.failed' : ''}`, [this.loadRecord.size, failLoadCount]) ) this.loadAllAdapter() this.emit('ready') @@ -547,7 +552,7 @@ export class Loader extends Core { try { const bot = new array[0]( - this.extends({}, `${botConfig.extends}/${identity}`), + this.extends(`${botConfig.extends}/${identity}`), result ? (result.data as AdapterConfig) : botConfig, identity ) diff --git a/packages/loader/src/service/database.ts b/packages/loader/src/service/database.ts index c1bcda83..1f216a97 100644 --- a/packages/loader/src/service/database.ts +++ b/packages/loader/src/service/database.ts @@ -2,36 +2,64 @@ import { type Context, Service } from '@kotori-bot/core' import { Level } from 'level' import { resolve } from 'node:path' -class DatabaseReality extends Service<{ prefix: string }> { - private readonly db: Level +type DbValue = string | number | object + +class Database extends Service<{ prefix: string }> { + public readonly level: Level + + private prefixKey(key: string): string { + return `${this.ctx.identity?.toString() ?? ''}:${key}` + } public constructor(ctx: Context, config: { prefix: string }) { super(ctx, config, 'database') - this.db = new Level(resolve(this.ctx.baseDir.data, 'db'), { prefix: this.config.prefix }) + this.level = new Level(resolve(this.ctx.baseDir.data, 'db'), { prefix: this.config.prefix }) } public start() { - this.db.open().then(() => this.ctx.logger.record(`Database opened with prefix: ${this.config.prefix}`)) + this.level.open().then(() => this.ctx.logger.record(`Database opened with prefix: ${this.config.prefix}`)) } public stop() { - this.db.close() + this.level.close() } -} - -export type Database = Level & DatabaseReality - -export const Database = new Proxy(DatabaseReality, { - construct: (target, argArray, newTarget) => - new Proxy(Reflect.construct(target, argArray, newTarget), { - get: (target, prop, receiver) => { - if (prop in target) return Reflect.get(target, prop, receiver) - const db = Reflect.get(target, 'db', receiver) - if (!(prop in db)) return undefined - const value = Reflect.get(db, prop, receiver) - return typeof value === 'function' ? value.bind(db) : value + public async get(key: string, init?: Exclude): Promise { + try { + const value = await this.level.get(this.prefixKey(key)) + return JSON.parse(value) + } catch (error) { + if (error && typeof error === 'object' && 'notFound' in error) { + if (init === undefined) return null as T + await this.put(key, init) + return init } - }) -}) as typeof DatabaseReality + throw error + } + } + + public async getMany(keys: string[]): Promise { + const values = await this.level.getMany(keys.map(this.prefixKey.bind(this))) + return values.map((value) => JSON.parse(value)) + } + + public async put(key: string, value: T): Promise { + await this.level.put(this.prefixKey(key), JSON.stringify(value)) + } + + public async del(key: string): Promise { + await this.level.del(this.prefixKey(key)) + } + + public async batch( + operations: Array<{ type: 'put'; key: string; value: T } | { type: 'del'; key: string }> + ): Promise { + const prefixedOps = operations.map((op) => ({ + ...op, + key: this.prefixKey(op.key), + value: 'value' in op ? JSON.stringify(op.value) : '' + })) as Array & { value: string }> + return this.level.batch(prefixedOps) + } +} export default Database diff --git a/packages/tools/src/common/function.ts b/packages/tools/src/common/function.ts index 5b511aef..e5110b61 100644 --- a/packages/tools/src/common/function.ts +++ b/packages/tools/src/common/function.ts @@ -55,3 +55,7 @@ export function stringFormat(template: string, args: (string | number)[]) { }) return str } + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(() => resolve(ms), ms)) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71376f9e..cbdd6d8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: imap-simple: specifier: ^5.1.0 version: 5.1.0 + kotori-bot: + specifier: workspace:^ + version: link:../../packages/kotori mailparser: specifier: ^3.7.1 version: 3.7.1 @@ -316,6 +319,15 @@ importers: specifier: workspace:^ version: link:../../packages/kotori + modules/webui: + dependencies: + '@kotori-bot/kotori-plugin-filter': + specifier: workspace:^ + version: link:../filter + kotori-bot: + specifier: workspace:^ + version: link:../../packages/kotori + packages/core: dependencies: '@kotori-bot/i18n':