From 54b06872ebcd6854c53ee28c653366c5e4a813d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Thu, 8 Aug 2024 20:21:44 +0800 Subject: [PATCH] chore: old webui Co-Authored-By: Wesley F. Young <25684570+Wesley-Young@users.noreply.github.com> --- src/common/utils/{type.ts => types.ts} | 0 src/core/core.ts | 20 +- src/core/index.ts | 3 +- src/core/wrapper/context.ts | 16 + src/core/wrapper/index.ts | 4 + src/liteloader/napcat.ts | 69 ++- src/onebot/index.ts | 10 +- src/shell/napcat.ts | 53 ++- src/webui/Readme.md | 2 + src/webui/index.ts | 45 ++ src/webui/src/api/Auth.ts | 68 +++ src/webui/src/api/LogConsole.ts | 54 +++ src/webui/src/api/OB11Config.ts | 97 +++++ src/webui/src/api/QQLogin.ts | 77 ++++ src/webui/src/helper/Data.ts | 83 ++++ src/webui/src/helper/SignToken.ts | 68 +++ src/webui/src/helper/config.ts | 142 +++++++ src/webui/src/router/OB11Config.ts | 6 + src/webui/src/router/QQLogin.ts | 8 + src/webui/src/router/auth.ts | 9 + src/webui/src/router/index.ts | 65 +++ src/webui/ui/NapCat.ts | 392 ++++++++++++++++++ src/webui/ui/components/SettingButton.ts | 3 + src/webui/ui/components/SettingItem.ts | 15 + src/webui/ui/components/SettingList.ts | 14 + src/webui/ui/components/SettingOption.ts | 3 + src/webui/ui/components/SettingSelect.ts | 84 ++++ src/webui/ui/components/SettingSwitch.ts | 8 + src/webui/ui/components/WebUiApiOB11Config.ts | 74 ++++ src/webui/vite.config.ts | 13 + src/webui/webui.json | 8 + 31 files changed, 1441 insertions(+), 72 deletions(-) rename src/common/utils/{type.ts => types.ts} (100%) create mode 100644 src/core/wrapper/context.ts create mode 100644 src/core/wrapper/index.ts create mode 100644 src/webui/Readme.md create mode 100644 src/webui/index.ts create mode 100644 src/webui/src/api/Auth.ts create mode 100644 src/webui/src/api/LogConsole.ts create mode 100644 src/webui/src/api/OB11Config.ts create mode 100644 src/webui/src/api/QQLogin.ts create mode 100644 src/webui/src/helper/Data.ts create mode 100644 src/webui/src/helper/SignToken.ts create mode 100644 src/webui/src/helper/config.ts create mode 100644 src/webui/src/router/OB11Config.ts create mode 100644 src/webui/src/router/QQLogin.ts create mode 100644 src/webui/src/router/auth.ts create mode 100644 src/webui/src/router/index.ts create mode 100644 src/webui/ui/NapCat.ts create mode 100644 src/webui/ui/components/SettingButton.ts create mode 100644 src/webui/ui/components/SettingItem.ts create mode 100644 src/webui/ui/components/SettingList.ts create mode 100644 src/webui/ui/components/SettingOption.ts create mode 100644 src/webui/ui/components/SettingSelect.ts create mode 100644 src/webui/ui/components/SettingSwitch.ts create mode 100644 src/webui/ui/components/WebUiApiOB11Config.ts create mode 100644 src/webui/vite.config.ts create mode 100644 src/webui/webui.json diff --git a/src/common/utils/type.ts b/src/common/utils/types.ts similarity index 100% rename from src/common/utils/type.ts rename to src/common/utils/types.ts diff --git a/src/core/core.ts b/src/core/core.ts index c300a8dc..c8ed1c52 100644 --- a/src/core/core.ts +++ b/src/core/core.ts @@ -1,9 +1,8 @@ -import { LogWrapper } from "@/common/utils/log"; -import { NodeIQQNTWrapperSession, WrapperNodeApi } from "./wrapper/wrapper"; +import { WrapperNodeApi } from "./wrapper/wrapper"; import path from "node:path"; import fs from "node:fs"; -import { NodeIKernelLoginService } from "./services"; -import { SelfInfo } from "./entities"; +import { InstanceContext } from "./wrapper"; +import { NTEventChannel } from "@/common/framework/event"; export enum NapCatCoreWorkingEnv { Unknown = 0, @@ -21,22 +20,13 @@ export function loadQQWrapper(QQVersion: string): WrapperNodeApi { return nativemodule.exports; } -export interface InstanceContext { - readonly workingEnv: NapCatCoreWorkingEnv; - readonly core: NapCatCore; - readonly wrapper: WrapperNodeApi; - readonly session: NodeIQQNTWrapperSession; - readonly logger: LogWrapper; - readonly loginService: NodeIKernelLoginService; - readonly selfInfo: SelfInfo; - readonly QQVersion: string; -} - export class NapCatCore { readonly context: InstanceContext; + readonly eventChannel: NTEventChannel; constructor(context: InstanceContext) { this.context = context; + this.eventChannel = new NTEventChannel(context.wrapper, context.session); } // Renamed from 'InitDataListener' diff --git a/src/core/index.ts b/src/core/index.ts index 02bb11c7..9e15dc13 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1 +1,2 @@ -export * from './core'; \ No newline at end of file +export * from './core'; +export * from './wrapper' \ No newline at end of file diff --git a/src/core/wrapper/context.ts b/src/core/wrapper/context.ts new file mode 100644 index 00000000..9c6812f0 --- /dev/null +++ b/src/core/wrapper/context.ts @@ -0,0 +1,16 @@ +import { LogWrapper } from "@/common/utils/log"; +import { QQBasicInfoWrapper } from "@/common/utils/QQBasicInfo"; +import { NapCatCoreWorkingEnv } from "../core"; +import { SelfInfo } from "../entities"; +import { NodeIKernelLoginService } from "../services"; +import { WrapperNodeApi, NodeIQQNTWrapperSession } from "./wrapper"; + +export interface InstanceContext { + readonly workingEnv: NapCatCoreWorkingEnv; + readonly wrapper: WrapperNodeApi; + readonly session: NodeIQQNTWrapperSession; + readonly logger: LogWrapper; + readonly loginService: NodeIKernelLoginService; + readonly selfInfo: SelfInfo; + readonly basicInfoWrapper: QQBasicInfoWrapper; +} diff --git a/src/core/wrapper/index.ts b/src/core/wrapper/index.ts new file mode 100644 index 00000000..2f85aeac --- /dev/null +++ b/src/core/wrapper/index.ts @@ -0,0 +1,4 @@ +export * from './wrapper'; +export * from './data'; +export * from './helper'; +export * from './context'; \ No newline at end of file diff --git a/src/liteloader/napcat.ts b/src/liteloader/napcat.ts index ffbc01ae..0c7d9f23 100644 --- a/src/liteloader/napcat.ts +++ b/src/liteloader/napcat.ts @@ -1,14 +1,13 @@ import { NTEventChannel } from "@/common/framework/event"; import { NapCatPathWrapper } from "@/common/framework/napcat"; -import { sleep } from "@/common/utils/helper"; import { LogWrapper } from "@/common/utils/log"; import { proxiedListenerOf } from "@/common/utils/proxy-handler"; import { QQBasicInfoWrapper } from "@/common/utils/QQBasicInfo"; -import { NapCatCoreWorkingEnv, loadQQWrapper } from "@/core/core"; +import { NapCatCore, NapCatCoreWorkingEnv, loadQQWrapper } from "@/core/core"; +import { InstanceContext } from "@/core"; import { SelfInfo } from "@/core/entities"; import { LoginListener } from "@/core/listeners"; import { NodeIKernelLoginService } from "@/core/services"; -import { selfInfo } from "@/core/wrapper/data"; import { WrapperNodeApi, NodeIQQNTWrapperSession } from "@/core/wrapper/wrapper"; import { NapCatOneBot11Adapter } from "@/onebot"; @@ -16,59 +15,53 @@ import { NapCatOneBot11Adapter } from "@/onebot"; export async function NCoreInitLiteLoader(session: NodeIQQNTWrapperSession, loginService: NodeIKernelLoginService) { //在进入本层前是否登录未进行判断 console.log("NapCat LiteLoader App Loading..."); - let Basicframework = new NapCatPathWrapper(); - let logger = new LogWrapper(Basicframework.logsPath); - let BasicInfo = new QQBasicInfoWrapper({ logger }); - let LLNC = new NapCatLiteLoader(logger, session, loginService, BasicInfo); - + let pathWrapper = new NapCatPathWrapper(); + let logger = new LogWrapper(pathWrapper.logsPath); + let basicInfoWrapper = new QQBasicInfoWrapper({ logger }); + let wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion()); //直到登录成功后,执行下一步 let selfInfo = await new Promise((resolve) => { - let OBLoginListener = new LoginListener(); - OBLoginListener.onQRCodeLoginSucceed = async (loginResult) => resolve({ + let loginListener = new LoginListener(); + loginListener.onQRCodeLoginSucceed = async (loginResult) => resolve({ uid: loginResult.uid, uin: loginResult.uin, nick: '', // 获取不到 online: true }); - loginService.addKernelLoginListener(new LLNC.wrapper.NodeIKernelLoginListener(proxiedListenerOf(OBLoginListener, logger))); + loginService.addKernelLoginListener(new wrapper.NodeIKernelLoginListener( + proxiedListenerOf(loginListener, logger))); }); + + // 初始化 NapCatLiteLoader + let loaderObject = new NapCatLiteLoader(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper); + //启动WebUi //初始化LLNC的Onebot实现 - new NapCatOneBot11Adapter(); - + new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context); } export class NapCatLiteLoader { - public workingEnv: NapCatCoreWorkingEnv = NapCatCoreWorkingEnv.LiteLoader; - public wrapper: WrapperNodeApi; - public EventChannel: NTEventChannel; - public session: NodeIQQNTWrapperSession; - public logger: LogWrapper; - public loginListener: LoginListener; - //public core: NapCatCore; + public core: NapCatCore; + context: InstanceContext; + constructor( - logger: LogWrapper, + wrapper: WrapperNodeApi, session: NodeIQQNTWrapperSession, + logger: LogWrapper, loginService: NodeIKernelLoginService, - QQBasic: QQBasicInfoWrapper + selfInfo: SelfInfo, + basicInfoWrapper: QQBasicInfoWrapper, ) { - this.session = session; - this.logger = logger; - //context保存 - this.wrapper = loadQQWrapper(QQBasic.getFullQQVesion()); - //载入Wrapper.node - this.EventChannel = new NTEventChannel(this.wrapper, session); - this.loginListener = new LoginListener(); - this.loginListener.onQRCodeLoginSucceed = async (arg) => { - await sleep(2500); // TODO: 等待登录完成 init那堆不知道多久完成 搞清楚之前先用个sleep 2500顶着 - selfInfo.uin = arg.uin; - selfInfo.uid = arg.uid; - // 保存基础登录信息 - // 初始化DataListener + this.context = { + workingEnv: NapCatCoreWorkingEnv.LiteLoader, + wrapper, + session, + logger, + loginService, + selfInfo, + basicInfoWrapper }; - loginService.addKernelLoginListener(new this.wrapper.NodeIKernelLoginListener( - proxiedListenerOf(this.loginListener, logger) - )); + this.core = new NapCatCore(this.context); } } diff --git a/src/onebot/index.ts b/src/onebot/index.ts index fe25f932..0a00655c 100644 --- a/src/onebot/index.ts +++ b/src/onebot/index.ts @@ -1,4 +1,12 @@ +import { InstanceContext, NapCatCore } from "@/core"; + //OneBot实现类 export class NapCatOneBot11Adapter{ - + readonly core: NapCatCore; + readonly context: InstanceContext; + + constructor(core: NapCatCore, context: InstanceContext) { + this.core = core; + this.context = context; + } } \ No newline at end of file diff --git a/src/shell/napcat.ts b/src/shell/napcat.ts index fc9c0827..ef4ceff4 100644 --- a/src/shell/napcat.ts +++ b/src/shell/napcat.ts @@ -1,26 +1,49 @@ -import { NapCatPathWrapper } from "@/common/framework/napcat"; +import type { WrapperNodeApi, NodeIQQNTWrapperEngine, NodeQQNTWrapperUtil, NodeIQQNTWrapperSession } from "@/core/wrapper/wrapper"; +import type { NodeIKernelLoginService } from "@/core/services"; +import type { NapCatCore } from "@/core"; +import type { SelfInfo } from "@/core/entities"; + import { LogWrapper } from "@/common/utils/log"; +import { LoginListener, SessionListener } from "@/core/listeners"; +import { DependsAdapter, DispatcherAdapter, GlobalAdapter } from "@/core/adapters"; +import { NapCatPathWrapper } from "@/common/framework/napcat"; +import { NapCatCoreWorkingEnv, loadQQWrapper } from "@/core"; import { QQBasicInfoWrapper } from "@/common/utils/QQBasicInfo"; import { hostname, systemVersion } from "@/common/utils/system"; -import { DependsAdapter, DispatcherAdapter, GlobalAdapter } from "@/core/adapters"; -import { NapCatCoreWorkingEnv, NapCatCore, loadQQWrapper } from "@/core/core"; -import { LoginListener, SessionListener } from "@/core/listeners"; -import { NodeIKernelLoginService } from "@/core/services"; -import { WrapperNodeApi, NodeIQQNTWrapperEngine, NodeQQNTWrapperUtil, NodeIQQNTWrapperSession } from "@/core/wrapper/wrapper"; +import { genSessionConfig } from "@/core/wrapper/helper"; +import { proxiedListenerOf } from "@/common/utils/proxy-handler"; + import path from "path"; import fs from "fs"; import os from "os"; -import { genSessionConfig } from "@/core/wrapper/helper"; -import { selfInfo } from "@/core/wrapper/data"; + + // NapCat Shell App ES 入口文件 export async function NCoreInitShell() { console.log("NapCat Shell App Loading..."); - let Basicframework = new NapCatPathWrapper(); - let logger = new LogWrapper(Basicframework.logsPath); - let BasicInfo = new QQBasicInfoWrapper({ logger }); - new NapCatShell(logger, BasicInfo); + + let pathWrapper = new NapCatPathWrapper(); + let logger = new LogWrapper(pathWrapper.logsPath); + let basicInfoWrapper = new QQBasicInfoWrapper({ logger }); + let wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion()); + + let selfInfo = await new Promise((resolve) => { + let loginListener = new LoginListener(); + loginListener.onQRCodeLoginSucceed = async (loginResult) => resolve({ + uid: loginResult.uid, + uin: loginResult.uin, + nick: '', // 获取不到 + online: true + }); + loginService.addKernelLoginListener(); + + let ShellNapCat = new NapCatShell(logger, BasicInfo); + ShellNapCat.loginService.addKernelLoginListener(new wrapper.NodeIKernelLoginListener( + proxiedListenerOf(loginListener, logger))); +}) } + export class NapCatShell { public QQWrapper: WrapperNodeApi; public WorkMode: NapCatCoreWorkingEnv = NapCatCoreWorkingEnv.Shell; @@ -43,6 +66,7 @@ export class NapCatShell { get dataPathGlobal(): string { return path.resolve(this.dataPath, './nt_qq/global'); } + private initSession(BasicInfo: QQBasicInfoWrapper, uin: string, uid: string): Promise { return new Promise(async (res, rej) => { if (!BasicInfo.QQVersionAppid) throw new Error("QQVersionAppid must be provided"); @@ -54,11 +78,6 @@ export class NapCatShell { } rej(r); }; - // const oldOnSendOidbRepl = this.session.onSendOidbRepl; - // this.session.onSendOidbRepl = (...args: unknown[]) => { - // console.log('onSendOidbRepl', args); - // return oldOnSendOidbRepl(...args); - // }; this.session.init(sessionConfig, new this.QQWrapper.NodeIDependsAdapter(new DependsAdapter()), new this.QQWrapper.NodeIDispatcherAdapter(new DispatcherAdapter()), diff --git a/src/webui/Readme.md b/src/webui/Readme.md new file mode 100644 index 00000000..c79d50bd --- /dev/null +++ b/src/webui/Readme.md @@ -0,0 +1,2 @@ +# The Path of NapCatQQ +Tiny WebUi for NapCatQQ \ No newline at end of file diff --git a/src/webui/index.ts b/src/webui/index.ts new file mode 100644 index 00000000..ed65bd81 --- /dev/null +++ b/src/webui/index.ts @@ -0,0 +1,45 @@ +import express from 'express'; +import { NextFunction, Request, Response } from 'express'; +import { AuthHelper } from './src/helper/SignToken'; +import { resolve } from 'node:path'; +import { ALLRouter } from './src/router'; +import { WebUiConfig } from './src/helper/config'; +const app = express(); +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { log } from '@/common/utils/log'; + + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * 初始化并启动WebUI服务。 + * 该函数配置了Express服务器以支持JSON解析和静态文件服务,并监听6099端口。 + * 无需参数。 + * @returns {Promise} 无返回值。 + */ +export async function InitWebUi() { + const config = await WebUiConfig.GetWebUIConfig(); + if (config.port == 0) { + log('[NapCat] [WebUi] Current WebUi is not run.'); + return; + } + app.use(express.json()); + // 初始服务 + // WebUI只在config.prefix所示路径上提供服务,可配合Nginx挂载到子目录中 + app.all(config.prefix + '/', (_req, res) => { + res.json({ + msg: 'NapCat WebAPI is now running!', + }); + }); + // 配置静态文件服务,提供./static目录下的文件服务,访问路径为/webui + app.use(config.prefix + '/webui', express.static(resolve(__dirname, './static'))); + //挂载API接口 + app.use(config.prefix + '/api', ALLRouter); + app.listen(config.port, config.host, async () => { + log(`[NapCat] [WebUi] Current WebUi is running at http://${config.host}:${config.port}${config.prefix}`); + log(`[NapCat] [WebUi] Login URL is http://${config.host}:${config.port}${config.prefix}/webui`); + log(`[NapCat] [WebUi] Login Token is ${config.token}`); + }); +} diff --git a/src/webui/src/api/Auth.ts b/src/webui/src/api/Auth.ts new file mode 100644 index 00000000..7f8e9d8c --- /dev/null +++ b/src/webui/src/api/Auth.ts @@ -0,0 +1,68 @@ +import { RequestHandler } from 'express'; +import { AuthHelper } from '../helper/SignToken'; +import { WebUiConfig } from '../helper/config'; +import { WebUiDataRuntime } from '../helper/Data'; +const isEmpty = (data: any) => data === undefined || data === null || data === ''; +export const LoginHandler: RequestHandler = async (req, res) => { + const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + const { token } = req.body; + if (isEmpty(token)) { + res.json({ + code: -1, + message: 'token is empty' + }); + return; + } + if (!await WebUiDataRuntime.checkLoginRate(WebUiConfigData.loginRate)) { + res.json({ + code: -1, + message: 'login rate limit' + }); + return; + } + //验证config.token是否等于token + if (WebUiConfigData.token !== token) { + res.json({ + code: -1, + message: 'token is invalid' + }); + return; + } + const signCredential = Buffer.from(JSON.stringify(await AuthHelper.signCredential(WebUiConfigData.token))).toString('base64'); + res.json({ + code: 0, + message: 'success', + data: { + 'Credential': signCredential + } + }); + return; +}; +export const LogoutHandler: RequestHandler = (req, res) => { + // 这玩意无状态销毁个灯 得想想办法 + res.json({ + code: 0, + message: 'success' + }); + return; +}; +export const checkHandler: RequestHandler = async (req, res) => { + const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + const authorization = req.headers.authorization; + try { + const CredentialBase64:string = authorization?.split(' ')[1] as string; + const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString()); + await AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token,Credential); + res.json({ + code: 0, + message: 'success' + }); + return; + } catch (e) { + res.json({ + code: -1, + message: 'failed' + }); + } + return; +}; diff --git a/src/webui/src/api/LogConsole.ts b/src/webui/src/api/LogConsole.ts new file mode 100644 index 00000000..68eec759 --- /dev/null +++ b/src/webui/src/api/LogConsole.ts @@ -0,0 +1,54 @@ +import { RequestHandler } from 'express'; +import { resolve } from 'path'; +import { readdir, stat } from 'fs/promises'; +import { existsSync } from 'fs'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +export const GetLogFileListHandler: RequestHandler = async (req, res) => { + try { + const LogsPath = resolve(__dirname, './logs/'); + const LogFiles = await readdir(LogsPath); + res.json({ + code: 0, + data: LogFiles + }); + } catch (error) { + res.json({ code: -1, msg: 'Failed to retrieve log file list.' }); + } +}; + +export const GetLogFileHandler: RequestHandler = async (req, res) => { + const LogsPath = resolve(__dirname, './logs/'); + const LogFile = req.query.file as string; + + // if (!isValidFileName(LogFile)) { + // res.json({ code: -1, msg: 'LogFile is not safe' }); + // return; + // } + + const filePath = `${LogsPath}/${LogFile}`; + if (!existsSync(filePath)) { + res.status(404).json({ code: -1, msg: 'LogFile does not exist' }); + return; + } + + try { + const fileStats = await stat(filePath); + if (!fileStats.isFile()) { + res.json({ code: -1, msg: 'LogFile must be a file' }); + return; + } + + res.sendFile(filePath); + } catch (error) { + res.json({ code: -1, msg: 'Failed to send log file.' }); + } +}; +// export function isValidFileName(fileName: string): boolean { +// const invalidChars = /[\.\:\*\?\"\<\>\|\/\\]/; +// return !invalidChars.test(fileName); +// } \ No newline at end of file diff --git a/src/webui/src/api/OB11Config.ts b/src/webui/src/api/OB11Config.ts new file mode 100644 index 00000000..1a3ffd50 --- /dev/null +++ b/src/webui/src/api/OB11Config.ts @@ -0,0 +1,97 @@ +import { RequestHandler } from 'express'; +import { WebUiDataRuntime } from '../helper/Data'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { OB11Config } from '@/webui/ui/components/WebUiApiOB11Config'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const isEmpty = (data: any) => + data === undefined || data === null || data === ''; +export const OB11GetConfigHandler: RequestHandler = async (req, res) => { + const isLogin = await WebUiDataRuntime.getQQLoginStatus(); + if (!isLogin) { + res.send({ + code: -1, + message: 'Not Login', + }); + return; + } + const uin = await WebUiDataRuntime.getQQLoginUin(); + const configFilePath = resolve(__dirname, `./config/onebot11_${uin}.json`); + //console.log(configFilePath); + let data: OB11Config; + try { + data = JSON.parse( + existsSync(configFilePath) + ? readFileSync(configFilePath).toString() + : readFileSync(resolve(__dirname, './config/onebot11.json')).toString() + ); + } catch (e) { + data = {} as OB11Config; + res.send({ + code: -1, + message: 'Config Get Error', + }); + return; + } + res.send({ + code: 0, + message: 'success', + data: data, + }); + return; +}; +export const OB11SetConfigHandler: RequestHandler = async (req, res) => { + const isLogin = await WebUiDataRuntime.getQQLoginStatus(); + if (!isLogin) { + res.send({ + code: -1, + message: 'Not Login', + }); + return; + } + if (isEmpty(req.body.config)) { + res.send({ + code: -1, + message: 'config is empty', + }); + return; + } + let SetResult; + try { + await WebUiDataRuntime.setOB11Config(JSON.parse(req.body.config)); + SetResult = true; + } catch (e) { + SetResult = false; + } + + // let configFilePath = resolve(__dirname, `./config/onebot11_${await WebUiDataRuntime.getQQLoginUin()}.json`); + // try { + // JSON.parse(req.body.config) + // readFileSync(configFilePath); + // } + // catch (e) { + // //console.log(e); + // configFilePath = resolve(__dirname, `./config/onebot11.json`); + // } + // //console.log(configFilePath,JSON.parse(req.body.config)); + // writeFileSync(configFilePath, JSON.stringify(JSON.parse(req.body.config), null, 4)); + if (SetResult) { + res.send({ + code: 0, + message: 'success', + }); + } else { + res.send({ + code: -1, + message: 'Config Set Error', + }); + } + + return; +}; diff --git a/src/webui/src/api/QQLogin.ts b/src/webui/src/api/QQLogin.ts new file mode 100644 index 00000000..84355f62 --- /dev/null +++ b/src/webui/src/api/QQLogin.ts @@ -0,0 +1,77 @@ +import { RequestHandler } from 'express'; +import { WebUiDataRuntime } from '../helper/Data'; +import { sleep } from '@/common/utils/helper'; +const isEmpty = (data: any) => data === undefined || data === null || data === ''; +export const QQGetQRcodeHandler: RequestHandler = async (req, res) => { + if (await WebUiDataRuntime.getQQLoginStatus()) { + res.send({ + code: -1, + message: 'QQ Is Logined' + }); + return; + } + const qrcodeUrl = await WebUiDataRuntime.getQQLoginQrcodeURL(); + if (isEmpty(qrcodeUrl)) { + res.send({ + code: -1, + message: 'QRCode Get Error' + }); + return; + } + res.send({ + code: 0, + message: 'success', + data: { + qrcode: qrcodeUrl + } + }); + return; +}; +export const QQCheckLoginStatusHandler: RequestHandler = async (req, res) => { + res.send({ + code: 0, + message: 'success', + data: { + isLogin: await WebUiDataRuntime.getQQLoginStatus() + } + }); +}; +export const QQSetQuickLoginHandler: RequestHandler = async (req, res) => { + const { uin } = req.body; + const isLogin = await WebUiDataRuntime.getQQLoginStatus(); + if (isLogin) { + res.send({ + code: -1, + message: 'QQ Is Logined' + }); + return; + } + if (isEmpty(uin)) { + res.send({ + code: -1, + message: 'uin is empty' + }); + return; + } + const { result, message } = await WebUiDataRuntime.getQQQuickLogin(uin); + if (!result) { + res.send({ + code: -1, + message: message + }); + return; + } + //本来应该验证 但是http不宜这么搞 建议前端验证 + //isLogin = await WebUiDataRuntime.getQQLoginStatus(); + res.send({ + code: 0, + message: 'success' + }); +}; +export const QQGetQuickLoginListHandler: RequestHandler = async (req, res) => { + const quickLoginList = await WebUiDataRuntime.getQQQuickLoginList(); + res.send({ + code: 0, + data: quickLoginList + }); +}; \ No newline at end of file diff --git a/src/webui/src/helper/Data.ts b/src/webui/src/helper/Data.ts new file mode 100644 index 00000000..8be898c5 --- /dev/null +++ b/src/webui/src/helper/Data.ts @@ -0,0 +1,83 @@ +import { OB11Config } from '@/onebot11/config'; + +interface LoginRuntimeType { + LoginCurrentTime: number; + LoginCurrentRate: number; + QQLoginStatus: boolean; + QQQRCodeURL: string; + QQLoginUin: string; + NapCatHelper: { + CoreQuickLoginCall: (uin: string) => Promise<{ result: boolean, message: string }>; + SetOb11ConfigCall: (ob11: OB11Config) => Promise; + QQLoginList: string[] + } +} +const LoginRuntime: LoginRuntimeType = { + LoginCurrentTime: Date.now(), + LoginCurrentRate: 0, + QQLoginStatus: false, //已实现 但太傻了 得去那边注册个回调刷新 + QQQRCodeURL: '', + QQLoginUin: '', + NapCatHelper: { + SetOb11ConfigCall: async (ob11: OB11Config) => { return; }, + CoreQuickLoginCall: async (uin: string) => { return { result: false, message: '' }; }, + QQLoginList: [] + } +}; +export const WebUiDataRuntime = { + checkLoginRate: async function (RateLimit: number): Promise { + LoginRuntime.LoginCurrentRate++; + //console.log(RateLimit, LoginRuntime.LoginCurrentRate, Date.now() - LoginRuntime.LoginCurrentTime); + if (Date.now() - LoginRuntime.LoginCurrentTime > 1000 * 60) { + LoginRuntime.LoginCurrentRate = 0;//超出时间重置限速 + LoginRuntime.LoginCurrentTime = Date.now(); + return true; + } + if (LoginRuntime.LoginCurrentRate <= RateLimit) { + return true; + } + return false; + } + , + getQQLoginStatus: async function (): Promise { + return LoginRuntime.QQLoginStatus; + } + , + setQQLoginStatus: async function (status: boolean): Promise { + LoginRuntime.QQLoginStatus = status; + } + , + setQQLoginQrcodeURL: async function (url: string): Promise { + LoginRuntime.QQQRCodeURL = url; + } + , + getQQLoginQrcodeURL: async function (): Promise { + return LoginRuntime.QQQRCodeURL; + } + , + setQQLoginUin: async function (uin: string): Promise { + LoginRuntime.QQLoginUin = uin; + } + , + getQQLoginUin: async function (): Promise { + return LoginRuntime.QQLoginUin; + }, + getQQQuickLoginList: async function (): Promise { + return LoginRuntime.NapCatHelper.QQLoginList; + }, + setQQQuickLoginList: async function (list: string[]): Promise { + LoginRuntime.NapCatHelper.QQLoginList = list; + }, + setQQQuickLoginCall(func: (uin: string) => Promise<{ result: boolean, message: string }>): void { + LoginRuntime.NapCatHelper.CoreQuickLoginCall = func; + }, + getQQQuickLogin: async function (uin: string): Promise<{ result: boolean, message: string }> { + return await LoginRuntime.NapCatHelper.CoreQuickLoginCall(uin); + }, + setOB11ConfigCall: async function (func: (ob11: OB11Config) => Promise): Promise { + LoginRuntime.NapCatHelper.SetOb11ConfigCall = func; + }, + setOB11Config: async function (ob11: OB11Config): Promise { + await LoginRuntime.NapCatHelper.SetOb11ConfigCall(ob11); + } +}; \ No newline at end of file diff --git a/src/webui/src/helper/SignToken.ts b/src/webui/src/helper/SignToken.ts new file mode 100644 index 00000000..8803a9e1 --- /dev/null +++ b/src/webui/src/helper/SignToken.ts @@ -0,0 +1,68 @@ +import crypto from 'crypto'; + +interface WebUiCredentialInnerJson { + CreatedTime: number; + TokenEncoded: string; +} + +interface WebUiCredentialJson { + Data: WebUiCredentialInnerJson; + Hmac: string; +} + +export class AuthHelper { + private static secretKey = Math.random().toString(36).slice(2); + + /** + * 签名凭证方法。 + * @param token 待签名的凭证字符串。 + * @returns 签名后的凭证对象。 + */ + public static async signCredential(token: string): Promise { + const innerJson: WebUiCredentialInnerJson = { + CreatedTime: Date.now(), + TokenEncoded: token, + }; + const jsonString = JSON.stringify(innerJson); + const hmac = crypto.createHmac('sha256', AuthHelper.secretKey) + .update(jsonString, 'utf8') + .digest('hex'); + return { Data: innerJson, Hmac: hmac }; + } + + /** + * 检查凭证是否被篡改的方法。 + * @param credentialJson 凭证的JSON对象。 + * @returns 布尔值,表示凭证是否有效。 + */ + public static async checkCredential(credentialJson: WebUiCredentialJson): Promise { + try { + const jsonString = JSON.stringify(credentialJson.Data); + const calculatedHmac = crypto.createHmac('sha256', AuthHelper.secretKey) + .update(jsonString, 'utf8') + .digest('hex'); + return calculatedHmac === credentialJson.Hmac; + } catch (error) { + return false; + } + } + + /** + * 验证凭证在1小时内有效且token与原始token相同。 + * @param token 待验证的原始token。 + * @param credentialJson 已签名的凭证JSON对象。 + * @returns 布尔值,表示凭证是否有效且token匹配。 + */ + public static async validateCredentialWithinOneHour(token: string, credentialJson: WebUiCredentialJson): Promise { + const isValid = await AuthHelper.checkCredential(credentialJson); + if (!isValid) { + return false; + } + + const currentTime = Date.now() / 1000; + const createdTime = credentialJson.Data.CreatedTime; + const timeDifference = currentTime - createdTime; + + return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token; + } +} \ No newline at end of file diff --git a/src/webui/src/helper/config.ts b/src/webui/src/helper/config.ts new file mode 100644 index 00000000..44b77c75 --- /dev/null +++ b/src/webui/src/helper/config.ts @@ -0,0 +1,142 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import * as net from 'node:net'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { logError } from '@/common/utils/log'; + + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// 限制尝试端口的次数,避免死循环 +const MAX_PORT_TRY = 100; + +async function tryUseHost(host: string): Promise { + return new Promise(async (resolve, reject) => { + try { + const server = net.createServer(); + server.on('listening', () => { + server.close(); + resolve(host); + }); + + server.on('error', (err: any) => { + if (err.code === 'EADDRNOTAVAIL') { + reject('主机地址验证失败,可能为非本机地址'); + } else { + reject(`遇到错误: ${err.code}`); + } + }); + + // 尝试监听 让系统随机分配一个端口 + server.listen(0, host); + } catch (error) { + // 这里捕获到的错误应该是启动服务器时的同步错误 + reject(`服务器启动时发生错误: ${error}`); + } + }); +} + +async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise { + return new Promise(async (resolve, reject) => { + try { + const server = net.createServer(); + server.on('listening', () => { + server.close(); + resolve(port); + }); + + server.on('error', (err: any) => { + if (err.code === 'EADDRINUSE') { + if (tryCount < MAX_PORT_TRY) { + // 使用循环代替递归 + resolve(tryUsePort(port + 1, host, tryCount + 1)); + } else { + reject(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`); + } + } else { + reject(`遇到错误: ${err.code}`); + } + }); + + // 尝试监听端口 + server.listen(port, host); + } catch (error) { + // 这里捕获到的错误应该是启动服务器时的同步错误 + reject(`服务器启动时发生错误: ${error}`); + } + }); +} + +export interface WebUiConfigType { + host: string; + port: number; + prefix: string; + token: string; + loginRate: number +} +// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件 +class WebUiConfigWrapper { + WebUiConfigData: WebUiConfigType | undefined = undefined; + private applyDefaults(obj: Partial, defaults: T): T { + return { ...defaults, ...obj }; + } + async GetWebUIConfig(): Promise { + if (this.WebUiConfigData) { + return this.WebUiConfigData; + } + const defaultconfig: WebUiConfigType = { + host: '0.0.0.0', + port: 6099, + prefix: '', + token: '', // 默认先填空,空密码无法登录 + loginRate: 3 + }; + try { + defaultconfig.token = Math.random().toString(36).slice(2); //生成随机密码 + } catch (e) { + logError('随机密码生成失败', e); + } + try { + const configPath = resolve(__dirname, './config/webui.json'); + + if (!existsSync(configPath)) { + writeFileSync(configPath, JSON.stringify(defaultconfig, null, 4)); + } + + const fileContent = readFileSync(configPath, 'utf-8'); + // 更新配置字段后新增字段可能会缺失,同步一下 + const parsedConfig = this.applyDefaults(JSON.parse(fileContent) as Partial, defaultconfig); + + if (!parsedConfig.prefix.startsWith('/')) parsedConfig.prefix = '/' + parsedConfig.prefix; + if (parsedConfig.prefix.endsWith('/')) parsedConfig.prefix = parsedConfig.prefix.slice(0, -1); + // 配置已经被操作过了,还是回写一下吧,不然新配置不会出现在配置文件里 + writeFileSync(configPath, JSON.stringify(parsedConfig, null, 4)); + // 不希望回写的配置放后面 + + // 查询主机地址是否可用 + const [host_err, host] = await tryUseHost(parsedConfig.host).then(data => [null, data as string]).catch(err => [err, null]); + if (host_err) { + logError('host不可用', host_err); + parsedConfig.port = 0; // 设置为0,禁用WebUI + } else { + parsedConfig.host = host; + // 修正端口占用情况 + const [port_err, port] = await tryUsePort(parsedConfig.port, parsedConfig.host).then(data => [null, data as number]).catch(err => [err, null]); + if (port_err) { + logError('port不可用', port_err); + parsedConfig.port = 0; // 设置为0,禁用WebUI + } else { + parsedConfig.port = port; + } + } + this.WebUiConfigData = parsedConfig; + return this.WebUiConfigData; + } catch (e) { + logError('读取配置文件失败', e); + } + return defaultconfig; // 理论上这行代码到不了,到了只能返回默认配置了 + } +} +export const WebUiConfig = new WebUiConfigWrapper(); diff --git a/src/webui/src/router/OB11Config.ts b/src/webui/src/router/OB11Config.ts new file mode 100644 index 00000000..c9fae805 --- /dev/null +++ b/src/webui/src/router/OB11Config.ts @@ -0,0 +1,6 @@ +import { Router } from 'express'; +import { OB11GetConfigHandler,OB11SetConfigHandler } from '../api/OB11Config'; +const router = Router(); +router.post('/GetConfig', OB11GetConfigHandler); +router.post('/SetConfig', OB11SetConfigHandler); +export { router as OB11ConfigRouter }; \ No newline at end of file diff --git a/src/webui/src/router/QQLogin.ts b/src/webui/src/router/QQLogin.ts new file mode 100644 index 00000000..61fccd80 --- /dev/null +++ b/src/webui/src/router/QQLogin.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { QQCheckLoginStatusHandler, QQGetQRcodeHandler, QQGetQuickLoginListHandler, QQSetQuickLoginHandler } from '../api/QQLogin'; +const router = Router(); +router.all('/GetQuickLoginList', QQGetQuickLoginListHandler); +router.post('/CheckLoginStatus', QQCheckLoginStatusHandler); +router.post('/GetQQLoginQrcode', QQGetQRcodeHandler); +router.post('/SetQuickLogin', QQSetQuickLoginHandler); +export { router as QQLoginRouter }; \ No newline at end of file diff --git a/src/webui/src/router/auth.ts b/src/webui/src/router/auth.ts new file mode 100644 index 00000000..c61367ec --- /dev/null +++ b/src/webui/src/router/auth.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { LoginHandler, LogoutHandler, checkHandler } from '../api/Auth'; + +const router = Router(); + +router.post('/login', LoginHandler); +router.post('/check', checkHandler); +router.post('/logout', LogoutHandler); +export { router as AuthRouter }; \ No newline at end of file diff --git a/src/webui/src/router/index.ts b/src/webui/src/router/index.ts new file mode 100644 index 00000000..6efd6e76 --- /dev/null +++ b/src/webui/src/router/index.ts @@ -0,0 +1,65 @@ +import { Router } from 'express'; +import { AuthHelper } from '../../src/helper/SignToken'; +import { NextFunction, Request, Response } from 'express'; +import { QQLoginRouter } from './QQLogin'; +import { AuthRouter } from './auth'; +import { OB11ConfigRouter } from './OB11Config'; +import { WebUiConfig } from '../helper/config'; +const router = Router(); +export async function AuthApi(req: Request, res: Response, next: NextFunction) { + //判断当前url是否为/login 如果是跳过鉴权 + if (req.url == '/auth/login') { + next(); + return; + } + if (req.headers?.authorization) { + const authorization = req.headers.authorization.split(' '); + if (authorization.length < 2) { + res.json({ + code: -1, + msg: 'Unauthorized', + }); + return; + } + const token = authorization[1]; + let Credential: any; + try { + Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); + } catch (e) { + res.json({ + code: -1, + msg: 'Unauthorized', + }); + return; + } + const config = await WebUiConfig.GetWebUIConfig(); + const credentialJson = await AuthHelper.validateCredentialWithinOneHour(config.token, Credential); + if (credentialJson) { + //通过验证 + next(); + return; + } + res.json({ + code: -1, + msg: 'Unauthorized', + }); + return; + } + + res.json({ + code: -1, + msg: 'Server Error', + }); + return; +} +router.use(AuthApi); +router.all('/test', (req, res) => { + res.json({ + code: 0, + msg: 'ok', + }); +}); +router.use('/auth', AuthRouter); +router.use('/QQLogin', QQLoginRouter); +router.use('/OB11Config', OB11ConfigRouter); +export { router as ALLRouter }; \ No newline at end of file diff --git a/src/webui/ui/NapCat.ts b/src/webui/ui/NapCat.ts new file mode 100644 index 00000000..2f3f8108 --- /dev/null +++ b/src/webui/ui/NapCat.ts @@ -0,0 +1,392 @@ +import { SettingList } from './components/SettingList'; +import { SettingItem } from './components/SettingItem'; +import { SettingButton } from './components/SettingButton'; +import { SettingSwitch } from './components/SettingSwitch'; +import { SettingSelect } from './components/SettingSelect'; +import { OB11Config, OB11ConfigWrapper } from './components/WebUiApiOB11Config'; +async function onSettingWindowCreated(view: Element) { + const isEmpty = (value: any) => value === undefined || value === undefined || value === ''; + await OB11ConfigWrapper.Init(localStorage.getItem('auth') as string); + const ob11Config: OB11Config = await OB11ConfigWrapper.GetOB11Config(); + const setOB11Config = (key: string, value: any) => { + const configKey = key.split('.'); + if (configKey.length === 2) { + ob11Config[configKey[1]] = value; + } else if (configKey.length === 3) { + ob11Config[configKey[1]][configKey[2]] = value; + } + // OB11ConfigWrapper.SetOB11Config(ob11Config); // 只有当点保存时才下发配置,而不是在修改值后立即下发 + }; + + const parser = new DOMParser(); + const doc = parser.parseFromString( + [ + '
', + ` +
+
`, + SettingList([ + SettingItem( + 'Napcat', + undefined, + SettingButton('V1.8.3', 'napcat-update-button', 'secondary') + ), + ]), + SettingList([ + SettingItem( + '启用 HTTP 服务', + undefined, + SettingSwitch('ob11.http.enable', ob11Config.http.enable, { + 'control-display-id': 'config-ob11-http-port', + }) + ), + SettingItem( + 'HTTP 服务监听端口', + undefined, + `
`, + 'config-ob11-http-port', + ob11Config.http.enable + ), + SettingItem( + '启用 HTTP 心跳', + undefined, + SettingSwitch('ob11.http.enableHeart', ob11Config.http.enableHeart, { + 'control-display-id': 'config-ob11-HTTP.enableHeart', + }) + ), + SettingItem( + '启用 HTTP 事件上报', + undefined, + SettingSwitch('ob11.http.enablePost', ob11Config.http.enablePost, { + 'control-display-id': 'config-ob11-http-postUrls', + }) + ), + `
+ +
+ HTTP 事件上报密钥 +
+
+ +
+
+ +
+ HTTP 事件上报地址 +
+ 添加 +
+
+
`, + SettingItem( + '启用正向 WebSocket 服务', + undefined, + SettingSwitch('ob11.ws.enable', ob11Config.ws.enable, { + 'control-display-id': 'config-ob11-ws-port', + }) + ), + SettingItem( + '正向 WebSocket 服务监听端口', + undefined, + `
`, + 'config-ob11-ws-port', + ob11Config.ws.enable + ), + SettingItem( + '启用反向 WebSocket 服务', + undefined, + SettingSwitch('ob11.reverseWs.enable', ob11Config.reverseWs.enable, { + 'control-display-id': 'config-ob11-reverseWs-urls', + }) + ), + `
+ +
+ 反向 WebSocket 监听地址 +
+ 添加 +
+
+
`, + SettingItem( + ' WebSocket 服务心跳间隔', + '控制每隔多久发送一个心跳包,单位为毫秒', + `
` + ), + SettingItem( + 'Access token', + undefined, + `
` + ), + SettingItem( + '新消息上报格式', + '如客户端无特殊需求推荐保持默认设置,两者的详细差异可参考 OneBot v11 文档', + SettingSelect( + [ + { text: '消息段', value: 'array' }, + { text: 'CQ码', value: 'string' }, + ], + 'ob11.messagePostFormat', + ob11Config.messagePostFormat + ) + ), + SettingItem( + '音乐卡片签名地址', + undefined, + `
`, + 'ob11.musicSignUrl' + ), + SettingItem( + '启用本地进群时间与发言时间记录', + undefined, + SettingSwitch('ob11.GroupLocalTime.Record', ob11Config.GroupLocalTime.Record, { + 'control-display-id': 'config-ob11-GroupLocalTime-RecordList', + }) + ), + `
+ +
+ 群列表 +
+ 添加 +
+
+
`, + SettingItem( + '', + undefined, + SettingButton('保存', 'config-ob11-save', 'primary') + ), + ]), + SettingList([ + SettingItem( + '上报 Bot 自身发送的消息', + '上报 event 为 message_sent', + SettingSwitch('ob11.reportSelfMessage', ob11Config.reportSelfMessage) + ), + ]), + SettingList([ + SettingItem( + 'GitHub 仓库', + 'https://github.com/NapNeko/NapCatQQ', + SettingButton('点个星星', 'open-github') + ), + SettingItem('NapCat 文档', '', SettingButton('看看文档', 'open-docs')), + SettingItem( + 'Telegram 群', + 'https://t.me/+nLZEnpne-pQ1OWFl', + SettingButton('进去逛逛', 'open-telegram') + ), + SettingItem( + 'QQ 群', + '545402644', + SettingButton('我要进去', 'open-qq-group') + ), + ]), + '
', + ].join(''), + 'text/html' + ); + + // 外链按钮 + doc.querySelector('#open-github')?.addEventListener('click', () => { + window.open('https://github.com/NapNeko/NapCatQQ', '_blank'); + }); + doc.querySelector('#open-telegram')?.addEventListener('click', () => { + window.open('https://t.me/+nLZEnpne-pQ1OWFl'); + }); + doc.querySelector('#open-qq-group')?.addEventListener('click', () => { + window.open('https://qm.qq.com/q/bDnHRG38aI'); + }); + doc.querySelector('#open-docs')?.addEventListener('click', () => { + window.open('https://napneko.github.io/', '_blank'); + }); + // 生成反向地址列表 + const buildHostListItem = ( + type: string, + host: string, + index: number, + inputAttrs: any = {} + ) => { + const dom = { + container: document.createElement('setting-item'), + input: document.createElement('input'), + inputContainer: document.createElement('div'), + deleteBtn: document.createElement('setting-button'), + }; + dom.container.classList.add('setting-host-list-item'); + dom.container.dataset.direction = 'row'; + Object.assign(dom.input, inputAttrs); + dom.input.classList.add('q-input__inner'); + dom.input.type = 'url'; + dom.input.value = host; + dom.input.addEventListener('input', () => { + ob11Config[type.split('-')[0]][type.split('-')[1]][index] = + dom.input.value; + }); + + dom.inputContainer.classList.add('q-input'); + dom.inputContainer.appendChild(dom.input); + + dom.deleteBtn.innerHTML = '删除'; + dom.deleteBtn.dataset.type = 'secondary'; + dom.deleteBtn.addEventListener('click', () => { + ob11Config[type.split('-')[0]][type.split('-')[1]].splice(index, 1); + initReverseHost(type); + }); + + dom.container.appendChild(dom.inputContainer); + dom.container.appendChild(dom.deleteBtn); + + return dom.container; + }; + const buildHostList = ( + hosts: string[], + type: string, + inputAttr: any = {} + ) => { + const result: HTMLElement[] = []; + + hosts?.forEach((host, index) => { + result.push(buildHostListItem(type, host, index, inputAttr)); + }); + + return result; + }; + const addReverseHost = ( + type: string, + doc: Document = document, + inputAttr: any = {} + ) => { + type = type.replace(/\./g, '-');//替换操作 + const hostContainerDom = doc.body.querySelector( + `#config-ob11-${type}-list` + ); + hostContainerDom?.appendChild( + buildHostListItem( + type, + '', + ob11Config[type.split('-')[0]][type.split('-')[1]].length, + inputAttr + ) + ); + ob11Config[type.split('-')[0]][type.split('-')[1]].push(''); + }; + const initReverseHost = (type: string, doc: Document = document) => { + type = type.replace(/\./g, '-');//替换操作 + const hostContainerDom = doc.body?.querySelector( + `#config-ob11-${type}-list` + ); + if (hostContainerDom) { + [...hostContainerDom.childNodes].forEach((dom) => dom.remove()); + buildHostList( + ob11Config[type.split('-')[0]][type.split('-')[1]], + type + ).forEach((dom) => { + hostContainerDom?.appendChild(dom); + }); + } + }; + + initReverseHost('http.postUrls', doc); + initReverseHost('reverseWs.urls', doc); + initReverseHost('GroupLocalTime.RecordList', doc); + + doc + .querySelector('#config-ob11-http-postUrls-add') + ?.addEventListener('click', () => + addReverseHost('http.postUrls', document, { + placeholder: '如:http://127.0.0.1:5140/onebot', + }) + ); + + doc + .querySelector('#config-ob11-reverseWs-urls-add') + ?.addEventListener('click', () => + addReverseHost('reverseWs.urls', document, { + placeholder: '如:ws://127.0.0.1:5140/onebot', + }) + ); + doc + .querySelector('#config-ob11-GroupLocalTime-RecordList-add') + ?.addEventListener('click', () => + addReverseHost('GroupLocalTime.RecordList', document, { + placeholder: '此处填写群号 -1为全部', + }) + ); + doc.querySelector('#config-ffmpeg-select')?.addEventListener('click', () => { + //选择ffmpeg + }); + + doc.querySelector('#config-open-log-path')?.addEventListener('click', () => { + //打开日志 + }); + + // 开关 + doc + .querySelectorAll('setting-switch[data-config-key]') + .forEach((dom: Element) => { + dom.addEventListener('click', () => { + const active = dom.getAttribute('is-active') == undefined; + //@ts-expect-error 等待修复 + setOB11Config(dom.dataset.configKey, active); + if (active) dom.setAttribute('is-active', ''); + else dom.removeAttribute('is-active'); + //@ts-expect-error 等待修复 + if (!isEmpty(dom.dataset.controlDisplayId)) { + const displayDom = document.querySelector( + //@ts-expect-error 等待修复 + `#${dom.dataset.controlDisplayId}` + ); + if (active) displayDom?.removeAttribute('is-hidden'); + else displayDom?.setAttribute('is-hidden', ''); + } + }); + }); + + // 输入框 + doc + .querySelectorAll( + 'setting-item .q-input input.q-input__inner[data-config-key]' + ) + .forEach((dom: Element) => { + dom.addEventListener('input', () => { + const Type = dom.getAttribute('type'); + //@ts-expect-error等待修复 + const configKey = dom.dataset.configKey; + const configValue = + Type === 'number' + ? parseInt((dom as HTMLInputElement).value) >= 1 + ? parseInt((dom as HTMLInputElement).value) + : 1 + : (dom as HTMLInputElement).value; + + setOB11Config(configKey, configValue); + }); + }); + + // 下拉框 + doc + .querySelectorAll('ob-setting-select[data-config-key]') + .forEach((dom: Element) => { + //@ts-expect-error等待修复 + dom?.addEventListener('selected', (e: CustomEvent) => { + //@ts-expect-error等待修复 + const configKey = dom.dataset.configKey; + const configValue = e.detail.value; + setOB11Config(configKey, configValue); + }); + }); + + // 保存按钮 + doc.querySelector('#config-ob11-save')?.addEventListener('click', () => { + OB11ConfigWrapper.SetOB11Config(ob11Config); + alert('保存成功'); + }); + doc.body.childNodes.forEach((node) => { + view.appendChild(node); + }); +} +export { onSettingWindowCreated }; diff --git a/src/webui/ui/components/SettingButton.ts b/src/webui/ui/components/SettingButton.ts new file mode 100644 index 00000000..35f6bc87 --- /dev/null +++ b/src/webui/ui/components/SettingButton.ts @@ -0,0 +1,3 @@ +export const SettingButton = (text: string, id?: string, type: string = 'secondary') => { + return `${text}`; +}; \ No newline at end of file diff --git a/src/webui/ui/components/SettingItem.ts b/src/webui/ui/components/SettingItem.ts new file mode 100644 index 00000000..40c573ae --- /dev/null +++ b/src/webui/ui/components/SettingItem.ts @@ -0,0 +1,15 @@ +export const SettingItem = ( + title: string, + subtitle?: string, + action?: string, + id?: string, + visible: boolean = true, +) => { + return ` +
+ ${title} + ${subtitle ? `${subtitle}` : ''} +
+ ${action ? `
${action}
` : ''} +
`; +}; \ No newline at end of file diff --git a/src/webui/ui/components/SettingList.ts b/src/webui/ui/components/SettingList.ts new file mode 100644 index 00000000..2357773a --- /dev/null +++ b/src/webui/ui/components/SettingList.ts @@ -0,0 +1,14 @@ +export const SettingList = ( + items: string[], + title?: string, + isCollapsible: boolean = false, + direction: string = 'column', +) => { + return ` + + + ${items.join('')} + + + `; +}; \ No newline at end of file diff --git a/src/webui/ui/components/SettingOption.ts b/src/webui/ui/components/SettingOption.ts new file mode 100644 index 00000000..ccb0654b --- /dev/null +++ b/src/webui/ui/components/SettingOption.ts @@ -0,0 +1,3 @@ +export const SettingOption = (text: string, value?: string, isSelected: boolean = false) => { + return `${text}`; +}; \ No newline at end of file diff --git a/src/webui/ui/components/SettingSelect.ts b/src/webui/ui/components/SettingSelect.ts new file mode 100644 index 00000000..7764cd4a --- /dev/null +++ b/src/webui/ui/components/SettingSelect.ts @@ -0,0 +1,84 @@ +import { SettingOption } from './SettingOption'; + +interface MouseEventExtend extends MouseEvent { + target: HTMLElement +} + +// +const SelectTemplate = document.createElement('template'); +SelectTemplate.innerHTML = ` +
+
+ + + + +
+ +
`; + +window.customElements.define( + 'ob-setting-select', + class extends HTMLElement { + readonly _button: HTMLDivElement; + readonly _text: HTMLInputElement; + readonly _context: HTMLUListElement; + + constructor() { + super(); + + this.attachShadow({ mode: 'open' }); + this.shadowRoot?.append(SelectTemplate.content.cloneNode(true)); + + this._button = this.shadowRoot.querySelector('div[part="button"]'); + this._text = this.shadowRoot.querySelector('input[part="current-text"]'); + this._context = this.shadowRoot.querySelector('ul[part="option-list"]'); + + const buttonClick = () => { + const isHidden = this._context.classList.toggle('hidden'); + window[`${isHidden ? 'remove' : 'add'}EventListener`]('pointerdown', windowPointerDown); + }; + + const windowPointerDown = ({ target }) => { + if (!this.contains(target)) buttonClick(); + }; + + this._button.addEventListener('click', buttonClick); + this._context.addEventListener('click', ({ target }: MouseEventExtend) => { + if (target.tagName !== 'SETTING-OPTION') return; + buttonClick(); + + if (target.hasAttribute('is-selected')) return; + + this.querySelectorAll('setting-option[is-selected]').forEach((dom) => dom.toggleAttribute('is-selected')); + target.toggleAttribute('is-selected'); + + this._text.value = target.textContent as string; + this.dispatchEvent( + new CustomEvent('selected', { + bubbles: true, + composed: true, + detail: { + name: target.textContent, + value: target.dataset.value, + }, + }), + ); + }); + + this._text.value = this.querySelector('setting-option[is-selected]')?.textContent as string; + } + }, +); + +export const SettingSelect = (items: Array<{ text: string; value: string }>, configKey?: string, configValue?: any) => { + return ` + ${items + .map((e, i) => { + return SettingOption(e.text, e.value, configKey && configValue ? configValue === e.value : i === 0); + }) + .join('')} +`; +}; \ No newline at end of file diff --git a/src/webui/ui/components/SettingSwitch.ts b/src/webui/ui/components/SettingSwitch.ts new file mode 100644 index 00000000..bb18fd30 --- /dev/null +++ b/src/webui/ui/components/SettingSwitch.ts @@ -0,0 +1,8 @@ +export const SettingSwitch = (configKey?: string, isActive: boolean = false, extraData?: Record) => { + return ` `data-${key}="${extraData[key]}"`) : ''} + > + `; +}; \ No newline at end of file diff --git a/src/webui/ui/components/WebUiApiOB11Config.ts b/src/webui/ui/components/WebUiApiOB11Config.ts new file mode 100644 index 00000000..492cc6bf --- /dev/null +++ b/src/webui/ui/components/WebUiApiOB11Config.ts @@ -0,0 +1,74 @@ +export interface OB11Config { + [key: string]: any; + http: { + enable: boolean; + host: ''; + port: number; + secret: ''; + enableHeart: boolean; + enablePost: boolean; + postUrls: string[]; + }; + ws: { + enable: boolean; + host: ''; + port: number; + }; + reverseWs: { + enable: boolean; + urls: string[]; + }; + GroupLocalTime: { + Record: boolean, + RecordList: Array + }; + debug: boolean; + heartInterval: number; + messagePostFormat: 'array' | 'string'; + enableLocalFile2Url: boolean; + musicSignUrl: ''; + reportSelfMessage: boolean; + token: ''; + +} + +class WebUiApiOB11ConfigWrapper { + private retCredential: string = ''; + async Init(Credential: string) { + this.retCredential = Credential; + } + async GetOB11Config(): Promise { + const ConfigResponse = await fetch('../api/OB11Config/GetConfig', { + method: 'POST', + headers: { + Authorization: 'Bearer ' + this.retCredential, + 'Content-Type': 'application/json', + }, + }); + if (ConfigResponse.status == 200) { + const ConfigResponseJson = await ConfigResponse.json(); + if (ConfigResponseJson.code == 0) { + return ConfigResponseJson?.data; + } + } + return {} as OB11Config; + } + async SetOB11Config(config: OB11Config): Promise { + const ConfigResponse = await fetch('../api/OB11Config/SetConfig', { + method: 'POST', + headers: { + Authorization: 'Bearer ' + this.retCredential, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ config: JSON.stringify(config) }), + }); + if (ConfigResponse.status == 200) { + const ConfigResponseJson = await ConfigResponse.json(); + if (ConfigResponseJson.code == 0) { + return true; + } + } + return false; + } +} +export const OB11ConfigWrapper = new WebUiApiOB11ConfigWrapper(); diff --git a/src/webui/vite.config.ts b/src/webui/vite.config.ts new file mode 100644 index 00000000..dd98d374 --- /dev/null +++ b/src/webui/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build:{ + target: 'esnext', + minify: false, + lib: { + entry: 'ui/NapCat.ts', + formats: ['es'], + fileName: () => 'renderer.js', + } + } +}); \ No newline at end of file diff --git a/src/webui/webui.json b/src/webui/webui.json new file mode 100644 index 00000000..793b8cf1 --- /dev/null +++ b/src/webui/webui.json @@ -0,0 +1,8 @@ +{ + "host": "0.0.0.0", + "port": 6099, + "prefix": "", + "token": "random", + "loginRate": 3 + +} \ No newline at end of file