mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-12 16:00:27 +00:00
Compare commits
9 Commits
feat/secur
...
new-networ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
323dc71d4e | ||
|
|
b0d88d3705 | ||
|
|
32c0c93f3b | ||
|
|
ea399c8017 | ||
|
|
26d38bebe7 | ||
|
|
506358e01a | ||
|
|
7cd0e5b2a4 | ||
|
|
76447a385f | ||
|
|
5047b03303 |
43
packages/napcat-common/src/protocol/index.ts
Normal file
43
packages/napcat-common/src/protocol/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 协议管理器 - 用于统一管理多协议适配
|
||||
|
||||
export interface ProtocolInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ProtocolConfig {
|
||||
protocols: {
|
||||
[key: string]: {
|
||||
enabled: boolean;
|
||||
config: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const SUPPORTED_PROTOCOLS: ProtocolInfo[] = [
|
||||
{
|
||||
id: 'onebot11',
|
||||
name: 'OneBot 11',
|
||||
description: 'OneBot 11 协议适配器,兼容 go-cqhttp',
|
||||
version: '11.0.0',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'satori',
|
||||
name: 'Satori',
|
||||
description: 'Satori 协议适配器,跨平台机器人协议',
|
||||
version: '1.0.0',
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function getProtocolInfo (protocolId: string): ProtocolInfo | undefined {
|
||||
return SUPPORTED_PROTOCOLS.find((p) => p.id === protocolId);
|
||||
}
|
||||
|
||||
export function getSupportedProtocols (): ProtocolInfo[] {
|
||||
return SUPPORTED_PROTOCOLS;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const NapcatConfigSchema = Type.Object({
|
||||
packetBackend: Type.String({ default: 'auto' }),
|
||||
packetServer: Type.String({ default: '' }),
|
||||
o3HookMode: Type.Number({ default: 0 }),
|
||||
protocols: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
|
||||
});
|
||||
|
||||
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
|
||||
|
||||
@@ -53,6 +53,8 @@ export class NodeIKernelLoginListener {
|
||||
|
||||
onLoginState (..._args: any[]): any {
|
||||
}
|
||||
onLoginRecordUpdate (..._args: any[]): any {
|
||||
}
|
||||
}
|
||||
|
||||
export interface QRCodeLoginSucceedResult {
|
||||
|
||||
@@ -382,5 +382,8 @@ export class NodeIKernelMsgListener {
|
||||
// 第一次发现于Win 9.9.9-23159
|
||||
onBroadcastHelperProgerssUpdate (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
onNtMsgSyncContactUnread (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/index';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
import { ProtocolManager } from 'napcat-protocol';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
|
||||
@@ -39,22 +39,12 @@ export async function NCoreInitFramework (
|
||||
await applyPendingUpdates(pathWrapper, logger);
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
// nativePacketHandler.onAll((packet) => {
|
||||
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
|
||||
// });
|
||||
const nativePacketHandler = new NativePacketHandler({ logger });
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
|
||||
// 在 init 之后注册监听器
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
// 直到登录成功后,执行下一步
|
||||
// const selfInfo = {
|
||||
// uid: 'u_FUSS0_x06S_9Tf4na_WpUg',
|
||||
// uin: '3684714082',
|
||||
// nick: '',
|
||||
// online: true
|
||||
// }
|
||||
|
||||
const selfInfo = await new Promise<SelfInfo>((resolve) => {
|
||||
const loginListener = new NodeIKernelLoginListener();
|
||||
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
|
||||
@@ -64,14 +54,13 @@ export async function NCoreInitFramework (
|
||||
resolve({
|
||||
uid: loginResult.uid,
|
||||
uin: loginResult.uin,
|
||||
nick: '', // 获取不到
|
||||
nick: '',
|
||||
online: true,
|
||||
});
|
||||
};
|
||||
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
|
||||
});
|
||||
// 过早进入会导致addKernelMsgListener等Listener添加失败
|
||||
// await sleep(2500);
|
||||
|
||||
// 初始化 NapCatFramework
|
||||
const loaderObject = new NapCatFramework(wrapper, session, logger, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
|
||||
await loaderObject.core.initCore();
|
||||
@@ -79,16 +68,38 @@ export async function NCoreInitFramework (
|
||||
// 启动WebUi
|
||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||
// 初始化LLNC的Onebot实现
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
await oneBotAdapter.InitOneBot();
|
||||
|
||||
// 使用协议管理器初始化所有协议
|
||||
const protocolManager = new ProtocolManager(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
WebUiDataRuntime.setProtocolManager(protocolManager);
|
||||
|
||||
// 初始化所有协议
|
||||
await protocolManager.initAllProtocols();
|
||||
|
||||
// 获取适配器并注册到 WebUiDataRuntime
|
||||
const onebotAdapter = protocolManager.getOneBotAdapter();
|
||||
const satoriAdapter = protocolManager.getSatoriAdapter();
|
||||
|
||||
if (onebotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(onebotAdapter.getRawAdapter());
|
||||
}
|
||||
|
||||
if (satoriAdapter) {
|
||||
WebUiDataRuntime.setSatoriContext(satoriAdapter.getRawAdapter());
|
||||
WebUiDataRuntime.setOnSatoriConfigChanged(async (newConfig) => {
|
||||
const prev = satoriAdapter.getConfigLoader().configData;
|
||||
await protocolManager.reloadProtocolConfig('satori', prev, newConfig);
|
||||
});
|
||||
}
|
||||
|
||||
// 保存协议管理器引用
|
||||
loaderObject.protocolManager = protocolManager;
|
||||
}
|
||||
|
||||
export class NapCatFramework {
|
||||
public core: NapCatCore;
|
||||
context: InstanceContext;
|
||||
public context: InstanceContext;
|
||||
public protocolManager?: ProtocolManager;
|
||||
|
||||
constructor (
|
||||
wrapper: WrapperNodeApi,
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"name": "napcat-framework",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-framework",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-protocol": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const HttpServerConfigSchema = Type.Object({
|
||||
port: Type.Number({ default: 3000 }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
enableCors: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: false }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
@@ -18,7 +18,7 @@ const HttpSseServerConfigSchema = Type.Object({
|
||||
port: Type.Number({ default: 3000 }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
enableCors: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: false }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
|
||||
@@ -246,7 +246,7 @@ export class NapCatOneBot11Adapter {
|
||||
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
|
||||
}
|
||||
|
||||
private async handleConfigChange<CT extends NetworkAdapterConfig>(
|
||||
private async handleConfigChange<CT extends NetworkAdapterConfig> (
|
||||
prevConfig: NetworkAdapterConfig[],
|
||||
nowConfig: NetworkAdapterConfig[],
|
||||
adapterClass: new (
|
||||
@@ -305,6 +305,9 @@ export class NapCatOneBot11Adapter {
|
||||
};
|
||||
|
||||
msgListener.onRecvMsg = async (msg) => {
|
||||
if (!this.networkManager.hasActiveAdapters()) {
|
||||
return;
|
||||
}
|
||||
for (const m of msg) {
|
||||
if (this.bootTime > parseInt(m.msgTime)) {
|
||||
this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`);
|
||||
@@ -517,15 +520,14 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
|
||||
private async emitMsg (message: RawMessage) {
|
||||
const network = await this.networkManager.getAllConfig();
|
||||
this.context.logger.logDebug('收到新消息 RawMessage', message);
|
||||
await Promise.allSettled([
|
||||
this.handleMsg(message, network),
|
||||
this.handleMsg(message),
|
||||
message.chatType === ChatType.KCHATTYPEGROUP ? this.handleGroupEvent(message) : this.handlePrivateMsgEvent(message),
|
||||
]);
|
||||
}
|
||||
|
||||
private async handleMsg (message: RawMessage, network: Array<NetworkAdapterConfig>) {
|
||||
private async handleMsg (message: RawMessage) {
|
||||
// 过滤无效消息
|
||||
if (message.msgType === NTMsgType.KMSGTYPENULL) {
|
||||
return;
|
||||
@@ -535,10 +537,36 @@ export class NapCatOneBot11Adapter {
|
||||
if (ob11Msg) {
|
||||
const isSelfMsg = this.isSelfMessage(ob11Msg);
|
||||
this.context.logger.logDebug('转化为 OB11Message', ob11Msg);
|
||||
const msgMap = this.createMsgMap(network, ob11Msg, isSelfMsg, message);
|
||||
this.handleDebugNetwork(network, msgMap, message);
|
||||
this.handleNotReportSelfNetwork(network, msgMap, isSelfMsg);
|
||||
this.networkManager.emitEventByNames(msgMap);
|
||||
if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) {
|
||||
const targetId = parseInt(message.peerUin);
|
||||
ob11Msg.stringMsg.target_id = targetId;
|
||||
ob11Msg.arrayMsg.target_id = targetId;
|
||||
}
|
||||
|
||||
const msgMap = new Map<string, OB11Message>();
|
||||
|
||||
for (const adapter of this.networkManager.adapters.values()) {
|
||||
if (!adapter.isActive) continue;
|
||||
const config = adapter.config;
|
||||
if (isSelfMsg) {
|
||||
if (!('reportSelfMessage' in config) || !config.reportSelfMessage) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const msgData = config.messagePostFormat === 'string' ? ob11Msg.stringMsg : ob11Msg.arrayMsg;
|
||||
if (config.debug) {
|
||||
const clone = structuredClone(msgData);
|
||||
clone.raw = message;
|
||||
msgMap.set(adapter.name, clone);
|
||||
} else {
|
||||
msgMap.set(adapter.name, msgData);
|
||||
}
|
||||
}
|
||||
if (msgMap.size > 0) {
|
||||
this.networkManager.emitEventByNames(msgMap);
|
||||
} else if (this.networkManager.hasActiveAdapters()) {
|
||||
this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.context.logger.logError('constructMessage error: ', e);
|
||||
@@ -553,48 +581,6 @@ export class NapCatOneBot11Adapter {
|
||||
ob11Msg.arrayMsg.user_id.toString() === this.core.selfInfo.uin;
|
||||
}
|
||||
|
||||
private createMsgMap (network: Array<NetworkAdapterConfig>, ob11Msg: {
|
||||
stringMsg: OB11Message;
|
||||
arrayMsg: OB11Message;
|
||||
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
|
||||
const msgMap: Map<string, OB11Message> = new Map();
|
||||
network.filter(e => e.enable).forEach(e => {
|
||||
if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) {
|
||||
ob11Msg.stringMsg.target_id = parseInt(message.peerUin);
|
||||
ob11Msg.arrayMsg.target_id = parseInt(message.peerUin);
|
||||
}
|
||||
if ('messagePostFormat' in e && e.messagePostFormat === 'string') {
|
||||
msgMap.set(e.name, structuredClone(ob11Msg.stringMsg));
|
||||
} else {
|
||||
msgMap.set(e.name, structuredClone(ob11Msg.arrayMsg));
|
||||
}
|
||||
});
|
||||
return msgMap;
|
||||
}
|
||||
|
||||
private handleDebugNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
|
||||
const debugNetwork = network.filter(e => e.enable && e.debug);
|
||||
if (debugNetwork.length > 0) {
|
||||
debugNetwork.forEach(adapter => {
|
||||
const msg = msgMap.get(adapter.name);
|
||||
if (msg) {
|
||||
msg.raw = message;
|
||||
}
|
||||
});
|
||||
} else if (msgMap.size === 0) {
|
||||
this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message);
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotReportSelfNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
|
||||
if (isSelfMsg) {
|
||||
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
|
||||
notReportSelfNetwork.forEach(adapter => {
|
||||
msgMap.delete(adapter.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGroupEvent (message: RawMessage) {
|
||||
try {
|
||||
// 群名片修改事件解析 任何都该判断
|
||||
|
||||
@@ -23,11 +23,15 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
|
||||
this.logger = core.context.logger;
|
||||
}
|
||||
|
||||
abstract onEvent<T extends OB11EmitEventContent>(event: T): Promise<void>;
|
||||
abstract onEvent<T extends OB11EmitEventContent> (event: T): Promise<void>;
|
||||
|
||||
abstract open (): void | Promise<void>;
|
||||
|
||||
abstract close (): void | Promise<void>;
|
||||
|
||||
abstract reload (config: unknown): OB11NetworkReloadType | Promise<OB11NetworkReloadType>;
|
||||
|
||||
get isActive (): boolean {
|
||||
return this.isEnable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import { OB11HttpServerAdapter } from './http-server';
|
||||
export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
||||
private sseClients: Response[] = [];
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && (this.sseClients.length > 0 || super.isActive);
|
||||
}
|
||||
|
||||
override async handleRequest (req: Request, res: Response) {
|
||||
if (req.path === '/_events') {
|
||||
this.createSseSupport(req, res);
|
||||
@@ -25,7 +29,8 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
override async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
super.onEvent(event);
|
||||
const promises: Promise<void>[] = [];
|
||||
this.sseClients.forEach((res) => {
|
||||
promises.push(new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||
import express, { Express, NextFunction, Request, Response } from 'express';
|
||||
import http from 'http';
|
||||
import http, { IncomingMessage } from 'http';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import cors from 'cors';
|
||||
import { HttpServerConfig } from '@/napcat-onebot/config/config';
|
||||
@@ -8,13 +8,41 @@ import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import json5 from 'json5';
|
||||
import { isFinished } from 'on-finished';
|
||||
import typeis from 'type-is';
|
||||
import { WebSocket, WebSocketServer, RawData } from 'ws';
|
||||
import { URL } from 'url';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { Mutex } from 'async-mutex';
|
||||
|
||||
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
|
||||
private app: Express | undefined;
|
||||
private server: http.Server | undefined;
|
||||
private wsServer?: WebSocketServer;
|
||||
private wsClients: WebSocket[] = [];
|
||||
private wsClientsMutex = new Mutex();
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
private wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent> (_event: T) {
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.wsClientWithEvent.length > 0;
|
||||
}
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
// http server is passive, no need to emit event
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
});
|
||||
}
|
||||
|
||||
open () {
|
||||
@@ -36,11 +64,24 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
this.isEnable = false;
|
||||
this.server?.close();
|
||||
this.app = undefined;
|
||||
this.stopHeartbeat();
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClients.forEach((wsClient) => {
|
||||
wsClient.close();
|
||||
});
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
});
|
||||
this.wsServer?.close();
|
||||
}
|
||||
|
||||
private initializeServer () {
|
||||
this.app = express();
|
||||
this.server = http.createServer(this.app);
|
||||
if (this.config.enableWebsocket) {
|
||||
this.wsServer = new WebSocketServer({ server: this.server });
|
||||
this.createWSServer(this.wsServer);
|
||||
}
|
||||
|
||||
this.app.use(cors());
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '5000mb' }));
|
||||
@@ -93,6 +134,137 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
}
|
||||
}
|
||||
|
||||
createWSServer (newServer: WebSocketServer) {
|
||||
newServer.on('connection', async (wsClient, wsReq) => {
|
||||
if (!this.isEnable) {
|
||||
wsClient.close();
|
||||
return;
|
||||
}
|
||||
if (!this.authorizeWS(this.config.token, wsClient, wsReq)) {
|
||||
return;
|
||||
}
|
||||
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
|
||||
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
|
||||
if (!isApiConnect) {
|
||||
this.connectEvent(this.core, wsClient);
|
||||
}
|
||||
|
||||
wsClient.on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Client Error:', err.message));
|
||||
wsClient.on('message', (message) => {
|
||||
this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e));
|
||||
});
|
||||
wsClient.on('ping', () => {
|
||||
wsClient.pong();
|
||||
});
|
||||
wsClient.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received');
|
||||
});
|
||||
wsClient.once('close', () => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
});
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message));
|
||||
}
|
||||
|
||||
connectEvent (core: any, wsClient: WebSocket) {
|
||||
try {
|
||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e));
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private stopHeartbeat () {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private authorizeWS (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
|
||||
if (!token || token.length === 0) return true;
|
||||
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
|
||||
const QueryClientToken = url.searchParams.get('access_token');
|
||||
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
|
||||
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
|
||||
if (ClientToken === token) {
|
||||
return true;
|
||||
}
|
||||
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
|
||||
wsClient.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkStateAndReply<T> (data: T, wsClient: WebSocket) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(data));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleWSMessage (wsClient: WebSocket, message: RawData) {
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
|
||||
let echo;
|
||||
try {
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
} catch {
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};
|
||||
|
||||
const action = this.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.logger.logError('[OneBot] [HTTP WebSocket] 发生错误', '不支持的API ' + receiveData.action);
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
|
||||
},
|
||||
});
|
||||
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
|
||||
}
|
||||
|
||||
async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) {
|
||||
let payload = req.body;
|
||||
if (req.method === 'get') {
|
||||
@@ -152,6 +324,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
async reload (newConfig: HttpServerConfig) {
|
||||
const wasEnabled = this.isEnable;
|
||||
const oldPort = this.config.port;
|
||||
const oldEnableWebsocket = this.config.enableWebsocket;
|
||||
this.config = newConfig;
|
||||
|
||||
if (newConfig.enable && !wasEnabled) {
|
||||
@@ -162,7 +335,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
return OB11NetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
if (oldPort !== newConfig.port) {
|
||||
if (oldPort !== newConfig.port || oldEnableWebsocket !== newConfig.enableWebsocket) {
|
||||
this.close();
|
||||
if (newConfig.enable) {
|
||||
this.open();
|
||||
|
||||
@@ -21,7 +21,7 @@ export class OB11NetworkManager {
|
||||
|
||||
async emitEvent (event: OB11EmitEventContent) {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
|
||||
if (adapter.isEnable) {
|
||||
if (adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
@@ -34,7 +34,7 @@ export class OB11NetworkManager {
|
||||
async emitEventByName (names: string[], event: OB11EmitEventContent) {
|
||||
return Promise.all(names.map(async name => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isEnable) {
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
@@ -43,29 +43,29 @@ export class OB11NetworkManager {
|
||||
async emitEventByNames (map: Map<string, OB11EmitEventContent>) {
|
||||
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isEnable) {
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
registerAdapter<CT extends NetworkAdapterConfig>(adapter: IOB11NetworkAdapter<CT>) {
|
||||
registerAdapter<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
|
||||
this.adapters.set(adapter.name, adapter);
|
||||
}
|
||||
|
||||
async registerAdapterAndOpen<CT extends NetworkAdapterConfig>(adapter: IOB11NetworkAdapter<CT>) {
|
||||
async registerAdapterAndOpen<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
|
||||
this.registerAdapter(adapter);
|
||||
await adapter.open();
|
||||
}
|
||||
|
||||
async closeSomeAdapters<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
async closeSomeAdapters<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
await adapter.close();
|
||||
}
|
||||
}
|
||||
|
||||
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
if (adapter.isEnable) {
|
||||
@@ -88,17 +88,21 @@ export class OB11NetworkManager {
|
||||
this.adapters.clear();
|
||||
}
|
||||
|
||||
async readloadAdapter<T>(name: string, config: T) {
|
||||
async readloadAdapter<T> (name: string, config: T) {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter) {
|
||||
await adapter.reload(config);
|
||||
}
|
||||
}
|
||||
|
||||
async readloadSomeAdapters<T>(configMap: Map<string, T>) {
|
||||
async readloadSomeAdapters<T> (configMap: Map<string, T>) {
|
||||
await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.readloadAdapter(name, config)));
|
||||
}
|
||||
|
||||
hasActiveAdapters (): boolean {
|
||||
return Array.from(this.adapters.values()).some(adapter => adapter.isActive);
|
||||
}
|
||||
|
||||
async getAllConfig () {
|
||||
return Array.from(this.adapters.values()).map(adapter => adapter.config);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
private readonly pluginPath: string;
|
||||
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
|
||||
declare config: PluginConfig;
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.loadedPlugins.size > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
|
||||
) {
|
||||
@@ -251,7 +255,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
@@ -359,7 +363,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
// 重新加载插件
|
||||
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
|
||||
plugin.pluginPath !== this.pluginPath;
|
||||
plugin.pluginPath !== this.pluginPath;
|
||||
|
||||
if (isDirectory) {
|
||||
const dirname = path.basename(plugin.pluginPath);
|
||||
|
||||
@@ -33,6 +33,10 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
private readonly pluginPath: string;
|
||||
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
|
||||
declare config: PluginConfig;
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.loadedPlugins.size > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
|
||||
) {
|
||||
|
||||
@@ -13,6 +13,10 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
private connection: WebSocket | null = null;
|
||||
private heartbeatRef: NodeJS.Timeout | null = null;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && !!this.connection && this.connection.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(event));
|
||||
|
||||
@@ -21,6 +21,10 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.wsClientWithEvent.length > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
|
||||
) {
|
||||
@@ -70,6 +74,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
@@ -77,6 +84,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
});
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
|
||||
}
|
||||
@@ -114,9 +124,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
|
||||
|
||||
this.isEnable = true;
|
||||
if (this.config.heartInterval > 0) {
|
||||
this.registerHeartBeat();
|
||||
}
|
||||
}
|
||||
|
||||
async close () {
|
||||
@@ -128,10 +135,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.logger.log('[OneBot] [WebSocket Server] Server Closed');
|
||||
}
|
||||
});
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
this.stopHeartbeat();
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClients.forEach((wsClient) => {
|
||||
wsClient.close();
|
||||
@@ -141,7 +145,8 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
});
|
||||
}
|
||||
|
||||
private registerHeartBeat () {
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
@@ -153,6 +158,13 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}, this.config.heartInterval);
|
||||
}
|
||||
|
||||
private stopHeartbeat () {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
|
||||
if (!token || token.length === 0) return true;// 客户端未设置密钥
|
||||
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
|
||||
@@ -235,12 +247,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}
|
||||
|
||||
if (oldHeartbeatInterval !== newConfig.heartInterval) {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
if (newConfig.heartInterval > 0 && this.isEnable) {
|
||||
this.registerHeartBeat();
|
||||
this.stopHeartbeat();
|
||||
if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
251
packages/napcat-protocol/README.md
Normal file
251
packages/napcat-protocol/README.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# NapCat Protocol Manager
|
||||
|
||||
统一管理 NapCat 的多协议适配器(OneBot 和 Satori)。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🔌 **统一接口**: 提供统一的协议管理接口
|
||||
- 🎯 **插件化设计**: 支持动态注册和管理协议适配器
|
||||
- 🔄 **热重载**: 支持协议配置的热重载
|
||||
- 📦 **开箱即用**: 内置 OneBot11 和 Satori 协议支持
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
napcat-protocol
|
||||
├── types.ts # 协议接口定义
|
||||
├── manager.ts # 协议管理器
|
||||
├── adapters/
|
||||
│ ├── onebot.ts # OneBot11 协议适配器包装
|
||||
│ └── satori.ts # Satori 协议适配器包装
|
||||
└── index.ts # 导出入口
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基础使用
|
||||
|
||||
```typescript
|
||||
import { ProtocolManager } from 'napcat-protocol';
|
||||
|
||||
// 创建协议管理器
|
||||
const protocolManager = new ProtocolManager(core, context, pathWrapper);
|
||||
|
||||
// 初始化所有协议
|
||||
await protocolManager.initAllProtocols();
|
||||
|
||||
// 获取协议适配器
|
||||
const onebotAdapter = protocolManager.getOneBotAdapter();
|
||||
const satoriAdapter = protocolManager.getSatoriAdapter();
|
||||
```
|
||||
|
||||
### 单独初始化协议
|
||||
|
||||
```typescript
|
||||
// 只初始化 OneBot11
|
||||
await protocolManager.initProtocol('onebot11');
|
||||
|
||||
// 只初始化 Satori
|
||||
await protocolManager.initProtocol('satori');
|
||||
```
|
||||
|
||||
### 获取原始适配器
|
||||
|
||||
```typescript
|
||||
// 获取 OneBot 原始适配器
|
||||
const onebotAdapter = protocolManager.getOneBotAdapter();
|
||||
if (onebotAdapter) {
|
||||
const rawOneBot = onebotAdapter.getRawAdapter();
|
||||
// 使用 NapCatOneBot11Adapter 的所有功能
|
||||
}
|
||||
|
||||
// 获取 Satori 原始适配器
|
||||
const satoriAdapter = protocolManager.getSatoriAdapter();
|
||||
if (satoriAdapter) {
|
||||
const rawSatori = satoriAdapter.getRawAdapter();
|
||||
// 使用 NapCatSatoriAdapter 的所有功能
|
||||
}
|
||||
```
|
||||
|
||||
### 配置重载
|
||||
|
||||
```typescript
|
||||
// 重载 OneBot 配置
|
||||
await protocolManager.reloadProtocolConfig('onebot11', prevConfig, newConfig);
|
||||
|
||||
// 重载 Satori 配置
|
||||
await protocolManager.reloadProtocolConfig('satori', prevConfig, newConfig);
|
||||
```
|
||||
|
||||
### 查询协议状态
|
||||
|
||||
```typescript
|
||||
// 获取所有已注册的协议信息
|
||||
const protocols = protocolManager.getRegisteredProtocols();
|
||||
|
||||
// 检查协议是否已初始化
|
||||
const isInitialized = protocolManager.isProtocolInitialized('onebot11');
|
||||
|
||||
// 获取所有已初始化的协议ID
|
||||
const initializedIds = protocolManager.getInitializedProtocolIds();
|
||||
```
|
||||
|
||||
### 销毁协议
|
||||
|
||||
```typescript
|
||||
// 销毁指定协议
|
||||
await protocolManager.destroyProtocol('onebot11');
|
||||
|
||||
// 销毁所有协议
|
||||
await protocolManager.destroyAllProtocols();
|
||||
```
|
||||
|
||||
## 在 Framework 中使用
|
||||
|
||||
```typescript
|
||||
// packages/napcat-framework/napcat.ts
|
||||
import { ProtocolManager } from 'napcat-protocol';
|
||||
|
||||
const protocolManager = new ProtocolManager(core, context, pathWrapper);
|
||||
await protocolManager.initAllProtocols();
|
||||
|
||||
// 注册到 WebUI
|
||||
const onebotAdapter = protocolManager.getOneBotAdapter();
|
||||
if (onebotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(onebotAdapter.getRawAdapter());
|
||||
}
|
||||
|
||||
const satoriAdapter = protocolManager.getSatoriAdapter();
|
||||
if (satoriAdapter) {
|
||||
WebUiDataRuntime.setSatoriContext(satoriAdapter.getRawAdapter());
|
||||
}
|
||||
```
|
||||
|
||||
## 在 Shell 中使用
|
||||
|
||||
```typescript
|
||||
// packages/napcat-shell/base.ts
|
||||
import { ProtocolManager } from 'napcat-protocol';
|
||||
|
||||
export class NapCatShell {
|
||||
public protocolManager?: ProtocolManager;
|
||||
|
||||
async InitNapCat() {
|
||||
await this.core.initCore();
|
||||
|
||||
this.protocolManager = new ProtocolManager(
|
||||
this.core,
|
||||
this.context,
|
||||
this.context.pathWrapper
|
||||
);
|
||||
|
||||
await this.protocolManager.initAllProtocols();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展自定义协议
|
||||
|
||||
如果需要添加新的协议支持,可以实现 `IProtocolAdapter` 和 `IProtocolAdapterFactory` 接口:
|
||||
|
||||
```typescript
|
||||
import { IProtocolAdapter, IProtocolAdapterFactory } from 'napcat-protocol';
|
||||
|
||||
// 实现协议适配器
|
||||
class MyProtocolAdapter implements IProtocolAdapter {
|
||||
readonly name = 'MyProtocol';
|
||||
readonly id = 'myprotocol';
|
||||
readonly version = '1.0.0';
|
||||
readonly description = '我的自定义协议';
|
||||
|
||||
async init(): Promise<void> {
|
||||
// 初始化逻辑
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
// 清理逻辑
|
||||
}
|
||||
|
||||
async reloadConfig(prevConfig: unknown, newConfig: unknown): Promise<void> {
|
||||
// 配置重载逻辑
|
||||
}
|
||||
}
|
||||
|
||||
// 实现工厂
|
||||
class MyProtocolAdapterFactory implements IProtocolAdapterFactory {
|
||||
readonly protocolId = 'myprotocol';
|
||||
readonly protocolName = 'MyProtocol';
|
||||
readonly protocolVersion = '1.0.0';
|
||||
readonly protocolDescription = '我的自定义协议';
|
||||
|
||||
create(core, context, pathWrapper) {
|
||||
return new MyProtocolAdapter(core, context, pathWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
// 注册到管理器
|
||||
protocolManager.registerFactory(new MyProtocolAdapterFactory());
|
||||
await protocolManager.initProtocol('myprotocol');
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### ProtocolManager
|
||||
|
||||
#### 方法
|
||||
|
||||
- `registerFactory(factory: IProtocolAdapterFactory)`: 注册协议工厂
|
||||
- `getRegisteredProtocols()`: 获取所有已注册的协议信息
|
||||
- `initProtocol(protocolId: string)`: 初始化指定协议
|
||||
- `initAllProtocols()`: 初始化所有协议
|
||||
- `destroyProtocol(protocolId: string)`: 销毁指定协议
|
||||
- `destroyAllProtocols()`: 销毁所有协议
|
||||
- `getAdapter<T>(protocolId: string)`: 获取协议适配器
|
||||
- `getOneBotAdapter()`: 获取 OneBot 协议适配器
|
||||
- `getSatoriAdapter()`: 获取 Satori 协议适配器
|
||||
- `reloadProtocolConfig(protocolId, prevConfig, newConfig)`: 重载协议配置
|
||||
- `isProtocolInitialized(protocolId: string)`: 检查协议是否已初始化
|
||||
- `getInitializedProtocolIds()`: 获取所有已初始化的协议ID
|
||||
|
||||
### IProtocolAdapter
|
||||
|
||||
协议适配器接口,所有协议适配器都需要实现此接口。
|
||||
|
||||
#### 属性
|
||||
|
||||
- `name: string`: 协议名称
|
||||
- `id: string`: 协议ID
|
||||
- `version: string`: 协议版本
|
||||
- `description: string`: 协议描述
|
||||
|
||||
#### 方法
|
||||
|
||||
- `init()`: 初始化协议适配器
|
||||
- `destroy()`: 销毁协议适配器
|
||||
- `reloadConfig(prevConfig, newConfig)`: 重载配置
|
||||
|
||||
### IProtocolAdapterFactory
|
||||
|
||||
协议适配器工厂接口,用于创建协议适配器实例。
|
||||
|
||||
#### 属性
|
||||
|
||||
- `protocolId: string`: 协议ID
|
||||
- `protocolName: string`: 协议名称
|
||||
- `protocolVersion: string`: 协议版本
|
||||
- `protocolDescription: string`: 协议描述
|
||||
|
||||
#### 方法
|
||||
|
||||
- `create(core, context, pathWrapper)`: 创建协议适配器实例
|
||||
|
||||
## 依赖
|
||||
|
||||
- `napcat-core`: NapCat 核心
|
||||
- `napcat-common`: NapCat 通用工具
|
||||
- `napcat-onebot`: OneBot11 协议实现
|
||||
- `napcat-satori`: Satori 协议实现
|
||||
|
||||
## 许可证
|
||||
|
||||
与 NapCat 主项目保持一致
|
||||
2
packages/napcat-protocol/adapters/index.ts
Normal file
2
packages/napcat-protocol/adapters/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './onebot';
|
||||
export * from './satori';
|
||||
67
packages/napcat-protocol/adapters/onebot.ts
Normal file
67
packages/napcat-protocol/adapters/onebot.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { InstanceContext, NapCatCore } from 'napcat-core';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
import { OB11ConfigLoader } from 'napcat-onebot/config';
|
||||
import { IProtocolAdapter, IProtocolAdapterFactory } from '../types';
|
||||
|
||||
/**
|
||||
* OneBot11 协议适配器包装器
|
||||
*/
|
||||
export class OneBotProtocolAdapter implements IProtocolAdapter {
|
||||
readonly name = 'OneBot11';
|
||||
readonly id = 'onebot11';
|
||||
readonly version = '11';
|
||||
readonly description = 'OneBot v11 协议适配器';
|
||||
|
||||
private adapter: NapCatOneBot11Adapter;
|
||||
|
||||
constructor (
|
||||
_core: NapCatCore,
|
||||
_context: InstanceContext,
|
||||
_pathWrapper: NapCatPathWrapper
|
||||
) {
|
||||
this.adapter = new NapCatOneBot11Adapter(_core, _context, _pathWrapper);
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
await this.adapter.InitOneBot();
|
||||
}
|
||||
|
||||
async destroy (): Promise<void> {
|
||||
await this.adapter.networkManager.closeAllAdapters();
|
||||
}
|
||||
|
||||
async reloadConfig (_prevConfig: unknown, newConfig: unknown): Promise<void> {
|
||||
const now = newConfig as Parameters<typeof this.adapter.configLoader.save>[0];
|
||||
this.adapter.configLoader.save(now);
|
||||
// 内部会处理网络重载
|
||||
}
|
||||
|
||||
/** 获取原始适配器实例 */
|
||||
getRawAdapter (): NapCatOneBot11Adapter {
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
/** 获取配置加载器 */
|
||||
getConfigLoader (): OB11ConfigLoader {
|
||||
return this.adapter.configLoader;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OneBot11 协议适配器工厂
|
||||
*/
|
||||
export class OneBotProtocolAdapterFactory implements IProtocolAdapterFactory<OneBotProtocolAdapter> {
|
||||
readonly protocolId = 'onebot11';
|
||||
readonly protocolName = 'OneBot11';
|
||||
readonly protocolVersion = '11';
|
||||
readonly protocolDescription = 'OneBot v11 协议适配器,支持 HTTP、WebSocket 等多种网络方式';
|
||||
|
||||
create (
|
||||
core: NapCatCore,
|
||||
context: InstanceContext,
|
||||
pathWrapper: NapCatPathWrapper
|
||||
): OneBotProtocolAdapter {
|
||||
return new OneBotProtocolAdapter(core, context, pathWrapper);
|
||||
}
|
||||
}
|
||||
68
packages/napcat-protocol/adapters/satori.ts
Normal file
68
packages/napcat-protocol/adapters/satori.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { InstanceContext, NapCatCore } from 'napcat-core';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { NapCatSatoriAdapter } from 'napcat-satori/index';
|
||||
import { SatoriConfig, SatoriConfigLoader } from 'napcat-satori/config';
|
||||
import { IProtocolAdapter, IProtocolAdapterFactory } from '../types';
|
||||
|
||||
/**
|
||||
* Satori 协议适配器包装器
|
||||
*/
|
||||
export class SatoriProtocolAdapter implements IProtocolAdapter {
|
||||
readonly name = 'Satori';
|
||||
readonly id = 'satori';
|
||||
readonly version = '1';
|
||||
readonly description = 'Satori 协议适配器';
|
||||
|
||||
private adapter: NapCatSatoriAdapter;
|
||||
|
||||
constructor (
|
||||
_core: NapCatCore,
|
||||
_context: InstanceContext,
|
||||
_pathWrapper: NapCatPathWrapper
|
||||
) {
|
||||
this.adapter = new NapCatSatoriAdapter(_core, _context, _pathWrapper);
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
await this.adapter.InitSatori();
|
||||
}
|
||||
|
||||
async destroy (): Promise<void> {
|
||||
await this.adapter.networkManager.closeAllAdapters();
|
||||
}
|
||||
|
||||
async reloadConfig (prevConfig: unknown, newConfig: unknown): Promise<void> {
|
||||
const prev = prevConfig as SatoriConfig;
|
||||
const now = newConfig as SatoriConfig;
|
||||
this.adapter.configLoader.save(now);
|
||||
await this.adapter.reloadNetwork(prev, now);
|
||||
}
|
||||
|
||||
/** 获取原始适配器实例 */
|
||||
getRawAdapter (): NapCatSatoriAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
/** 获取配置加载器 */
|
||||
getConfigLoader (): SatoriConfigLoader {
|
||||
return this.adapter.configLoader;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Satori 协议适配器工厂
|
||||
*/
|
||||
export class SatoriProtocolAdapterFactory implements IProtocolAdapterFactory<SatoriProtocolAdapter> {
|
||||
readonly protocolId = 'satori';
|
||||
readonly protocolName = 'Satori';
|
||||
readonly protocolVersion = '1';
|
||||
readonly protocolDescription = 'Satori 协议适配器,支持 WebSocket、HTTP、WebHook 等多种网络方式';
|
||||
|
||||
create (
|
||||
core: NapCatCore,
|
||||
context: InstanceContext,
|
||||
pathWrapper: NapCatPathWrapper
|
||||
): SatoriProtocolAdapter {
|
||||
return new SatoriProtocolAdapter(core, context, pathWrapper);
|
||||
}
|
||||
}
|
||||
35
packages/napcat-protocol/index.ts
Normal file
35
packages/napcat-protocol/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* NapCat Protocol Manager
|
||||
*
|
||||
* 统一管理 OneBot 和 Satori 协议适配器
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { ProtocolManager } from 'napcat-protocol';
|
||||
*
|
||||
* const protocolManager = new ProtocolManager(core, context, pathWrapper);
|
||||
*
|
||||
* // 初始化所有协议
|
||||
* await protocolManager.initAllProtocols();
|
||||
*
|
||||
* // 或者只初始化特定协议
|
||||
* await protocolManager.initProtocol('onebot11');
|
||||
* await protocolManager.initProtocol('satori');
|
||||
*
|
||||
* // 获取协议适配器
|
||||
* const onebotAdapter = protocolManager.getOneBotAdapter();
|
||||
* const satoriAdapter = protocolManager.getSatoriAdapter();
|
||||
*
|
||||
* // 获取原始适配器实例
|
||||
* const rawOneBot = onebotAdapter?.getRawAdapter();
|
||||
* const rawSatori = satoriAdapter?.getRawAdapter();
|
||||
* ```
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './manager';
|
||||
export * from './adapters';
|
||||
|
||||
// 重新导出原始适配器类型,方便使用
|
||||
export { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
export { NapCatSatoriAdapter } from 'napcat-satori/index';
|
||||
260
packages/napcat-protocol/manager.ts
Normal file
260
packages/napcat-protocol/manager.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { InstanceContext, NapCatCore } from 'napcat-core';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import json5 from 'json5';
|
||||
import { IProtocolAdapter, IProtocolAdapterFactory, ProtocolInfo } from './types';
|
||||
import { OneBotProtocolAdapterFactory, OneBotProtocolAdapter } from './adapters/onebot';
|
||||
import { SatoriProtocolAdapterFactory, SatoriProtocolAdapter } from './adapters/satori';
|
||||
|
||||
/**
|
||||
* 协议管理器 - 统一管理所有协议适配器
|
||||
*/
|
||||
export class ProtocolManager {
|
||||
private factories: Map<string, IProtocolAdapterFactory> = new Map();
|
||||
private adapters: Map<string, IProtocolAdapter> = new Map();
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor (
|
||||
private core: NapCatCore,
|
||||
private context: InstanceContext,
|
||||
private pathWrapper: NapCatPathWrapper
|
||||
) {
|
||||
// 注册内置协议工厂
|
||||
this.registerFactory(new OneBotProtocolAdapterFactory());
|
||||
this.registerFactory(new SatoriProtocolAdapterFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册协议适配器工厂
|
||||
*/
|
||||
registerFactory (factory: IProtocolAdapterFactory): void {
|
||||
if (this.factories.has(factory.protocolId)) {
|
||||
this.context.logger.logWarn(`[Protocol] 协议工厂 ${factory.protocolId} 已存在,将被覆盖`);
|
||||
}
|
||||
this.factories.set(factory.protocolId, factory);
|
||||
this.context.logger.log(`[Protocol] 注册协议工厂: ${factory.protocolName} (${factory.protocolId})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载协议状态
|
||||
*/
|
||||
private loadProtocolStatus (): Record<string, boolean> {
|
||||
return (this.core.configLoader.configData.protocols || { onebot11: true }) as Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存协议状态
|
||||
*/
|
||||
private saveProtocolStatus (status: Record<string, boolean>): void {
|
||||
const config = this.core.configLoader.configData;
|
||||
config.protocols = status;
|
||||
this.core.configLoader.save(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置协议启用状态
|
||||
*/
|
||||
async setProtocolEnabled (protocolId: string, enabled: boolean): Promise<void> {
|
||||
const status = this.loadProtocolStatus();
|
||||
status[protocolId] = enabled;
|
||||
this.saveProtocolStatus(status);
|
||||
|
||||
if (enabled) {
|
||||
await this.initProtocol(protocolId);
|
||||
} else {
|
||||
await this.destroyProtocol(protocolId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的协议信息
|
||||
*/
|
||||
getRegisteredProtocols (): ProtocolInfo[] {
|
||||
const status = this.loadProtocolStatus();
|
||||
const protocols: ProtocolInfo[] = [];
|
||||
for (const [id, factory] of this.factories) {
|
||||
protocols.push({
|
||||
id,
|
||||
name: factory.protocolName,
|
||||
version: factory.protocolVersion,
|
||||
description: factory.protocolDescription,
|
||||
enabled: status[id] ?? false, // 使用持久化的状态
|
||||
});
|
||||
}
|
||||
return protocols;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有协议
|
||||
*/
|
||||
async initAllProtocols (): Promise<void> {
|
||||
if (this.initialized) {
|
||||
this.context.logger.logWarn('[Protocol] 协议管理器已初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.logger.log('[Protocol] 开始初始化所有协议...');
|
||||
const status = this.loadProtocolStatus();
|
||||
|
||||
for (const [protocolId] of this.factories) {
|
||||
if (status[protocolId]) {
|
||||
await this.initProtocol(protocolId);
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
this.context.logger.log('[Protocol] 所有协议初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化指定协议
|
||||
*/
|
||||
async initProtocol (protocolId: string): Promise<IProtocolAdapter | null> {
|
||||
const factory = this.factories.get(protocolId);
|
||||
if (!factory) {
|
||||
this.context.logger.logError(`[Protocol] 未找到协议工厂: ${protocolId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.adapters.has(protocolId)) {
|
||||
// Already initialized
|
||||
return this.adapters.get(protocolId)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const adapter = factory.create(this.core, this.context, this.pathWrapper);
|
||||
await adapter.init();
|
||||
this.adapters.set(protocolId, adapter);
|
||||
this.context.logger.log(`[Protocol] 协议 ${adapter.name} 初始化成功`);
|
||||
return adapter;
|
||||
} catch (error) {
|
||||
this.context.logger.logError(`[Protocol] 协议 ${protocolId} 初始化失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁指定协议
|
||||
*/
|
||||
async destroyProtocol (protocolId: string): Promise<void> {
|
||||
const adapter = this.adapters.get(protocolId);
|
||||
if (!adapter) {
|
||||
this.context.logger.logWarn(`[Protocol] 协议 ${protocolId} 未初始化`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adapter.destroy();
|
||||
this.adapters.delete(protocolId);
|
||||
this.context.logger.log(`[Protocol] 协议 ${adapter.name} 已销毁`);
|
||||
} catch (error) {
|
||||
this.context.logger.logError(`[Protocol] 协议 ${protocolId} 销毁失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁所有协议
|
||||
*/
|
||||
async destroyAllProtocols (): Promise<void> {
|
||||
this.context.logger.log('[Protocol] 开始销毁所有协议...');
|
||||
|
||||
for (const [protocolId] of this.adapters) {
|
||||
await this.destroyProtocol(protocolId);
|
||||
}
|
||||
|
||||
this.initialized = false;
|
||||
this.context.logger.log('[Protocol] 所有协议已销毁');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取协议适配器
|
||||
*/
|
||||
getAdapter<T extends IProtocolAdapter = IProtocolAdapter> (protocolId: string): T | null {
|
||||
return (this.adapters.get(protocolId) as T) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OneBot 协议适配器
|
||||
*/
|
||||
getOneBotAdapter (): OneBotProtocolAdapter | null {
|
||||
return this.getAdapter<OneBotProtocolAdapter>('onebot11');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Satori 协议适配器
|
||||
*/
|
||||
getSatoriAdapter (): SatoriProtocolAdapter | null {
|
||||
return this.getAdapter<SatoriProtocolAdapter>('satori');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取协议配置
|
||||
*/
|
||||
async getProtocolConfig (protocolId: string, uin: string): Promise<any> {
|
||||
const configPath = resolve(this.pathWrapper.configPath, `./${protocolId}_${uin}.json`);
|
||||
if (!existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
return json5.parse(content);
|
||||
} catch (error) {
|
||||
this.context.logger.logError(`[Protocol] 读取协议 ${protocolId} 配置失败:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置协议配置
|
||||
*/
|
||||
async setProtocolConfig (protocolId: string, uin: string, config: any): Promise<void> {
|
||||
const configPath = resolve(this.pathWrapper.configPath, `./${protocolId}_${uin}.json`);
|
||||
const prevConfig = await this.getProtocolConfig(protocolId, uin);
|
||||
|
||||
try {
|
||||
writeFileSync(configPath, json5.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
// 热重载配置
|
||||
if (this.adapters.has(protocolId)) {
|
||||
await this.reloadProtocolConfig(protocolId, prevConfig, config);
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError(`[Protocol] 保存协议 ${protocolId} 配置失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重载协议配置
|
||||
*/
|
||||
async reloadProtocolConfig (protocolId: string, prevConfig: unknown, newConfig: unknown): Promise<void> {
|
||||
const adapter = this.adapters.get(protocolId);
|
||||
if (!adapter) {
|
||||
this.context.logger.logWarn(`[Protocol] 协议 ${protocolId} 未初始化,无法重载配置`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adapter.reloadConfig(prevConfig, newConfig);
|
||||
this.context.logger.log(`[Protocol] 协议 ${adapter.name} 配置已重载`);
|
||||
} catch (error) {
|
||||
this.context.logger.logError(`[Protocol] 协议 ${protocolId} 配置重载失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查协议是否已初始化
|
||||
*/
|
||||
isProtocolInitialized (protocolId: string): boolean {
|
||||
return this.adapters.has(protocolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已初始化的协议ID
|
||||
*/
|
||||
getInitializedProtocolIds (): string[] {
|
||||
return Array.from(this.adapters.keys());
|
||||
}
|
||||
}
|
||||
20
packages/napcat-protocol/package.json
Normal file
20
packages/napcat-protocol/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "napcat-protocol",
|
||||
"version": "1.0.0",
|
||||
"description": "NapCat Protocol Manager - Unified protocol adapter management for OneBot and Satori",
|
||||
"main": "index.ts",
|
||||
"types": "index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-satori": "workspace:*",
|
||||
"json5": "^2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
17
packages/napcat-protocol/tsconfig.json
Normal file
17
packages/napcat-protocol/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
64
packages/napcat-protocol/types.ts
Normal file
64
packages/napcat-protocol/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { InstanceContext, NapCatCore } from 'napcat-core';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
|
||||
/**
|
||||
* 协议适配器基础接口
|
||||
*/
|
||||
export interface IProtocolAdapter {
|
||||
/** 协议名称 */
|
||||
readonly name: string;
|
||||
/** 协议ID */
|
||||
readonly id: string;
|
||||
/** 协议版本 */
|
||||
readonly version: string;
|
||||
/** 协议描述 */
|
||||
readonly description: string;
|
||||
|
||||
/** 初始化协议适配器 */
|
||||
init (): Promise<void>;
|
||||
/** 销毁协议适配器 */
|
||||
destroy (): Promise<void>;
|
||||
/** 重载配置 */
|
||||
reloadConfig (prevConfig: unknown, newConfig: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议适配器工厂接口
|
||||
*/
|
||||
export interface IProtocolAdapterFactory<T extends IProtocolAdapter = IProtocolAdapter> {
|
||||
/** 协议ID */
|
||||
readonly protocolId: string;
|
||||
/** 协议名称 */
|
||||
readonly protocolName: string;
|
||||
/** 协议版本 */
|
||||
readonly protocolVersion: string;
|
||||
/** 协议描述 */
|
||||
readonly protocolDescription: string;
|
||||
|
||||
/** 创建协议适配器实例 */
|
||||
create (
|
||||
core: NapCatCore,
|
||||
context: InstanceContext,
|
||||
pathWrapper: NapCatPathWrapper
|
||||
): T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议信息
|
||||
*/
|
||||
export interface ProtocolInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议管理器配置变更回调
|
||||
*/
|
||||
export type ProtocolConfigChangeCallback = (
|
||||
protocolId: string,
|
||||
prevConfig: unknown,
|
||||
newConfig: unknown
|
||||
) => Promise<void>;
|
||||
94
packages/napcat-satori/action/SatoriAction.ts
Normal file
94
packages/napcat-satori/action/SatoriAction.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import Ajv, { ErrorObject, ValidateFunction } from 'ajv';
|
||||
import { TSchema } from '@sinclair/typebox';
|
||||
|
||||
export interface SatoriCheckResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SatoriResponse<T = unknown> {
|
||||
data?: T;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class SatoriResponseHelper {
|
||||
static success<T> (data: T): SatoriResponse<T> {
|
||||
return { data };
|
||||
}
|
||||
|
||||
static error (code: number, message: string): SatoriResponse<null> {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class SatoriAction<PayloadType, ReturnType> {
|
||||
abstract actionName: string;
|
||||
protected satoriAdapter: NapCatSatoriAdapter;
|
||||
protected core: NapCatCore;
|
||||
payloadSchema?: TSchema = undefined;
|
||||
private validate?: ValidateFunction<unknown> = undefined;
|
||||
|
||||
constructor (satoriAdapter: NapCatSatoriAdapter, core: NapCatCore) {
|
||||
this.satoriAdapter = satoriAdapter;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求参数
|
||||
*/
|
||||
protected async check (payload: PayloadType): Promise<SatoriCheckResult> {
|
||||
if (this.payloadSchema) {
|
||||
this.validate = new Ajv({
|
||||
allowUnionTypes: true,
|
||||
useDefaults: true,
|
||||
coerceTypes: true,
|
||||
}).compile(this.payloadSchema);
|
||||
}
|
||||
|
||||
if (this.validate && !this.validate(payload)) {
|
||||
const errors = this.validate.errors as ErrorObject[];
|
||||
const errorMessages = errors.map(
|
||||
(e) => `Key: ${e.instancePath.split('/').slice(1).join('.')}, Message: ${e.message}`
|
||||
);
|
||||
return {
|
||||
valid: false,
|
||||
message: errorMessages.join('\n') ?? '未知错误',
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理请求入口(带验证)
|
||||
*/
|
||||
async handle (payload: PayloadType): Promise<ReturnType> {
|
||||
const checkResult = await this.check(payload);
|
||||
if (!checkResult.valid) {
|
||||
throw new Error(checkResult.message || '参数验证失败');
|
||||
}
|
||||
return this._handle(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际处理逻辑(子类实现)
|
||||
*/
|
||||
protected abstract _handle (payload: PayloadType): Promise<ReturnType>;
|
||||
|
||||
protected get logger () {
|
||||
return this.core.context.logger;
|
||||
}
|
||||
|
||||
protected get selfInfo () {
|
||||
return this.core.selfInfo;
|
||||
}
|
||||
|
||||
protected get platform () {
|
||||
return this.satoriAdapter.configLoader.configData.platform;
|
||||
}
|
||||
}
|
||||
60
packages/napcat-satori/action/channel/ChannelGet.ts
Normal file
60
packages/napcat-satori/action/channel/ChannelGet.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriChannel, SatoriChannelType } from '../../types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
channel_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class ChannelGetAction extends SatoriAction<Payload, SatoriChannel> {
|
||||
actionName = SatoriActionName.ChannelGet;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<SatoriChannel> {
|
||||
const { channel_id } = payload;
|
||||
|
||||
const parts = channel_id.split(':');
|
||||
const type = parts[0];
|
||||
const id = parts[1];
|
||||
|
||||
if (!type || !id) {
|
||||
throw new Error(`无效的频道ID格式: ${channel_id}`);
|
||||
}
|
||||
|
||||
if (type === 'private') {
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(id);
|
||||
const userInfo = await this.core.apis.UserApi.getUserDetailInfo(uid, false);
|
||||
|
||||
return {
|
||||
id: channel_id,
|
||||
type: SatoriChannelType.DIRECT,
|
||||
name: userInfo.nick || id,
|
||||
};
|
||||
} else if (type === 'group') {
|
||||
// 先从群列表缓存中查找
|
||||
const groups = await this.core.apis.GroupApi.getGroups();
|
||||
const group = groups.find((e) => e.groupCode === id);
|
||||
|
||||
if (!group) {
|
||||
// 如果缓存中没有,尝试获取详细信息
|
||||
const data = await this.core.apis.GroupApi.fetchGroupDetail(id);
|
||||
return {
|
||||
id: channel_id,
|
||||
type: SatoriChannelType.TEXT,
|
||||
name: data.groupName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: channel_id,
|
||||
type: SatoriChannelType.TEXT,
|
||||
name: group.groupName,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`不支持的频道类型: ${type}`);
|
||||
}
|
||||
}
|
||||
44
packages/napcat-satori/action/channel/ChannelList.ts
Normal file
44
packages/napcat-satori/action/channel/ChannelList.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriChannel, SatoriChannelType, SatoriPageResult } from '../../types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
guild_id: Type.String(),
|
||||
next: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class ChannelListAction extends SatoriAction<Payload, SatoriPageResult<SatoriChannel>> {
|
||||
actionName = SatoriActionName.ChannelList;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<SatoriPageResult<SatoriChannel>> {
|
||||
const { guild_id } = payload;
|
||||
|
||||
// 在 QQ 中,群组只有一个文本频道
|
||||
// 先从群列表缓存中查找
|
||||
const groups = await this.core.apis.GroupApi.getGroups();
|
||||
const group = groups.find((e) => e.groupCode === guild_id);
|
||||
let groupName: string;
|
||||
|
||||
if (!group) {
|
||||
// 如果缓存中没有,尝试获取详细信息
|
||||
const data = await this.core.apis.GroupApi.fetchGroupDetail(guild_id);
|
||||
groupName = data.groupName;
|
||||
} else {
|
||||
groupName = group.groupName;
|
||||
}
|
||||
|
||||
const channel: SatoriChannel = {
|
||||
id: `group:${guild_id}`,
|
||||
type: SatoriChannelType.TEXT,
|
||||
name: groupName,
|
||||
};
|
||||
|
||||
return {
|
||||
data: [channel],
|
||||
};
|
||||
}
|
||||
}
|
||||
39
packages/napcat-satori/action/guild/GuildApprove.ts
Normal file
39
packages/napcat-satori/action/guild/GuildApprove.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { GroupNotifyMsgType, NTGroupRequestOperateTypes } from 'napcat-core';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
message_id: Type.String(), // 邀请请求的 seq
|
||||
approve: Type.Boolean(),
|
||||
comment: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GuildApproveAction extends SatoriAction<Payload, void> {
|
||||
actionName = SatoriActionName.GuildApprove;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<void> {
|
||||
const { message_id, approve, comment } = payload;
|
||||
|
||||
// message_id 是邀请请求的 seq
|
||||
const notifies = await this.core.apis.GroupApi.getSingleScreenNotifies(true, 100);
|
||||
const notify = notifies.find(
|
||||
(e) =>
|
||||
e.seq == message_id && // 使用 loose equality 以防类型不匹配
|
||||
(e.type === GroupNotifyMsgType.INVITED_BY_MEMBER || e.type === GroupNotifyMsgType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS)
|
||||
);
|
||||
|
||||
if (!notify) {
|
||||
throw new Error(`未找到加群邀请: ${message_id}`);
|
||||
}
|
||||
|
||||
const operateType = approve
|
||||
? NTGroupRequestOperateTypes.KAGREE
|
||||
: NTGroupRequestOperateTypes.KREFUSE;
|
||||
|
||||
await this.core.apis.GroupApi.handleGroupRequest(false, notify, operateType, comment);
|
||||
}
|
||||
}
|
||||
39
packages/napcat-satori/action/guild/GuildGet.ts
Normal file
39
packages/napcat-satori/action/guild/GuildGet.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriGuild } from '../../types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
guild_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GuildGetAction extends SatoriAction<Payload, SatoriGuild> {
|
||||
actionName = SatoriActionName.GuildGet;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<SatoriGuild> {
|
||||
const { guild_id } = payload;
|
||||
|
||||
// 先从群列表缓存中查找
|
||||
const groups = await this.core.apis.GroupApi.getGroups();
|
||||
const group = groups.find((e) => e.groupCode === guild_id);
|
||||
|
||||
if (!group) {
|
||||
// 如果缓存中没有,尝试获取详细信息
|
||||
const data = await this.core.apis.GroupApi.fetchGroupDetail(guild_id);
|
||||
return {
|
||||
id: guild_id,
|
||||
name: data.groupName,
|
||||
avatar: `https://p.qlogo.cn/gh/${guild_id}/${guild_id}/640`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: guild_id,
|
||||
name: group.groupName,
|
||||
avatar: `https://p.qlogo.cn/gh/${guild_id}/${guild_id}/640`,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
packages/napcat-satori/action/guild/GuildList.ts
Normal file
29
packages/napcat-satori/action/guild/GuildList.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriGuild, SatoriPageResult } from '../../types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
next: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GuildListAction extends SatoriAction<Payload, SatoriPageResult<SatoriGuild>> {
|
||||
actionName = SatoriActionName.GuildList;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (_payload: Payload): Promise<SatoriPageResult<SatoriGuild>> {
|
||||
const groups = await this.core.apis.GroupApi.getGroups(true);
|
||||
|
||||
const guilds: SatoriGuild[] = groups.map((group) => ({
|
||||
id: group.groupCode,
|
||||
name: group.groupName,
|
||||
avatar: `https://p.qlogo.cn/gh/${group.groupCode}/${group.groupCode}/640`,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: guilds,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
packages/napcat-satori/action/guild/GuildMemberApprove.ts
Normal file
39
packages/napcat-satori/action/guild/GuildMemberApprove.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { GroupNotifyMsgType, NTGroupRequestOperateTypes } from 'napcat-core';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
message_id: Type.String(), // 入群请求的 seq
|
||||
approve: Type.Boolean(),
|
||||
comment: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GuildMemberApproveAction extends SatoriAction<Payload, void> {
|
||||
actionName = SatoriActionName.GuildMemberApprove;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<void> {
|
||||
const { message_id, approve, comment } = payload;
|
||||
|
||||
// message_id 是入群请求的 seq
|
||||
const notifies = await this.core.apis.GroupApi.getSingleScreenNotifies(true, 100);
|
||||
const notify = notifies.find(
|
||||
(e) =>
|
||||
e.seq === message_id &&
|
||||
e.type === GroupNotifyMsgType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS
|
||||
);
|
||||
|
||||
if (!notify) {
|
||||
throw new Error(`未找到入群请求: ${message_id}`);
|
||||
}
|
||||
|
||||
const operateType = approve
|
||||
? NTGroupRequestOperateTypes.KAGREE
|
||||
: NTGroupRequestOperateTypes.KREFUSE;
|
||||
|
||||
await this.core.apis.GroupApi.handleGroupRequest(false, notify, operateType, comment);
|
||||
}
|
||||
}
|
||||
36
packages/napcat-satori/action/guild/GuildMemberGet.ts
Normal file
36
packages/napcat-satori/action/guild/GuildMemberGet.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriGuildMember } from '../../types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
guild_id: Type.String(),
|
||||
user_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GuildMemberGetAction extends SatoriAction<Payload, SatoriGuildMember> {
|
||||
actionName = SatoriActionName.GuildMemberGet;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<SatoriGuildMember> {
|
||||
const { guild_id, user_id } = payload;
|
||||
|
||||
const memberInfo = await this.core.apis.GroupApi.getGroupMember(guild_id, user_id);
|
||||
|
||||
if (!memberInfo) {
|
||||
throw new Error('群成员不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: memberInfo.uin,
|
||||
name: memberInfo.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${memberInfo.uin}&s=640`,
|
||||
},
|
||||
nick: memberInfo.cardName || memberInfo.nick,
|
||||
joined_at: memberInfo.joinTime ? Number(memberInfo.joinTime) * 1000 : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
27
packages/napcat-satori/action/guild/GuildMemberKick.ts
Normal file
27
packages/napcat-satori/action/guild/GuildMemberKick.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
guild_id: Type.String(),
|
||||
user_id: Type.String(),
|
||||
permanent: Type.Optional(Type.Boolean({ default: false })),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GuildMemberKickAction extends SatoriAction<Payload, void> {
|
||||
actionName = SatoriActionName.GuildMemberKick;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<void> {
|
||||
const { guild_id, user_id, permanent } = payload;
|
||||
|
||||
await this.core.apis.GroupApi.kickMember(
|
||||
guild_id,
|
||||
[await this.core.apis.UserApi.getUidByUinV2(user_id)],
|
||||
permanent ?? false,
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
39
packages/napcat-satori/action/guild/GuildMemberList.ts
Normal file
39
packages/napcat-satori/action/guild/GuildMemberList.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriGuildMember, SatoriPageResult } from '../../types';
|
||||
import { GroupMember } from 'napcat-core';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
guild_id: Type.String(),
|
||||
next: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GuildMemberListAction extends SatoriAction<Payload, SatoriPageResult<SatoriGuildMember>> {
|
||||
actionName = SatoriActionName.GuildMemberList;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<SatoriPageResult<SatoriGuildMember>> {
|
||||
const { guild_id } = payload;
|
||||
|
||||
// 使用 getGroupMemberAll 获取所有群成员
|
||||
const result = await this.core.apis.GroupApi.getGroupMemberAll(guild_id, true);
|
||||
const members: Map<string, GroupMember> = result.result.infos;
|
||||
|
||||
const memberList: SatoriGuildMember[] = Array.from(members.values()).map((member: GroupMember) => ({
|
||||
user: {
|
||||
id: member.uin,
|
||||
name: member.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${member.uin}&s=640`,
|
||||
},
|
||||
nick: member.cardName || member.nick,
|
||||
joined_at: member.joinTime ? Number(member.joinTime) * 1000 : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: memberList,
|
||||
};
|
||||
}
|
||||
}
|
||||
28
packages/napcat-satori/action/guild/GuildMemberMute.ts
Normal file
28
packages/napcat-satori/action/guild/GuildMemberMute.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
guild_id: Type.String(),
|
||||
user_id: Type.String(),
|
||||
duration: Type.Optional(Type.Number({ default: 0 })), // 禁言时长(毫秒),0 表示解除禁言
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GuildMemberMuteAction extends SatoriAction<Payload, void> {
|
||||
actionName = SatoriActionName.GuildMemberMute;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<void> {
|
||||
const { guild_id, user_id, duration } = payload;
|
||||
|
||||
// 将毫秒转换为秒
|
||||
const durationSeconds = duration ? Math.floor(duration / 1000) : 0;
|
||||
|
||||
await this.core.apis.GroupApi.banMember(
|
||||
guild_id,
|
||||
[{ uid: await this.core.apis.UserApi.getUidByUinV2(user_id), timeStamp: durationSeconds }]
|
||||
);
|
||||
}
|
||||
}
|
||||
68
packages/napcat-satori/action/index.ts
Normal file
68
packages/napcat-satori/action/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import { SatoriAction } from './SatoriAction';
|
||||
|
||||
// 导入所有 Action
|
||||
import { MessageCreateAction } from './message/MessageCreate';
|
||||
import { MessageGetAction } from './message/MessageGet';
|
||||
import { MessageDeleteAction } from './message/MessageDelete';
|
||||
import { ChannelGetAction } from './channel/ChannelGet';
|
||||
import { ChannelListAction } from './channel/ChannelList';
|
||||
import { GuildGetAction } from './guild/GuildGet';
|
||||
import { GuildListAction } from './guild/GuildList';
|
||||
import { GuildApproveAction } from './guild/GuildApprove';
|
||||
import { GuildMemberGetAction } from './guild/GuildMemberGet';
|
||||
import { GuildMemberListAction } from './guild/GuildMemberList';
|
||||
import { GuildMemberKickAction } from './guild/GuildMemberKick';
|
||||
import { GuildMemberMuteAction } from './guild/GuildMemberMute';
|
||||
import { GuildMemberApproveAction } from './guild/GuildMemberApprove';
|
||||
import { UserGetAction } from './user/UserGet';
|
||||
import { FriendListAction } from './user/FriendList';
|
||||
import { FriendApproveAction } from './user/FriendApprove';
|
||||
import { LoginGetAction } from './login/LoginGet';
|
||||
import { UploadCreateAction } from './upload/UploadCreate';
|
||||
|
||||
export type SatoriActionMap = Map<string, SatoriAction<unknown, unknown>>;
|
||||
|
||||
export function createSatoriActionMap (
|
||||
satoriAdapter: NapCatSatoriAdapter,
|
||||
core: NapCatCore
|
||||
): SatoriActionMap {
|
||||
const actionMap: SatoriActionMap = new Map();
|
||||
|
||||
const actions: SatoriAction<unknown, unknown>[] = [
|
||||
// 消息相关
|
||||
new MessageCreateAction(satoriAdapter, core),
|
||||
new MessageGetAction(satoriAdapter, core),
|
||||
new MessageDeleteAction(satoriAdapter, core),
|
||||
// 频道相关
|
||||
new ChannelGetAction(satoriAdapter, core),
|
||||
new ChannelListAction(satoriAdapter, core),
|
||||
// 群组相关
|
||||
new GuildGetAction(satoriAdapter, core),
|
||||
new GuildListAction(satoriAdapter, core),
|
||||
new GuildApproveAction(satoriAdapter, core),
|
||||
new GuildMemberGetAction(satoriAdapter, core),
|
||||
new GuildMemberListAction(satoriAdapter, core),
|
||||
new GuildMemberKickAction(satoriAdapter, core),
|
||||
new GuildMemberMuteAction(satoriAdapter, core),
|
||||
new GuildMemberApproveAction(satoriAdapter, core),
|
||||
// 用户相关
|
||||
new UserGetAction(satoriAdapter, core),
|
||||
new FriendListAction(satoriAdapter, core),
|
||||
new FriendApproveAction(satoriAdapter, core),
|
||||
// 登录相关
|
||||
new LoginGetAction(satoriAdapter, core),
|
||||
// 上传相关
|
||||
new UploadCreateAction(satoriAdapter, core),
|
||||
];
|
||||
|
||||
for (const action of actions) {
|
||||
actionMap.set(action.actionName, action);
|
||||
}
|
||||
|
||||
return actionMap;
|
||||
}
|
||||
|
||||
export { SatoriAction, SatoriCheckResult, SatoriResponse, SatoriResponseHelper } from './SatoriAction';
|
||||
export { SatoriActionName, SatoriActionNameType } from './router';
|
||||
26
packages/napcat-satori/action/login/LoginGet.ts
Normal file
26
packages/napcat-satori/action/login/LoginGet.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriLogin, SatoriLoginStatus } from '../../types';
|
||||
|
||||
const SchemaData = Type.Object({});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class LoginGetAction extends SatoriAction<Payload, SatoriLogin> {
|
||||
actionName = SatoriActionName.LoginGet;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (_payload: Payload): Promise<SatoriLogin> {
|
||||
return {
|
||||
user: {
|
||||
id: this.selfInfo.uin,
|
||||
name: this.selfInfo.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.selfInfo.uin}&s=640`,
|
||||
},
|
||||
self_id: this.selfInfo.uin,
|
||||
platform: this.platform,
|
||||
status: SatoriLoginStatus.ONLINE,
|
||||
};
|
||||
}
|
||||
}
|
||||
74
packages/napcat-satori/action/message/MessageCreate.ts
Normal file
74
packages/napcat-satori/action/message/MessageCreate.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriMessage, SatoriChannelType } from '../../types';
|
||||
import { ChatType, SendMessageElement } from 'napcat-core';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
channel_id: Type.String(),
|
||||
content: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class MessageCreateAction extends SatoriAction<Payload, SatoriMessage[]> {
|
||||
actionName = SatoriActionName.MessageCreate;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<SatoriMessage[]> {
|
||||
const { channel_id, content } = payload;
|
||||
|
||||
// 解析 channel_id,格式: private:{user_id} 或 group:{group_id}
|
||||
const parts = channel_id.split(':');
|
||||
const type = parts[0];
|
||||
const id = parts[1];
|
||||
|
||||
if (!type || !id) {
|
||||
throw new Error(`无效的频道ID格式: ${channel_id}`);
|
||||
}
|
||||
|
||||
let chatType: ChatType;
|
||||
let peerUid: string;
|
||||
|
||||
if (type === 'private') {
|
||||
chatType = ChatType.KCHATTYPEC2C;
|
||||
peerUid = await this.core.apis.UserApi.getUidByUinV2(id);
|
||||
} else if (type === 'group') {
|
||||
chatType = ChatType.KCHATTYPEGROUP;
|
||||
peerUid = id;
|
||||
} else {
|
||||
throw new Error(`不支持的频道类型: ${type}`);
|
||||
}
|
||||
|
||||
// 解析 Satori 消息内容为 NapCat 消息元素
|
||||
const elements = await this.satoriAdapter.apis.MsgApi.parseContent(content);
|
||||
|
||||
// 发送消息
|
||||
const result = await this.core.apis.MsgApi.sendMsg(
|
||||
{ chatType, peerUid, guildId: '' },
|
||||
elements as SendMessageElement[],
|
||||
30000
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('消息发送失败: 未知错误');
|
||||
}
|
||||
|
||||
// 构造返回结果
|
||||
const message: SatoriMessage = {
|
||||
id: result.msgId,
|
||||
content,
|
||||
channel: {
|
||||
id: channel_id,
|
||||
type: type === 'private' ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
|
||||
},
|
||||
user: {
|
||||
id: this.selfInfo.uin,
|
||||
name: this.selfInfo.nick,
|
||||
},
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
return [message];
|
||||
}
|
||||
}
|
||||
44
packages/napcat-satori/action/message/MessageDelete.ts
Normal file
44
packages/napcat-satori/action/message/MessageDelete.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { ChatType } from 'napcat-core';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
channel_id: Type.String(),
|
||||
message_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class MessageDeleteAction extends SatoriAction<Payload, void> {
|
||||
actionName = SatoriActionName.MessageDelete;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<void> {
|
||||
const { channel_id, message_id } = payload;
|
||||
|
||||
const parts = channel_id.split(':');
|
||||
const type = parts[0];
|
||||
const id = parts[1];
|
||||
|
||||
if (!type || !id) {
|
||||
throw new Error(`无效的频道ID格式: ${channel_id}`);
|
||||
}
|
||||
|
||||
let chatType: ChatType;
|
||||
let peerUid: string;
|
||||
|
||||
if (type === 'private') {
|
||||
chatType = ChatType.KCHATTYPEC2C;
|
||||
peerUid = await this.core.apis.UserApi.getUidByUinV2(id);
|
||||
} else if (type === 'group') {
|
||||
chatType = ChatType.KCHATTYPEGROUP;
|
||||
peerUid = id;
|
||||
} else {
|
||||
throw new Error(`不支持的频道类型: ${type}`);
|
||||
}
|
||||
|
||||
const peer = { chatType, peerUid, guildId: '' };
|
||||
await this.core.apis.MsgApi.recallMsg(peer, message_id);
|
||||
}
|
||||
}
|
||||
72
packages/napcat-satori/action/message/MessageGet.ts
Normal file
72
packages/napcat-satori/action/message/MessageGet.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriMessage, SatoriChannelType } from '../../types';
|
||||
import { ChatType } from 'napcat-core';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
channel_id: Type.String(),
|
||||
message_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class MessageGetAction extends SatoriAction<Payload, SatoriMessage> {
|
||||
actionName = SatoriActionName.MessageGet;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<SatoriMessage> {
|
||||
const { channel_id, message_id } = payload;
|
||||
|
||||
const parts = channel_id.split(':');
|
||||
const type = parts[0];
|
||||
const id = parts[1];
|
||||
|
||||
if (!type || !id) {
|
||||
throw new Error(`无效的频道ID: ${channel_id}`);
|
||||
}
|
||||
|
||||
let chatType: ChatType;
|
||||
let peerUid: string;
|
||||
|
||||
if (type === 'private') {
|
||||
chatType = ChatType.KCHATTYPEC2C;
|
||||
peerUid = await this.core.apis.UserApi.getUidByUinV2(id);
|
||||
} else if (type === 'group') {
|
||||
chatType = ChatType.KCHATTYPEGROUP;
|
||||
peerUid = id;
|
||||
} else {
|
||||
throw new Error(`不支持的频道类型: ${type}`);
|
||||
}
|
||||
|
||||
const peer = { chatType, peerUid, guildId: '' };
|
||||
const msgs = await this.core.apis.MsgApi.getMsgsByMsgId(peer, [message_id]);
|
||||
|
||||
if (!msgs || msgs.msgList.length === 0) {
|
||||
throw new Error('消息不存在');
|
||||
}
|
||||
|
||||
const msg = msgs.msgList[0];
|
||||
if (!msg) {
|
||||
throw new Error('消息不存在');
|
||||
}
|
||||
|
||||
const content = await this.satoriAdapter.apis.MsgApi.parseElements(msg.elements);
|
||||
|
||||
const message: SatoriMessage = {
|
||||
id: msg.msgId,
|
||||
content,
|
||||
channel: {
|
||||
id: channel_id,
|
||||
type: type === 'private' ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
|
||||
},
|
||||
user: {
|
||||
id: msg.senderUin,
|
||||
name: msg.sendNickName,
|
||||
},
|
||||
created_at: parseInt(msg.msgTime) * 1000,
|
||||
};
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
57
packages/napcat-satori/action/router.ts
Normal file
57
packages/napcat-satori/action/router.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Satori Action 名称映射
|
||||
*/
|
||||
export const SatoriActionName = {
|
||||
// 消息相关
|
||||
MessageCreate: 'message.create',
|
||||
MessageGet: 'message.get',
|
||||
MessageDelete: 'message.delete',
|
||||
MessageUpdate: 'message.update',
|
||||
MessageList: 'message.list',
|
||||
|
||||
// 频道相关
|
||||
ChannelGet: 'channel.get',
|
||||
ChannelList: 'channel.list',
|
||||
ChannelCreate: 'channel.create',
|
||||
ChannelUpdate: 'channel.update',
|
||||
ChannelDelete: 'channel.delete',
|
||||
ChannelMute: 'channel.mute',
|
||||
|
||||
// 群组/公会相关
|
||||
GuildGet: 'guild.get',
|
||||
GuildList: 'guild.list',
|
||||
GuildApprove: 'guild.approve',
|
||||
|
||||
// 群成员相关
|
||||
GuildMemberGet: 'guild.member.get',
|
||||
GuildMemberList: 'guild.member.list',
|
||||
GuildMemberKick: 'guild.member.kick',
|
||||
GuildMemberMute: 'guild.member.mute',
|
||||
GuildMemberApprove: 'guild.member.approve',
|
||||
GuildMemberRole: 'guild.member.role',
|
||||
|
||||
// 角色相关
|
||||
GuildRoleList: 'guild.role.list',
|
||||
GuildRoleCreate: 'guild.role.create',
|
||||
GuildRoleUpdate: 'guild.role.update',
|
||||
GuildRoleDelete: 'guild.role.delete',
|
||||
|
||||
// 用户相关
|
||||
UserGet: 'user.get',
|
||||
UserChannelCreate: 'user.channel.create',
|
||||
|
||||
// 好友相关
|
||||
FriendList: 'friend.list',
|
||||
FriendApprove: 'friend.approve',
|
||||
|
||||
// 登录相关
|
||||
LoginGet: 'login.get',
|
||||
|
||||
// 上传相关
|
||||
UploadCreate: 'upload.create',
|
||||
|
||||
// 内部互操作(Satori 可选)
|
||||
InternalAction: 'internal.action',
|
||||
} as const;
|
||||
|
||||
export type SatoriActionNameType = typeof SatoriActionName[keyof typeof SatoriActionName];
|
||||
46
packages/napcat-satori/action/upload/UploadCreate.ts
Normal file
46
packages/napcat-satori/action/upload/UploadCreate.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
|
||||
const SchemaData = Type.Record(Type.String(), Type.Unknown());
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
interface UploadResult {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export class UploadCreateAction extends SatoriAction<Payload, UploadResult> {
|
||||
actionName = SatoriActionName.UploadCreate;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<UploadResult> {
|
||||
const result: UploadResult = {};
|
||||
|
||||
// 处理上传的文件
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (typeof value === 'string' && value.startsWith('data:')) {
|
||||
// Base64 数据
|
||||
const matches = value.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (matches && matches[1] && matches[2]) {
|
||||
const mimeType = matches[1];
|
||||
const base64Data = matches[2];
|
||||
// 保存文件并返回 URL
|
||||
const url = await this.saveFile(base64Data, mimeType);
|
||||
result[key] = url;
|
||||
}
|
||||
} else if (typeof value === 'string') {
|
||||
// 可能是 URL,直接返回
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async saveFile (base64Data: string, _mimeType: string): Promise<string> {
|
||||
// 将 base64 数据保存为临时文件并返回 URL
|
||||
// 这里简化处理,实际应该保存到文件系统
|
||||
return `base64://${base64Data}`;
|
||||
}
|
||||
}
|
||||
31
packages/napcat-satori/action/user/FriendApprove.ts
Normal file
31
packages/napcat-satori/action/user/FriendApprove.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
message_id: Type.String(),
|
||||
approve: Type.Boolean(),
|
||||
comment: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class FriendApproveAction extends SatoriAction<Payload, void> {
|
||||
actionName = SatoriActionName.FriendApprove;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<void> {
|
||||
const { message_id, approve } = payload;
|
||||
|
||||
// message_id 格式: reqTime (好友请求的时间戳)
|
||||
// 需要从好友请求列表中找到对应的请求
|
||||
const buddyReqData = await this.core.apis.FriendApi.getBuddyReq();
|
||||
const notify = buddyReqData.buddyReqs.find((e) => e.reqTime === message_id);
|
||||
|
||||
if (!notify) {
|
||||
throw new Error(`未找到好友请求: ${message_id}`);
|
||||
}
|
||||
|
||||
await this.core.apis.FriendApi.handleFriendRequest(notify, approve);
|
||||
}
|
||||
}
|
||||
30
packages/napcat-satori/action/user/FriendList.ts
Normal file
30
packages/napcat-satori/action/user/FriendList.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriUser, SatoriPageResult } from '../../types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
next: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class FriendListAction extends SatoriAction<Payload, SatoriPageResult<SatoriUser>> {
|
||||
actionName = SatoriActionName.FriendList;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (_payload: Payload): Promise<SatoriPageResult<SatoriUser>> {
|
||||
const friends = await this.core.apis.FriendApi.getBuddy();
|
||||
|
||||
const friendList: SatoriUser[] = friends.map((friend) => ({
|
||||
id: friend.uin || '',
|
||||
name: friend.coreInfo?.nick || '',
|
||||
nick: friend.coreInfo?.remark || friend.coreInfo?.nick || '',
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${friend.uin}&s=640`,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: friendList,
|
||||
};
|
||||
}
|
||||
}
|
||||
28
packages/napcat-satori/action/user/UserGet.ts
Normal file
28
packages/napcat-satori/action/user/UserGet.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { SatoriAction } from '../SatoriAction';
|
||||
import { SatoriActionName } from '../router';
|
||||
import { SatoriUser } from '../../types';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
user_id: Type.String(),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class UserGetAction extends SatoriAction<Payload, SatoriUser> {
|
||||
actionName = SatoriActionName.UserGet;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
protected async _handle (payload: Payload): Promise<SatoriUser> {
|
||||
const { user_id } = payload;
|
||||
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(user_id);
|
||||
const userInfo = await this.core.apis.UserApi.getUserDetailInfo(uid, false);
|
||||
|
||||
return {
|
||||
id: user_id,
|
||||
name: userInfo.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${user_id}&s=640`,
|
||||
};
|
||||
}
|
||||
}
|
||||
417
packages/napcat-satori/api/event.ts
Normal file
417
packages/napcat-satori/api/event.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { NapCatCore, RawMessage, ChatType, GroupNotify, FriendRequest } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import {
|
||||
SatoriEvent,
|
||||
SatoriChannelType,
|
||||
SatoriLoginStatus,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Satori 事件类型定义
|
||||
*/
|
||||
export const SatoriEventType = {
|
||||
// 消息事件
|
||||
MESSAGE_CREATED: 'message-created',
|
||||
MESSAGE_UPDATED: 'message-updated',
|
||||
MESSAGE_DELETED: 'message-deleted',
|
||||
|
||||
// 频道事件
|
||||
CHANNEL_CREATED: 'channel-created',
|
||||
CHANNEL_UPDATED: 'channel-updated',
|
||||
CHANNEL_DELETED: 'channel-deleted',
|
||||
|
||||
// 群组/公会事件
|
||||
GUILD_ADDED: 'guild-added',
|
||||
GUILD_UPDATED: 'guild-updated',
|
||||
GUILD_REMOVED: 'guild-removed',
|
||||
GUILD_REQUEST: 'guild-request',
|
||||
|
||||
// 群成员事件
|
||||
GUILD_MEMBER_ADDED: 'guild-member-added',
|
||||
GUILD_MEMBER_UPDATED: 'guild-member-updated',
|
||||
GUILD_MEMBER_REMOVED: 'guild-member-removed',
|
||||
GUILD_MEMBER_REQUEST: 'guild-member-request',
|
||||
|
||||
// 角色事件
|
||||
GUILD_ROLE_CREATED: 'guild-role-created',
|
||||
GUILD_ROLE_UPDATED: 'guild-role-updated',
|
||||
GUILD_ROLE_DELETED: 'guild-role-deleted',
|
||||
|
||||
// 好友事件
|
||||
FRIEND_REQUEST: 'friend-request',
|
||||
|
||||
// 登录事件
|
||||
LOGIN_ADDED: 'login-added',
|
||||
LOGIN_REMOVED: 'login-removed',
|
||||
LOGIN_UPDATED: 'login-updated',
|
||||
|
||||
// 内部事件
|
||||
INTERNAL: 'internal',
|
||||
} as const;
|
||||
|
||||
export type SatoriEventTypeName = typeof SatoriEventType[keyof typeof SatoriEventType];
|
||||
|
||||
export class SatoriEventApi {
|
||||
private satoriAdapter: NapCatSatoriAdapter;
|
||||
private core: NapCatCore;
|
||||
private eventId: number = 0;
|
||||
|
||||
constructor (satoriAdapter: NapCatSatoriAdapter, core: NapCatCore) {
|
||||
this.satoriAdapter = satoriAdapter;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
private getNextEventId (): number {
|
||||
return ++this.eventId;
|
||||
}
|
||||
|
||||
private get platform (): string {
|
||||
return this.satoriAdapter.configLoader.configData.platform;
|
||||
}
|
||||
|
||||
private get selfId (): string {
|
||||
return this.core.selfInfo.uin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基础事件结构
|
||||
*/
|
||||
private createBaseEvent (type: SatoriEventTypeName): SatoriEvent {
|
||||
return {
|
||||
id: this.getNextEventId(),
|
||||
type,
|
||||
platform: this.platform,
|
||||
self_id: this.selfId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 NapCat 消息转换为 Satori 事件
|
||||
*/
|
||||
async createMessageEvent (message: RawMessage): Promise<SatoriEvent | null> {
|
||||
try {
|
||||
const content = await this.satoriAdapter.apis.MsgApi.parseElements(message.elements);
|
||||
const isPrivate = message.chatType === ChatType.KCHATTYPEC2C;
|
||||
|
||||
const event = this.createBaseEvent(SatoriEventType.MESSAGE_CREATED);
|
||||
event.timestamp = parseInt(message.msgTime) * 1000;
|
||||
event.channel = {
|
||||
id: isPrivate ? `private:${message.senderUin}` : `group:${message.peerUin}`,
|
||||
type: isPrivate ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
|
||||
};
|
||||
event.user = {
|
||||
id: message.senderUin,
|
||||
name: message.sendNickName,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${message.senderUin}&s=640`,
|
||||
};
|
||||
event.message = {
|
||||
id: message.msgId,
|
||||
content,
|
||||
};
|
||||
|
||||
if (!isPrivate) {
|
||||
event.guild = {
|
||||
id: message.peerUin,
|
||||
name: message.peerName,
|
||||
avatar: `https://p.qlogo.cn/gh/${message.peerUin}/${message.peerUin}/640`,
|
||||
};
|
||||
event.member = {
|
||||
nick: message.sendMemberName || message.sendNickName,
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
} catch (error) {
|
||||
this.core.context.logger.logError('[Satori] 创建消息事件失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建消息更新事件
|
||||
*/
|
||||
async createMessageUpdatedEvent (message: RawMessage): Promise<SatoriEvent | null> {
|
||||
try {
|
||||
const content = await this.satoriAdapter.apis.MsgApi.parseElements(message.elements);
|
||||
const isPrivate = message.chatType === ChatType.KCHATTYPEC2C;
|
||||
|
||||
const event = this.createBaseEvent(SatoriEventType.MESSAGE_UPDATED);
|
||||
event.channel = {
|
||||
id: isPrivate ? `private:${message.senderUin}` : `group:${message.peerUin}`,
|
||||
type: isPrivate ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
|
||||
};
|
||||
event.user = {
|
||||
id: message.senderUin,
|
||||
name: message.sendNickName,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${message.senderUin}&s=640`,
|
||||
};
|
||||
event.message = {
|
||||
id: message.msgId,
|
||||
content,
|
||||
};
|
||||
|
||||
return event;
|
||||
} catch (error) {
|
||||
this.core.context.logger.logError('[Satori] 创建消息更新事件失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建好友请求事件
|
||||
*/
|
||||
createFriendRequestEvent (request: FriendRequest): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.FRIEND_REQUEST);
|
||||
event.user = {
|
||||
id: request.friendUid,
|
||||
name: request.friendNick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${request.friendUid}&s=640`,
|
||||
};
|
||||
event.message = {
|
||||
id: request.reqTime,
|
||||
content: request.extWords,
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群组加入请求事件
|
||||
*/
|
||||
createGuildMemberRequestEvent (notify: GroupNotify): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.GUILD_MEMBER_REQUEST);
|
||||
event.guild = {
|
||||
id: notify.group.groupCode,
|
||||
name: notify.group.groupName,
|
||||
avatar: `https://p.qlogo.cn/gh/${notify.group.groupCode}/${notify.group.groupCode}/640`,
|
||||
};
|
||||
event.user = {
|
||||
id: notify.user1.uid,
|
||||
name: notify.user1.nickName,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${notify.user1.uid}&s=640`,
|
||||
};
|
||||
event.message = {
|
||||
id: notify.seq,
|
||||
content: notify.postscript,
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群组邀请事件
|
||||
*/
|
||||
createGuildRequestEvent (notify: GroupNotify): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.GUILD_REQUEST);
|
||||
event.guild = {
|
||||
id: notify.group.groupCode,
|
||||
name: notify.group.groupName,
|
||||
avatar: `https://p.qlogo.cn/gh/${notify.group.groupCode}/${notify.group.groupCode}/640`,
|
||||
};
|
||||
event.user = {
|
||||
id: notify.user2.uid,
|
||||
name: notify.user2.nickName,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${notify.user2.uid}&s=640`,
|
||||
};
|
||||
event.message = {
|
||||
id: notify.seq,
|
||||
content: notify.postscript,
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群成员增加事件
|
||||
*/
|
||||
createGuildMemberAddedEvent (
|
||||
guildId: string,
|
||||
guildName: string,
|
||||
userId: string,
|
||||
userName: string,
|
||||
operatorId?: string
|
||||
): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.GUILD_MEMBER_ADDED);
|
||||
event.guild = {
|
||||
id: guildId,
|
||||
name: guildName,
|
||||
avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`,
|
||||
};
|
||||
event.user = {
|
||||
id: userId,
|
||||
name: userName,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${userId}&s=640`,
|
||||
};
|
||||
|
||||
if (operatorId) {
|
||||
event.operator = {
|
||||
id: operatorId,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${operatorId}&s=640`,
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群成员移除事件
|
||||
*/
|
||||
createGuildMemberRemovedEvent (
|
||||
guildId: string,
|
||||
guildName: string,
|
||||
userId: string,
|
||||
userName: string,
|
||||
operatorId?: string
|
||||
): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.GUILD_MEMBER_REMOVED);
|
||||
event.guild = {
|
||||
id: guildId,
|
||||
name: guildName,
|
||||
avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`,
|
||||
};
|
||||
event.user = {
|
||||
id: userId,
|
||||
name: userName,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${userId}&s=640`,
|
||||
};
|
||||
|
||||
if (operatorId) {
|
||||
event.operator = {
|
||||
id: operatorId,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${operatorId}&s=640`,
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群添加事件(自己被邀请或加入群)
|
||||
*/
|
||||
createGuildAddedEvent (
|
||||
guildId: string,
|
||||
guildName: string
|
||||
): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.GUILD_ADDED);
|
||||
event.guild = {
|
||||
id: guildId,
|
||||
name: guildName,
|
||||
avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`,
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群移除事件(被踢出或退出群)
|
||||
*/
|
||||
createGuildRemovedEvent (
|
||||
guildId: string,
|
||||
guildName: string
|
||||
): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.GUILD_REMOVED);
|
||||
event.guild = {
|
||||
id: guildId,
|
||||
name: guildName,
|
||||
avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`,
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建消息删除事件
|
||||
*/
|
||||
createMessageDeletedEvent (
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
userId: string,
|
||||
operatorId?: string
|
||||
): SatoriEvent {
|
||||
const isPrivate = channelId.startsWith('private:');
|
||||
const event = this.createBaseEvent(SatoriEventType.MESSAGE_DELETED);
|
||||
event.channel = {
|
||||
id: channelId,
|
||||
type: isPrivate ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
|
||||
};
|
||||
event.user = {
|
||||
id: userId,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${userId}&s=640`,
|
||||
};
|
||||
event.message = {
|
||||
id: messageId,
|
||||
content: '',
|
||||
};
|
||||
|
||||
if (operatorId) {
|
||||
event.operator = {
|
||||
id: operatorId,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${operatorId}&s=640`,
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建登录添加事件
|
||||
*/
|
||||
createLoginAddedEvent (): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.LOGIN_ADDED);
|
||||
event.login = {
|
||||
user: {
|
||||
id: this.selfId,
|
||||
name: this.core.selfInfo.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.selfId}&s=640`,
|
||||
},
|
||||
self_id: this.selfId,
|
||||
platform: this.platform,
|
||||
status: SatoriLoginStatus.ONLINE,
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建登录移除事件
|
||||
*/
|
||||
createLoginRemovedEvent (): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.LOGIN_REMOVED);
|
||||
event.login = {
|
||||
user: {
|
||||
id: this.selfId,
|
||||
name: this.core.selfInfo.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.selfId}&s=640`,
|
||||
},
|
||||
self_id: this.selfId,
|
||||
platform: this.platform,
|
||||
status: SatoriLoginStatus.OFFLINE,
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建登录状态更新事件
|
||||
*/
|
||||
createLoginUpdatedEvent (status: SatoriLoginStatus): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.LOGIN_UPDATED);
|
||||
event.login = {
|
||||
user: {
|
||||
id: this.selfId,
|
||||
name: this.core.selfInfo.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.selfId}&s=640`,
|
||||
},
|
||||
self_id: this.selfId,
|
||||
platform: this.platform,
|
||||
status,
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建内部事件(用于扩展)
|
||||
*/
|
||||
createInternalEvent (typeName: string, data: Record<string, unknown>): SatoriEvent {
|
||||
const event = this.createBaseEvent(SatoriEventType.INTERNAL);
|
||||
event._type = typeName;
|
||||
event._data = data;
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
export { SatoriEventType as EventType };
|
||||
22
packages/napcat-satori/api/index.ts
Normal file
22
packages/napcat-satori/api/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import { SatoriMsgApi } from './msg';
|
||||
import { SatoriEventApi } from './event';
|
||||
|
||||
export interface SatoriApiList {
|
||||
MsgApi: SatoriMsgApi;
|
||||
EventApi: SatoriEventApi;
|
||||
}
|
||||
|
||||
export function createSatoriApis (
|
||||
satoriAdapter: NapCatSatoriAdapter,
|
||||
core: NapCatCore
|
||||
): SatoriApiList {
|
||||
return {
|
||||
MsgApi: new SatoriMsgApi(satoriAdapter, core),
|
||||
EventApi: new SatoriEventApi(satoriAdapter, core),
|
||||
};
|
||||
}
|
||||
|
||||
export { SatoriMsgApi } from './msg';
|
||||
export { SatoriEventApi } from './event';
|
||||
392
packages/napcat-satori/api/msg.ts
Normal file
392
packages/napcat-satori/api/msg.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { NapCatCore, MessageElement, ElementType, NTMsgAtType } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import SatoriElement from '@satorijs/element';
|
||||
|
||||
/**
|
||||
* Satori 消息处理 API
|
||||
* 使用 @satorijs/element 处理消息格式转换
|
||||
*/
|
||||
export class SatoriMsgApi {
|
||||
private core: NapCatCore;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
private _adapter: NapCatSatoriAdapter;
|
||||
|
||||
constructor (satoriAdapter: NapCatSatoriAdapter, core: NapCatCore) {
|
||||
this._adapter = satoriAdapter;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Satori 消息内容为 NapCat 消息元素
|
||||
* 使用 @satorijs/element 解析
|
||||
*/
|
||||
async parseContent (content: string): Promise<MessageElement[]> {
|
||||
const elements: MessageElement[] = [];
|
||||
const parsed = SatoriElement.parse(content);
|
||||
|
||||
for (const elem of parsed) {
|
||||
const parsedElements = await this.parseSatoriElement(elem);
|
||||
elements.push(...parsedElements);
|
||||
}
|
||||
|
||||
// 如果没有解析到任何元素,将整个内容作为文本
|
||||
if (elements.length === 0 && content.trim()) {
|
||||
elements.push(this.createTextElement(content));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 satorijs 元素为消息元素
|
||||
*/
|
||||
private async parseSatoriElement (elem: SatoriElement): Promise<MessageElement[]> {
|
||||
const elements: MessageElement[] = [];
|
||||
|
||||
switch (elem.type) {
|
||||
case 'text':
|
||||
if (elem.attrs['content']) {
|
||||
elements.push(this.createTextElement(elem.attrs['content']));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'at': {
|
||||
const attrs = elem.attrs;
|
||||
elements.push(await this.createAtElement({
|
||||
id: attrs['id'] || '',
|
||||
type: attrs['type'] || '',
|
||||
name: attrs['name'] || '',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'img':
|
||||
case 'image': {
|
||||
const attrs = elem.attrs;
|
||||
elements.push(await this.createImageElement({
|
||||
src: attrs['src'] || '',
|
||||
width: attrs['width'] || '',
|
||||
height: attrs['height'] || '',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'audio': {
|
||||
const attrs = elem.attrs;
|
||||
elements.push(await this.createAudioElement({
|
||||
src: attrs['src'] || '',
|
||||
duration: attrs['duration'] || '',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'video': {
|
||||
const attrs = elem.attrs;
|
||||
elements.push(await this.createVideoElement({
|
||||
src: attrs['src'] || '',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'file': {
|
||||
const attrs = elem.attrs;
|
||||
elements.push(await this.createFileElement({
|
||||
src: attrs['src'] || '',
|
||||
title: attrs['title'] || '',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'face': {
|
||||
const attrs = elem.attrs;
|
||||
elements.push(this.createFaceElement({
|
||||
id: attrs['id'] || '0',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'quote': {
|
||||
const attrs = elem.attrs;
|
||||
elements.push(await this.createQuoteElement({
|
||||
id: attrs['id'] || '',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'a': {
|
||||
const href = elem.attrs['href'];
|
||||
if (href) {
|
||||
const linkText = elem.children.map((c) => c.toString()).join('');
|
||||
elements.push(this.createTextElement(`${linkText} (${href})`));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'button': {
|
||||
const text = elem.attrs['text'];
|
||||
if (text) {
|
||||
elements.push(this.createTextElement(`[${text}]`));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'br':
|
||||
elements.push(this.createTextElement('\n'));
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
for (const child of elem.children) {
|
||||
elements.push(...await this.parseSatoriElement(child));
|
||||
}
|
||||
elements.push(this.createTextElement('\n'));
|
||||
break;
|
||||
|
||||
default:
|
||||
// 递归处理子元素
|
||||
if (elem.children) {
|
||||
for (const child of elem.children) {
|
||||
elements.push(...await this.parseSatoriElement(child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 NapCat 消息元素为 Satori XML 消息内容
|
||||
*/
|
||||
async parseElements (elements: MessageElement[]): Promise<string> {
|
||||
const satoriElements: SatoriElement[] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
const node = await this.elementToSatoriElement(element);
|
||||
if (node) {
|
||||
satoriElements.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return satoriElements.map((e) => e.toString()).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个消息元素转换为 SatoriElement
|
||||
*/
|
||||
private async elementToSatoriElement (element: MessageElement): Promise<SatoriElement | null> {
|
||||
switch (element.elementType) {
|
||||
case ElementType.TEXT:
|
||||
if (element.textElement) {
|
||||
if (element.textElement.atType === NTMsgAtType.ATTYPEALL) {
|
||||
return SatoriElement('at', { type: 'all' });
|
||||
} else if (element.textElement.atType === NTMsgAtType.ATTYPEONE && element.textElement.atUid) {
|
||||
const uin = await this.core.apis.UserApi.getUinByUidV2(element.textElement.atUid);
|
||||
return SatoriElement('at', { id: uin, name: element.textElement.content?.replace('@', '') });
|
||||
}
|
||||
return SatoriElement.text(element.textElement.content);
|
||||
}
|
||||
break;
|
||||
|
||||
case ElementType.PIC:
|
||||
if (element.picElement) {
|
||||
const src = await this.getMediaUrl(element.picElement.sourcePath || '', 'image');
|
||||
return SatoriElement('img', {
|
||||
src,
|
||||
width: element.picElement.picWidth,
|
||||
height: element.picElement.picHeight,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case ElementType.PTT:
|
||||
if (element.pttElement) {
|
||||
const src = await this.getMediaUrl(element.pttElement.filePath || '', 'audio');
|
||||
return SatoriElement('audio', {
|
||||
src,
|
||||
duration: element.pttElement.duration,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case ElementType.VIDEO:
|
||||
if (element.videoElement) {
|
||||
const src = await this.getMediaUrl(element.videoElement.filePath || '', 'video');
|
||||
return SatoriElement('video', { src });
|
||||
}
|
||||
break;
|
||||
|
||||
case ElementType.FILE:
|
||||
if (element.fileElement) {
|
||||
const src = element.fileElement.filePath || '';
|
||||
return SatoriElement('file', {
|
||||
src,
|
||||
title: element.fileElement.fileName,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case ElementType.FACE:
|
||||
if (element.faceElement) {
|
||||
return SatoriElement('face', { id: element.faceElement.faceIndex });
|
||||
}
|
||||
break;
|
||||
|
||||
case ElementType.REPLY:
|
||||
if (element.replyElement) {
|
||||
const msgId = element.replyElement.sourceMsgIdInRecords || element.replyElement.replayMsgId || '';
|
||||
return SatoriElement('quote', { id: msgId });
|
||||
}
|
||||
break;
|
||||
|
||||
case ElementType.MFACE:
|
||||
if (element.marketFaceElement) {
|
||||
return SatoriElement('face', { id: element.marketFaceElement.emojiId || '0' });
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体资源 URL
|
||||
*/
|
||||
private async getMediaUrl (path: string, _type: 'image' | 'audio' | 'video'): Promise<string> {
|
||||
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (path.startsWith('/') || /^[a-zA-Z]:/.test(path)) {
|
||||
return `file://${path.replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private createTextElement (content: string): MessageElement {
|
||||
return {
|
||||
elementType: ElementType.TEXT,
|
||||
elementId: '',
|
||||
textElement: {
|
||||
content,
|
||||
atType: NTMsgAtType.ATTYPEUNKNOWN,
|
||||
atUid: '',
|
||||
atTinyId: '',
|
||||
atNtUid: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async createAtElement (attrs: { id: string; type?: string; name?: string; }): Promise<MessageElement> {
|
||||
const { id, type } = attrs;
|
||||
|
||||
if (type === 'all') {
|
||||
return {
|
||||
elementType: ElementType.TEXT,
|
||||
elementId: '',
|
||||
textElement: {
|
||||
content: '@全体成员',
|
||||
atType: NTMsgAtType.ATTYPEALL,
|
||||
atUid: '',
|
||||
atTinyId: '',
|
||||
atNtUid: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const uid = await this.core.apis.UserApi.getUidByUinV2(id);
|
||||
const userInfo = await this.core.apis.UserApi.getUserDetailInfo(uid, false);
|
||||
|
||||
return {
|
||||
elementType: ElementType.TEXT,
|
||||
elementId: '',
|
||||
textElement: {
|
||||
content: `@${userInfo.nick || id}`,
|
||||
atType: NTMsgAtType.ATTYPEONE,
|
||||
atUid: uid,
|
||||
atTinyId: '',
|
||||
atNtUid: uid,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async createImageElement (attrs: { src: string; width?: string; height?: string; }): Promise<MessageElement> {
|
||||
const src = attrs.src;
|
||||
return {
|
||||
elementType: ElementType.PIC,
|
||||
elementId: '',
|
||||
picElement: {
|
||||
sourcePath: src,
|
||||
picWidth: parseInt(attrs.width || '0', 10),
|
||||
picHeight: parseInt(attrs.height || '0', 10),
|
||||
},
|
||||
} as MessageElement;
|
||||
}
|
||||
|
||||
private async createAudioElement (attrs: { src: string; duration?: string; }): Promise<MessageElement> {
|
||||
const src = attrs.src;
|
||||
return {
|
||||
elementType: ElementType.PTT,
|
||||
elementId: '',
|
||||
pttElement: {
|
||||
filePath: src,
|
||||
duration: parseInt(attrs.duration || '0', 10),
|
||||
},
|
||||
} as MessageElement;
|
||||
}
|
||||
|
||||
private async createVideoElement (attrs: { src: string; }): Promise<MessageElement> {
|
||||
const src = attrs.src;
|
||||
return {
|
||||
elementType: ElementType.VIDEO,
|
||||
elementId: '',
|
||||
videoElement: {
|
||||
filePath: src,
|
||||
videoMd5: '',
|
||||
thumbMd5: '',
|
||||
fileSize: '',
|
||||
},
|
||||
} as MessageElement;
|
||||
}
|
||||
|
||||
private async createFileElement (attrs: { src: string; title?: string; }): Promise<MessageElement> {
|
||||
const src = attrs.src;
|
||||
return {
|
||||
elementType: ElementType.FILE,
|
||||
elementId: '',
|
||||
fileElement: {
|
||||
filePath: src,
|
||||
fileName: attrs.title || '',
|
||||
fileSize: '',
|
||||
},
|
||||
} as MessageElement;
|
||||
}
|
||||
|
||||
private createFaceElement (attrs: { id: string; }): MessageElement {
|
||||
return {
|
||||
elementType: ElementType.FACE,
|
||||
elementId: '',
|
||||
faceElement: {
|
||||
faceIndex: parseInt(attrs.id || '0', 10),
|
||||
faceType: 1,
|
||||
},
|
||||
} as MessageElement;
|
||||
}
|
||||
|
||||
private async createQuoteElement (attrs: { id: string; }): Promise<MessageElement> {
|
||||
const id = attrs.id;
|
||||
return {
|
||||
elementType: ElementType.REPLY,
|
||||
elementId: '',
|
||||
replyElement: {
|
||||
sourceMsgIdInRecords: id,
|
||||
replayMsgSeq: '',
|
||||
replayMsgId: id,
|
||||
senderUin: '',
|
||||
senderUinStr: '',
|
||||
},
|
||||
} as MessageElement;
|
||||
}
|
||||
}
|
||||
65
packages/napcat-satori/config/config.ts
Normal file
65
packages/napcat-satori/config/config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import Ajv from 'ajv';
|
||||
|
||||
// Satori WebSocket 服务器配置
|
||||
const SatoriWebSocketServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'satori-ws-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
port: Type.Number({ default: 5500 }),
|
||||
token: Type.String({ default: '' }),
|
||||
path: Type.String({ default: '/v1/events' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
heartInterval: Type.Number({ default: 10000 }),
|
||||
});
|
||||
|
||||
// Satori WebHook 客户端配置
|
||||
const SatoriWebHookClientConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'satori-webhook-client' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
url: Type.String({ default: 'http://localhost:8080/webhook' }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
// Satori HTTP 服务器配置
|
||||
const SatoriHttpServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'satori-http-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
port: Type.Number({ default: 5501 }),
|
||||
token: Type.String({ default: '' }),
|
||||
path: Type.String({ default: '/v1' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
// Satori 网络配置
|
||||
const SatoriNetworkConfigSchema = Type.Object({
|
||||
websocketServers: Type.Array(SatoriWebSocketServerConfigSchema, { default: [] }),
|
||||
webhookClients: Type.Array(SatoriWebHookClientConfigSchema, { default: [] }),
|
||||
httpServers: Type.Array(SatoriHttpServerConfigSchema, { default: [] }),
|
||||
}, { default: {} });
|
||||
|
||||
// Satori 协议配置
|
||||
export const SatoriConfigSchema = Type.Object({
|
||||
network: SatoriNetworkConfigSchema,
|
||||
platform: Type.String({ default: 'qq' }),
|
||||
selfId: Type.String({ default: '' }),
|
||||
});
|
||||
|
||||
export type SatoriConfig = Static<typeof SatoriConfigSchema>;
|
||||
export type SatoriWebSocketServerConfig = Static<typeof SatoriWebSocketServerConfigSchema>;
|
||||
export type SatoriWebHookClientConfig = Static<typeof SatoriWebHookClientConfigSchema>;
|
||||
export type SatoriHttpServerConfig = Static<typeof SatoriHttpServerConfigSchema>;
|
||||
export type SatoriNetworkAdapterConfig = SatoriWebSocketServerConfig | SatoriWebHookClientConfig | SatoriHttpServerConfig;
|
||||
export type SatoriNetworkConfigKey = keyof SatoriConfig['network'];
|
||||
|
||||
export function loadSatoriConfig (config: Partial<SatoriConfig>): SatoriConfig {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile(SatoriConfigSchema);
|
||||
const valid = validate(config);
|
||||
if (!valid) {
|
||||
throw new Error(ajv.errorsText(validate.errors));
|
||||
}
|
||||
return config as SatoriConfig;
|
||||
}
|
||||
59
packages/napcat-satori/config/index.ts
Normal file
59
packages/napcat-satori/config/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import json5 from 'json5';
|
||||
import { SatoriConfig, SatoriConfigSchema, loadSatoriConfig } from './config';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { Static, TSchema } from '@sinclair/typebox';
|
||||
|
||||
export class SatoriConfigLoader<T extends TSchema = typeof SatoriConfigSchema> {
|
||||
public configData: Static<T>;
|
||||
private configPath: string;
|
||||
private core: NapCatCore;
|
||||
|
||||
constructor (core: NapCatCore, configBasePath: string, _schema: T) {
|
||||
this.core = core;
|
||||
const configFileName = `satori_${core.selfInfo.uin}.json`;
|
||||
this.configPath = `${configBasePath}/${configFileName}`;
|
||||
this.configData = this.loadConfig();
|
||||
}
|
||||
|
||||
private loadConfig (): Static<T> {
|
||||
let configData: Partial<Static<T>> = {};
|
||||
|
||||
if (existsSync(this.configPath)) {
|
||||
try {
|
||||
const fileContent = readFileSync(this.configPath, 'utf-8');
|
||||
configData = json5.parse(fileContent);
|
||||
} catch (e) {
|
||||
this.core.context.logger.logError('[Satori] 配置文件解析失败,使用默认配置', e);
|
||||
}
|
||||
}
|
||||
|
||||
const loadedConfig = loadSatoriConfig(configData as Partial<SatoriConfig>) as Static<T>;
|
||||
this.save(loadedConfig);
|
||||
return loadedConfig;
|
||||
}
|
||||
|
||||
public save (config: Static<T>): void {
|
||||
this.configData = config;
|
||||
const dir = dirname(this.configPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
public reload (): Static<T> {
|
||||
if (existsSync(this.configPath)) {
|
||||
try {
|
||||
const fileContent = readFileSync(this.configPath, 'utf-8');
|
||||
this.configData = loadSatoriConfig(json5.parse(fileContent)) as Static<T>;
|
||||
} catch (e) {
|
||||
this.core.context.logger.logError('[Satori] 配置文件重载失败', e);
|
||||
}
|
||||
}
|
||||
return this.configData;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './config';
|
||||
1
packages/napcat-satori/helper/index.ts
Normal file
1
packages/napcat-satori/helper/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './xml';
|
||||
320
packages/napcat-satori/helper/xml.ts
Normal file
320
packages/napcat-satori/helper/xml.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Satori XML 元素节点
|
||||
*/
|
||||
export interface SatoriXmlNode {
|
||||
type: string;
|
||||
attrs: Record<string, string>;
|
||||
children: (SatoriXmlNode | string)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Satori XML 工具类
|
||||
* 用于解析和构建 Satori 协议的 XML 格式消息
|
||||
* 使用简单的正则解析方式,避免外部依赖
|
||||
*/
|
||||
export class SatoriXmlUtils {
|
||||
/**
|
||||
* 解析 Satori XML 字符串为元素节点数组
|
||||
*/
|
||||
static parse (xmlString: string): SatoriXmlNode[] {
|
||||
const nodes: SatoriXmlNode[] = [];
|
||||
const tagRegex = /<(\w+)([^>]*)(?:\/>|>([\s\S]*?)<\/\1>)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = tagRegex.exec(xmlString)) !== null) {
|
||||
// 处理标签前的文本
|
||||
if (match.index > lastIndex) {
|
||||
const text = xmlString.slice(lastIndex, match.index);
|
||||
if (text.trim()) {
|
||||
nodes.push({
|
||||
type: 'text',
|
||||
attrs: { content: this.unescapeXml(text) },
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [, tagName, rawAttrs = '', innerContent] = match;
|
||||
if (!tagName) continue;
|
||||
|
||||
const attrs = this.parseAttributes(rawAttrs);
|
||||
const children: (SatoriXmlNode | string)[] = [];
|
||||
|
||||
// 如果有内部内容,递归解析
|
||||
if (innerContent) {
|
||||
const innerNodes = this.parse(innerContent);
|
||||
// 如果解析出来只有一个空文本,直接用内容
|
||||
if (innerNodes.length === 1 && innerNodes[0]?.type === 'text') {
|
||||
children.push(innerNodes[0]);
|
||||
} else if (innerNodes.length > 0) {
|
||||
children.push(...innerNodes);
|
||||
} else if (innerContent.trim()) {
|
||||
children.push({
|
||||
type: 'text',
|
||||
attrs: { content: this.unescapeXml(innerContent) },
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
type: tagName.toLowerCase(),
|
||||
attrs,
|
||||
children,
|
||||
});
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// 处理剩余文本
|
||||
if (lastIndex < xmlString.length) {
|
||||
const text = xmlString.slice(lastIndex);
|
||||
if (text.trim()) {
|
||||
nodes.push({
|
||||
type: 'text',
|
||||
attrs: { content: this.unescapeXml(text) },
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析属性字符串
|
||||
*/
|
||||
private static parseAttributes (attrString: string): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
const attrRegex = /(\w+)=["']([^"']*)["']/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = attrRegex.exec(attrString)) !== null) {
|
||||
const key = match[1];
|
||||
const value = match[2];
|
||||
if (key !== undefined && value !== undefined) {
|
||||
attrs[key] = this.unescapeXml(value);
|
||||
}
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将元素节点数组序列化为 XML 字符串
|
||||
*/
|
||||
static serialize (nodes: SatoriXmlNode[]): string {
|
||||
return nodes.map((node) => this.serializeNode(node)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化单个节点
|
||||
*/
|
||||
private static serializeNode (node: SatoriXmlNode): string {
|
||||
if (node.type === 'text') {
|
||||
return this.escapeXml(node.attrs['content'] || '');
|
||||
}
|
||||
|
||||
const attrs = Object.entries(node.attrs)
|
||||
.map(([key, value]) => `${key}="${this.escapeXml(value)}"`)
|
||||
.join(' ');
|
||||
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
if (!hasChildren) {
|
||||
return attrs ? `<${node.type} ${attrs}/>` : `<${node.type}/>`;
|
||||
}
|
||||
|
||||
const openTag = attrs ? `<${node.type} ${attrs}>` : `<${node.type}>`;
|
||||
const childrenStr = node.children
|
||||
.map((child) => (typeof child === 'string' ? this.escapeXml(child) : this.serializeNode(child)))
|
||||
.join('');
|
||||
|
||||
return `${openTag}${childrenStr}</${node.type}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本节点
|
||||
*/
|
||||
static createText (content: string): SatoriXmlNode {
|
||||
return { type: 'text', attrs: { content }, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 at 节点
|
||||
*/
|
||||
static createAt (id?: string, name?: string, type?: string): SatoriXmlNode {
|
||||
const attrs: Record<string, string> = {};
|
||||
if (id) attrs['id'] = id;
|
||||
if (name) attrs['name'] = name;
|
||||
if (type) attrs['type'] = type;
|
||||
return { type: 'at', attrs, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建图片节点
|
||||
*/
|
||||
static createImg (src: string, attrs?: { width?: number; height?: number; title?: string; }): SatoriXmlNode {
|
||||
const nodeAttrs: Record<string, string> = { src };
|
||||
if (attrs?.width) nodeAttrs['width'] = String(attrs.width);
|
||||
if (attrs?.height) nodeAttrs['height'] = String(attrs.height);
|
||||
if (attrs?.title) nodeAttrs['title'] = attrs.title;
|
||||
return { type: 'img', attrs: nodeAttrs, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建音频节点
|
||||
*/
|
||||
static createAudio (src: string, attrs?: { duration?: number; title?: string; }): SatoriXmlNode {
|
||||
const nodeAttrs: Record<string, string> = { src };
|
||||
if (attrs?.duration) nodeAttrs['duration'] = String(attrs.duration);
|
||||
if (attrs?.title) nodeAttrs['title'] = attrs.title;
|
||||
return { type: 'audio', attrs: nodeAttrs, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建视频节点
|
||||
*/
|
||||
static createVideo (src: string, attrs?: { width?: number; height?: number; duration?: number; title?: string; }): SatoriXmlNode {
|
||||
const nodeAttrs: Record<string, string> = { src };
|
||||
if (attrs?.width) nodeAttrs['width'] = String(attrs.width);
|
||||
if (attrs?.height) nodeAttrs['height'] = String(attrs.height);
|
||||
if (attrs?.duration) nodeAttrs['duration'] = String(attrs.duration);
|
||||
if (attrs?.title) nodeAttrs['title'] = attrs.title;
|
||||
return { type: 'video', attrs: nodeAttrs, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件节点
|
||||
*/
|
||||
static createFile (src: string, attrs?: { title?: string; }): SatoriXmlNode {
|
||||
const nodeAttrs: Record<string, string> = { src };
|
||||
if (attrs?.title) nodeAttrs['title'] = attrs.title;
|
||||
return { type: 'file', attrs: nodeAttrs, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建表情节点
|
||||
*/
|
||||
static createFace (id: string | number): SatoriXmlNode {
|
||||
return { type: 'face', attrs: { id: String(id) }, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建引用节点
|
||||
*/
|
||||
static createQuote (id: string): SatoriXmlNode {
|
||||
return { type: 'quote', attrs: { id }, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建消息节点(用于转发消息)
|
||||
*/
|
||||
static createMessage (attrs?: { id?: string; forward?: boolean; }, children?: SatoriXmlNode[]): SatoriXmlNode {
|
||||
const nodeAttrs: Record<string, string> = {};
|
||||
if (attrs?.id) nodeAttrs['id'] = attrs.id;
|
||||
if (attrs?.forward !== undefined) nodeAttrs['forward'] = String(attrs.forward);
|
||||
return { type: 'message', attrs: nodeAttrs, children: children || [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建作者节点
|
||||
*/
|
||||
static createAuthor (attrs: { id?: string; name?: string; avatar?: string; }): SatoriXmlNode {
|
||||
const nodeAttrs: Record<string, string> = {};
|
||||
if (attrs.id) nodeAttrs['id'] = attrs.id;
|
||||
if (attrs.name) nodeAttrs['name'] = attrs.name;
|
||||
if (attrs.avatar) nodeAttrs['avatar'] = attrs.avatar;
|
||||
return { type: 'author', attrs: nodeAttrs, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建换行节点
|
||||
*/
|
||||
static createBr (): SatoriXmlNode {
|
||||
return { type: 'br', attrs: {}, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建按钮节点
|
||||
*/
|
||||
static createButton (attrs: { id?: string; type?: string; href?: string; text?: string; }): SatoriXmlNode {
|
||||
const nodeAttrs: Record<string, string> = {};
|
||||
if (attrs.id) nodeAttrs['id'] = attrs.id;
|
||||
if (attrs.type) nodeAttrs['type'] = attrs.type;
|
||||
if (attrs.href) nodeAttrs['href'] = attrs.href;
|
||||
if (attrs.text) nodeAttrs['text'] = attrs.text;
|
||||
return { type: 'button', attrs: nodeAttrs, children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建样式标签节点
|
||||
*/
|
||||
static createStyled (type: 'b' | 'i' | 'u' | 's' | 'code' | 'sup' | 'sub' | 'spl', children: SatoriXmlNode[]): SatoriXmlNode {
|
||||
return { type, attrs: {}, children };
|
||||
}
|
||||
|
||||
/**
|
||||
* XML 转义
|
||||
*/
|
||||
static escapeXml (str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* XML 反转义
|
||||
*/
|
||||
static unescapeXml (str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍历所有节点
|
||||
*/
|
||||
static walk (nodes: SatoriXmlNode[], callback: (node: SatoriXmlNode) => void): void {
|
||||
for (const node of nodes) {
|
||||
callback(node);
|
||||
if (node.children) {
|
||||
const childNodes = node.children.filter((c): c is SatoriXmlNode => typeof c !== 'string');
|
||||
this.walk(childNodes, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找指定类型的节点
|
||||
*/
|
||||
static find (nodes: SatoriXmlNode[], type: string): SatoriXmlNode[] {
|
||||
const result: SatoriXmlNode[] = [];
|
||||
this.walk(nodes, (node) => {
|
||||
if (node.type === type) {
|
||||
result.push(node);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取纯文本内容
|
||||
*/
|
||||
static extractText (nodes: SatoriXmlNode[]): string {
|
||||
const texts: string[] = [];
|
||||
this.walk(nodes, (node) => {
|
||||
if (node.type === 'text' && node.attrs['content']) {
|
||||
texts.push(node.attrs['content']);
|
||||
}
|
||||
});
|
||||
return texts.join('');
|
||||
}
|
||||
}
|
||||
|
||||
export { SatoriXmlUtils as XmlUtils };
|
||||
309
packages/napcat-satori/index.ts
Normal file
309
packages/napcat-satori/index.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
ChatType,
|
||||
InstanceContext,
|
||||
NapCatCore,
|
||||
NodeIKernelBuddyListener,
|
||||
NodeIKernelGroupListener,
|
||||
NodeIKernelMsgListener,
|
||||
RawMessage,
|
||||
SendStatusType,
|
||||
NTMsgType,
|
||||
BuddyReqType,
|
||||
GroupNotifyMsgStatus,
|
||||
GroupNotifyMsgType,
|
||||
} from 'napcat-core';
|
||||
import { SatoriConfigLoader, SatoriConfig, SatoriConfigSchema, SatoriNetworkAdapterConfig } from './config';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { createSatoriApis, SatoriApiList } from './api';
|
||||
import { createSatoriActionMap, SatoriActionMap } from './action';
|
||||
import {
|
||||
SatoriNetworkManager,
|
||||
SatoriWebSocketServerAdapter,
|
||||
SatoriHttpServerAdapter,
|
||||
SatoriWebHookClientAdapter,
|
||||
SatoriNetworkReloadType,
|
||||
ISatoriNetworkAdapter,
|
||||
} from './network';
|
||||
import { SatoriLoginStatus } from './types';
|
||||
// import { MessageUnique } from 'napcat-common/src/message-unique';
|
||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||||
|
||||
export class NapCatSatoriAdapter {
|
||||
readonly core: NapCatCore;
|
||||
readonly context: InstanceContext;
|
||||
|
||||
configLoader: SatoriConfigLoader;
|
||||
public apis: SatoriApiList;
|
||||
networkManager: SatoriNetworkManager;
|
||||
actions: SatoriActionMap;
|
||||
private readonly bootTime = Date.now() / 1000;
|
||||
|
||||
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||
this.core = core;
|
||||
this.context = context;
|
||||
this.configLoader = new SatoriConfigLoader(core, pathWrapper.configPath, SatoriConfigSchema);
|
||||
this.apis = createSatoriApis(this, core);
|
||||
this.actions = createSatoriActionMap(this, core);
|
||||
this.networkManager = new SatoriNetworkManager();
|
||||
}
|
||||
|
||||
async createSatoriLog (config: SatoriConfig): Promise<string> {
|
||||
let log = '[network] 配置加载\n';
|
||||
for (const key of config.network.websocketServers) {
|
||||
log += `WebSocket服务: ${key.host}:${key.port}${key.path}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
for (const key of config.network.httpServers) {
|
||||
log += `HTTP服务: ${key.host}:${key.port}${key.path}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
for (const key of config.network.webhookClients) {
|
||||
log += `WebHook上报: ${key.url}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
async InitSatori (): Promise<void> {
|
||||
const config = this.configLoader.configData;
|
||||
const serviceInfo = await this.createSatoriLog(config);
|
||||
this.context.logger.log(`[Notice] [Satori] ${serviceInfo}`);
|
||||
|
||||
// 注册网络适配器
|
||||
for (const wsConfig of config.network.websocketServers) {
|
||||
if (wsConfig.enable) {
|
||||
this.networkManager.registerAdapter(
|
||||
new SatoriWebSocketServerAdapter(wsConfig.name, wsConfig, this.core, this, this.actions)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const httpConfig of config.network.httpServers) {
|
||||
if (httpConfig.enable) {
|
||||
this.networkManager.registerAdapter(
|
||||
new SatoriHttpServerAdapter(httpConfig.name, httpConfig, this.core, this, this.actions)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const webhookConfig of config.network.webhookClients) {
|
||||
if (webhookConfig.enable) {
|
||||
this.networkManager.registerAdapter(
|
||||
new SatoriWebHookClientAdapter(webhookConfig.name, webhookConfig, this.core, this, this.actions)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.networkManager.openAllAdapters();
|
||||
|
||||
// 初始化监听器
|
||||
this.initMsgListener();
|
||||
this.initBuddyListener();
|
||||
this.initGroupListener();
|
||||
|
||||
// 发送登录成功事件
|
||||
const loginEvent = this.apis.EventApi.createLoginUpdatedEvent(SatoriLoginStatus.ONLINE);
|
||||
await this.networkManager.emitEvent(loginEvent);
|
||||
|
||||
// 注册 Satori 配置热重载回调
|
||||
WebUiDataRuntime.setOnSatoriConfigChanged(async (newConfig) => {
|
||||
const prev = this.configLoader.configData;
|
||||
this.configLoader.save(newConfig);
|
||||
await this.reloadNetwork(prev, newConfig);
|
||||
});
|
||||
}
|
||||
|
||||
async reloadNetwork (prev: SatoriConfig, now: SatoriConfig): Promise<void> {
|
||||
const prevLog = await this.createSatoriLog(prev);
|
||||
const newLog = await this.createSatoriLog(now);
|
||||
this.context.logger.log(`[Notice] [Satori] 配置变更前:\n${prevLog}`);
|
||||
this.context.logger.log(`[Notice] [Satori] 配置变更后:\n${newLog}`);
|
||||
|
||||
await this.handleConfigChange(prev.network.websocketServers, now.network.websocketServers, SatoriWebSocketServerAdapter);
|
||||
await this.handleConfigChange(prev.network.httpServers, now.network.httpServers, SatoriHttpServerAdapter);
|
||||
await this.handleConfigChange(prev.network.webhookClients, now.network.webhookClients, SatoriWebHookClientAdapter);
|
||||
}
|
||||
|
||||
private async handleConfigChange<CT extends SatoriNetworkAdapterConfig> (
|
||||
prevConfig: SatoriNetworkAdapterConfig[],
|
||||
nowConfig: SatoriNetworkAdapterConfig[],
|
||||
adapterClass: new (
|
||||
...args: ConstructorParameters<typeof ISatoriNetworkAdapter<CT>>
|
||||
) => ISatoriNetworkAdapter<CT>
|
||||
): Promise<void> {
|
||||
// 比较旧的在新的找不到的回收
|
||||
for (const adapterConfig of prevConfig) {
|
||||
const existingAdapter = nowConfig.find((e) => e.name === adapterConfig.name);
|
||||
if (!existingAdapter) {
|
||||
const adapter = this.networkManager.findSomeAdapter(adapterConfig.name);
|
||||
if (adapter) {
|
||||
await this.networkManager.closeSomeAdaterWhenOpen([adapter]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通知新配置重载
|
||||
for (const adapterConfig of nowConfig) {
|
||||
const existingAdapter = this.networkManager.findSomeAdapter(adapterConfig.name);
|
||||
if (existingAdapter) {
|
||||
const networkChange = await existingAdapter.reload(adapterConfig);
|
||||
if (networkChange === SatoriNetworkReloadType.NetWorkClose) {
|
||||
await this.networkManager.closeSomeAdaterWhenOpen([existingAdapter]);
|
||||
}
|
||||
} else if (adapterConfig.enable) {
|
||||
const newAdapter = new adapterClass(adapterConfig.name, adapterConfig as CT, this.core, this, this.actions);
|
||||
await this.networkManager.registerAdapterAndOpen(newAdapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private initMsgListener (): void {
|
||||
const msgListener = new NodeIKernelMsgListener();
|
||||
|
||||
msgListener.onRecvMsg = async (msgs) => {
|
||||
if (!this.networkManager.hasActiveAdapters()) return;
|
||||
|
||||
|
||||
for (const msg of msgs) {
|
||||
if (this.bootTime > parseInt(msg.msgTime)) {
|
||||
continue;
|
||||
}
|
||||
// this.context.logger.log(`[Satori] Debug: Processing message ${msg.msgId}`);
|
||||
await this.handleMessage(msg);
|
||||
}
|
||||
};
|
||||
|
||||
msgListener.onAddSendMsg = async (msg) => {
|
||||
try {
|
||||
if (msg.sendStatus === SendStatusType.KSEND_STATUS_SENDING) {
|
||||
const [updatemsgs] = await this.core.eventWrapper.registerListen(
|
||||
'NodeIKernelMsgListener/onMsgInfoListUpdate',
|
||||
(msgList: RawMessage[]) => {
|
||||
const report = msgList.find(
|
||||
(e) =>
|
||||
e.senderUin === this.core.selfInfo.uin &&
|
||||
e.sendStatus !== SendStatusType.KSEND_STATUS_SENDING &&
|
||||
e.msgId === msg.msgId
|
||||
);
|
||||
return !!report;
|
||||
},
|
||||
1,
|
||||
10 * 60 * 1000
|
||||
);
|
||||
const updatemsg = updatemsgs.find((e) => e.msgId === msg.msgId);
|
||||
if (updatemsg?.sendStatus === SendStatusType.KSEND_STATUS_SUCCESS) {
|
||||
await this.handleMessage(updatemsg);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError('[Satori] 处理发送消息失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
msgListener.onMsgRecall = async (chatType: ChatType, uid: string, msgSeq: string) => {
|
||||
try {
|
||||
const peer = { chatType, peerUid: uid, guildId: '' };
|
||||
const msgs = await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeq(peer, msgSeq);
|
||||
const msg = msgs.msgList.find((e) => e.msgType === NTMsgType.KMSGTYPEGRAYTIPS);
|
||||
|
||||
if (msg) {
|
||||
const channelId = chatType === ChatType.KCHATTYPEC2C
|
||||
? `private:${msg.senderUin}`
|
||||
: `group:${msg.peerUin}`;
|
||||
const event = this.apis.EventApi.createMessageDeletedEvent(
|
||||
channelId,
|
||||
msg.msgId,
|
||||
msg.senderUin
|
||||
);
|
||||
await this.networkManager.emitEvent(event);
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError('[Satori] 处理消息撤回失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.context.session.getMsgService().addKernelMsgListener(
|
||||
proxiedListenerOf(msgListener, this.context.logger)
|
||||
);
|
||||
}
|
||||
|
||||
private initBuddyListener (): void {
|
||||
const buddyListener = new NodeIKernelBuddyListener();
|
||||
|
||||
buddyListener.onBuddyReqChange = async (reqs) => {
|
||||
this.core.apis.FriendApi.clearBuddyReqUnreadCnt();
|
||||
|
||||
for (let i = 0; i < reqs.unreadNums; i++) {
|
||||
const req = reqs.buddyReqs[i];
|
||||
if (!req) continue;
|
||||
if (
|
||||
!!req.isInitiator ||
|
||||
(req.isDecide && req.reqType !== BuddyReqType.KMEINITIATORWAITPEERCONFIRM) ||
|
||||
!req.isUnread
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = this.apis.EventApi.createFriendRequestEvent(req);
|
||||
await this.networkManager.emitEvent(event);
|
||||
} catch (error) {
|
||||
this.context.logger.logError('[Satori] 处理好友请求失败', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.context.session.getBuddyService().addKernelBuddyListener(
|
||||
proxiedListenerOf(buddyListener, this.context.logger)
|
||||
);
|
||||
}
|
||||
|
||||
private initGroupListener (): void {
|
||||
const groupListener = new NodeIKernelGroupListener();
|
||||
|
||||
groupListener.onGroupNotifiesUpdated = async (_, notifies) => {
|
||||
await this.core.apis.GroupApi.clearGroupNotifiesUnreadCount(false);
|
||||
if (!notifies[0]?.type) return;
|
||||
|
||||
for (const notify of notifies) {
|
||||
const notifyTime = parseInt(notify.seq) / 1000 / 1000;
|
||||
if (notifyTime < this.bootTime) continue;
|
||||
|
||||
try {
|
||||
if (
|
||||
[GroupNotifyMsgType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS].includes(notify.type) &&
|
||||
notify.status === GroupNotifyMsgStatus.KUNHANDLE
|
||||
) {
|
||||
const event = this.apis.EventApi.createGuildMemberRequestEvent(notify);
|
||||
await this.networkManager.emitEvent(event);
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError('[Satori] 处理群通知失败', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.context.session.getGroupService().addKernelGroupListener(
|
||||
proxiedListenerOf(groupListener, this.context.logger)
|
||||
);
|
||||
}
|
||||
|
||||
private async handleMessage (message: RawMessage): Promise<void> {
|
||||
if (message.msgType === NTMsgType.KMSGTYPENULL) return;
|
||||
|
||||
try {
|
||||
const event = await this.apis.EventApi.createMessageEvent(message);
|
||||
if (event) {
|
||||
this.context.logger.logDebug(`[Satori] Emitting event ${event.type}`);
|
||||
await this.networkManager.emitEvent(event);
|
||||
} else {
|
||||
this.context.logger.logDebug(`[Satori] Event creation returned null for msg ${message.msgId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError('[Satori] 处理消息失败', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export * from './types';
|
||||
export * from './config';
|
||||
export * from './action';
|
||||
export * from './helper';
|
||||
50
packages/napcat-satori/network/adapter.ts
Normal file
50
packages/napcat-satori/network/adapter.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { SatoriNetworkAdapterConfig } from '../config/config';
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import { SatoriActionMap } from '../action';
|
||||
import { SatoriEvent } from '../types';
|
||||
|
||||
export enum SatoriNetworkReloadType {
|
||||
Normal = 0,
|
||||
NetWorkClose = 1,
|
||||
}
|
||||
|
||||
export type SatoriEmitEventContent = SatoriEvent;
|
||||
|
||||
export abstract class ISatoriNetworkAdapter<CT extends SatoriNetworkAdapterConfig> {
|
||||
name: string;
|
||||
isEnable: boolean = false;
|
||||
config: CT;
|
||||
readonly logger: LogWrapper;
|
||||
readonly core: NapCatCore;
|
||||
readonly satoriContext: NapCatSatoriAdapter;
|
||||
readonly actions: SatoriActionMap;
|
||||
|
||||
constructor (
|
||||
name: string,
|
||||
config: CT,
|
||||
core: NapCatCore,
|
||||
satoriContext: NapCatSatoriAdapter,
|
||||
actions: SatoriActionMap
|
||||
) {
|
||||
this.name = name;
|
||||
this.config = structuredClone(config);
|
||||
this.core = core;
|
||||
this.satoriContext = satoriContext;
|
||||
this.actions = actions;
|
||||
this.logger = core.context.logger;
|
||||
}
|
||||
|
||||
abstract onEvent<T extends SatoriEmitEventContent> (event: T): Promise<void>;
|
||||
|
||||
abstract open (): void | Promise<void>;
|
||||
|
||||
abstract close (): void | Promise<void>;
|
||||
|
||||
abstract reload (config: unknown): SatoriNetworkReloadType | Promise<SatoriNetworkReloadType>;
|
||||
|
||||
get isActive (): boolean {
|
||||
return this.isEnable;
|
||||
}
|
||||
}
|
||||
176
packages/napcat-satori/network/http-server.ts
Normal file
176
packages/napcat-satori/network/http-server.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import express, { Express, Request, Response, NextFunction } from 'express';
|
||||
import { createServer, Server } from 'http';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import { SatoriActionMap, SatoriResponseHelper } from '../action';
|
||||
import { SatoriHttpServerConfig } from '../config/config';
|
||||
import {
|
||||
ISatoriNetworkAdapter,
|
||||
SatoriEmitEventContent,
|
||||
SatoriNetworkReloadType,
|
||||
} from './adapter';
|
||||
import { SatoriLoginStatus } from '../types';
|
||||
|
||||
export class SatoriHttpServerAdapter extends ISatoriNetworkAdapter<SatoriHttpServerConfig> {
|
||||
private app: Express | null = null;
|
||||
private server: Server | null = null;
|
||||
|
||||
constructor (
|
||||
name: string,
|
||||
config: SatoriHttpServerConfig,
|
||||
core: NapCatCore,
|
||||
satoriContext: NapCatSatoriAdapter,
|
||||
actions: SatoriActionMap
|
||||
) {
|
||||
super(name, config, core, satoriContext, actions);
|
||||
}
|
||||
|
||||
async open (): Promise<void> {
|
||||
if (this.isEnable) return;
|
||||
|
||||
try {
|
||||
this.app = express();
|
||||
this.app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
// Token 验证中间件
|
||||
this.app.use(this.config.path || '/v1', (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (this.config.token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : authHeader;
|
||||
if (token !== this.config.token) {
|
||||
res.status(401).json(SatoriResponseHelper.error(401, 'Unauthorized'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 注册 API 路由
|
||||
this.registerRoutes();
|
||||
|
||||
this.server = createServer(this.app);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.listen(this.config.port, this.config.host, () => {
|
||||
this.logger.log(`[Satori] HTTP服务器已启动: http://${this.config.host}:${this.config.port}${this.config.path}`);
|
||||
resolve();
|
||||
});
|
||||
this.server!.on('error', reject);
|
||||
});
|
||||
|
||||
this.isEnable = true;
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Satori] HTTP服务器启动失败: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
if (!this.isEnable) return;
|
||||
|
||||
if (this.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.server!.close(() => resolve());
|
||||
});
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
this.app = null;
|
||||
this.isEnable = false;
|
||||
this.logger.log(`[Satori] HTTP服务器已关闭`);
|
||||
}
|
||||
|
||||
async reload (config: SatoriHttpServerConfig): Promise<SatoriNetworkReloadType> {
|
||||
const needRestart =
|
||||
this.config.host !== config.host ||
|
||||
this.config.port !== config.port ||
|
||||
this.config.path !== config.path;
|
||||
|
||||
this.config = structuredClone(config);
|
||||
|
||||
if (!config.enable) {
|
||||
return SatoriNetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
if (needRestart && this.isEnable) {
|
||||
await this.close();
|
||||
await this.open();
|
||||
}
|
||||
|
||||
return SatoriNetworkReloadType.Normal;
|
||||
}
|
||||
|
||||
async onEvent<T extends SatoriEmitEventContent> (_event: T): Promise<void> {
|
||||
// HTTP 服务器不主动推送事件
|
||||
}
|
||||
|
||||
private registerRoutes (): void {
|
||||
if (!this.app) return;
|
||||
|
||||
const basePath = this.config.path || '/v1';
|
||||
const router = express.Router();
|
||||
|
||||
// 通用 action 处理器
|
||||
const handleAction = async (actionName: string, req: Request, res: Response): Promise<void> => {
|
||||
const action = this.actions.get(actionName);
|
||||
if (!action) {
|
||||
res.status(404).json(SatoriResponseHelper.error(404, `未知的 action: ${actionName}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await action.handle(req.body || {});
|
||||
res.json(SatoriResponseHelper.success(result));
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Satori] Action ${actionName} 执行失败:`, error);
|
||||
res.status(500).json(SatoriResponseHelper.error(500, `${error}`));
|
||||
}
|
||||
};
|
||||
|
||||
// 登录信息(特殊处理,可以使用缓存)
|
||||
router.post('/login.get', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = {
|
||||
user: {
|
||||
id: this.core.selfInfo.uin,
|
||||
name: this.core.selfInfo.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.core.selfInfo.uin}&s=640`,
|
||||
},
|
||||
self_id: this.core.selfInfo.uin,
|
||||
platform: this.satoriContext.configLoader.configData.platform,
|
||||
status: SatoriLoginStatus.ONLINE,
|
||||
};
|
||||
res.json(SatoriResponseHelper.success(result));
|
||||
} catch (error) {
|
||||
res.status(500).json(SatoriResponseHelper.error(500, `获取登录信息失败: ${error}`));
|
||||
}
|
||||
});
|
||||
|
||||
// 动态注册所有 action 路由
|
||||
for (const [actionName] of this.actions) {
|
||||
const routePath = `/${actionName.replace(/\./g, '/')}`;
|
||||
router.post(routePath, (req, res) => handleAction(actionName, req, res));
|
||||
|
||||
// 同时支持点号格式的路由
|
||||
router.post(`/${actionName}`, (req, res) => handleAction(actionName, req, res));
|
||||
}
|
||||
|
||||
// 通用 action 入口
|
||||
router.post('/:action(*)', async (req: Request, res: Response) => {
|
||||
const actionParam = req.params['action'];
|
||||
if (!actionParam) {
|
||||
res.status(400).json(SatoriResponseHelper.error(400, '缺少 action 参数'));
|
||||
return;
|
||||
}
|
||||
const actionName = actionParam.replace(/\//g, '.');
|
||||
await handleAction(actionName, req, res);
|
||||
});
|
||||
|
||||
this.app.use(basePath, router);
|
||||
|
||||
// Debug 日志
|
||||
if (this.config.debug) {
|
||||
this.logger.logDebug(`[Satori] 已注册 ${this.actions.size} 个 action 路由`);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
packages/napcat-satori/network/index.ts
Normal file
72
packages/napcat-satori/network/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ISatoriNetworkAdapter, SatoriEmitEventContent } from './adapter';
|
||||
import { SatoriNetworkAdapterConfig } from '../config/config';
|
||||
|
||||
export class SatoriNetworkManager {
|
||||
adapters: Map<string, ISatoriNetworkAdapter<SatoriNetworkAdapterConfig>> = new Map();
|
||||
|
||||
async registerAdapter<T extends SatoriNetworkAdapterConfig> (
|
||||
adapter: ISatoriNetworkAdapter<T>
|
||||
): Promise<void> {
|
||||
this.adapters.set(adapter.name, adapter as ISatoriNetworkAdapter<SatoriNetworkAdapterConfig>);
|
||||
}
|
||||
|
||||
async registerAdapterAndOpen<T extends SatoriNetworkAdapterConfig> (
|
||||
adapter: ISatoriNetworkAdapter<T>
|
||||
): Promise<void> {
|
||||
await this.registerAdapter(adapter);
|
||||
await adapter.open();
|
||||
}
|
||||
|
||||
findSomeAdapter (name: string): ISatoriNetworkAdapter<SatoriNetworkAdapterConfig> | undefined {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
async openAllAdapters (): Promise<void> {
|
||||
const openPromises = Array.from(this.adapters.values()).map((adapter) =>
|
||||
Promise.resolve(adapter.open()).catch((e) => {
|
||||
adapter.logger.logError(`[Satori] 适配器 ${adapter.name} 启动失败: ${e}`);
|
||||
})
|
||||
);
|
||||
await Promise.all(openPromises);
|
||||
}
|
||||
|
||||
async closeAllAdapters (): Promise<void> {
|
||||
const closePromises = Array.from(this.adapters.values()).map((adapter) =>
|
||||
Promise.resolve(adapter.close()).catch((e) => {
|
||||
adapter.logger.logError(`[Satori] 适配器 ${adapter.name} 关闭失败: ${e}`);
|
||||
})
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
}
|
||||
|
||||
async closeSomeAdaterWhenOpen (
|
||||
adapters: ISatoriNetworkAdapter<SatoriNetworkAdapterConfig>[]
|
||||
): Promise<void> {
|
||||
for (const adapter of adapters) {
|
||||
if (adapter.isActive) {
|
||||
await adapter.close();
|
||||
}
|
||||
this.adapters.delete(adapter.name);
|
||||
}
|
||||
}
|
||||
|
||||
async emitEvent<T extends SatoriEmitEventContent> (event: T): Promise<void> {
|
||||
const emitPromises = Array.from(this.adapters.values())
|
||||
.filter((adapter) => adapter.isActive)
|
||||
.map((adapter) =>
|
||||
adapter.onEvent(event).catch((e) => {
|
||||
adapter.logger.logError(`[Satori] 适配器 ${adapter.name} 事件发送失败: ${e}`);
|
||||
})
|
||||
);
|
||||
await Promise.all(emitPromises);
|
||||
}
|
||||
|
||||
hasActiveAdapters (): boolean {
|
||||
return Array.from(this.adapters.values()).some((adapter) => adapter.isActive);
|
||||
}
|
||||
}
|
||||
|
||||
export { ISatoriNetworkAdapter, SatoriEmitEventContent, SatoriNetworkReloadType } from './adapter';
|
||||
export { SatoriWebSocketServerAdapter } from './websocket-server';
|
||||
export { SatoriHttpServerAdapter } from './http-server';
|
||||
export { SatoriWebHookClientAdapter } from './webhook-client';
|
||||
95
packages/napcat-satori/network/webhook-client.ts
Normal file
95
packages/napcat-satori/network/webhook-client.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import { SatoriActionMap } from '../action';
|
||||
import { SatoriWebHookClientConfig } from '../config/config';
|
||||
import {
|
||||
ISatoriNetworkAdapter,
|
||||
SatoriEmitEventContent,
|
||||
SatoriNetworkReloadType,
|
||||
} from './adapter';
|
||||
|
||||
export class SatoriWebHookClientAdapter extends ISatoriNetworkAdapter<SatoriWebHookClientConfig> {
|
||||
private eventQueue: SatoriEmitEventContent[] = [];
|
||||
private isSending: boolean = false;
|
||||
|
||||
constructor (
|
||||
name: string,
|
||||
config: SatoriWebHookClientConfig,
|
||||
core: NapCatCore,
|
||||
satoriContext: NapCatSatoriAdapter,
|
||||
actions: SatoriActionMap
|
||||
) {
|
||||
super(name, config, core, satoriContext, actions);
|
||||
}
|
||||
|
||||
async open (): Promise<void> {
|
||||
if (this.isEnable) return;
|
||||
this.isEnable = true;
|
||||
this.logger.log(`[Satori] WebHook客户端已启动: ${this.config.url}`);
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
if (!this.isEnable) return;
|
||||
this.isEnable = false;
|
||||
this.eventQueue = [];
|
||||
this.logger.log(`[Satori] WebHook客户端已关闭`);
|
||||
}
|
||||
|
||||
async reload (config: SatoriWebHookClientConfig): Promise<SatoriNetworkReloadType> {
|
||||
this.config = structuredClone(config);
|
||||
|
||||
if (!config.enable) {
|
||||
return SatoriNetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
return SatoriNetworkReloadType.Normal;
|
||||
}
|
||||
|
||||
async onEvent<T extends SatoriEmitEventContent> (event: T): Promise<void> {
|
||||
if (!this.isEnable) return;
|
||||
|
||||
this.eventQueue.push(event);
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
private async processQueue (): Promise<void> {
|
||||
if (this.isSending || this.eventQueue.length === 0) return;
|
||||
|
||||
this.isSending = true;
|
||||
|
||||
while (this.eventQueue.length > 0) {
|
||||
const event = this.eventQueue.shift();
|
||||
if (event) {
|
||||
await this.sendEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
this.isSending = false;
|
||||
}
|
||||
|
||||
private async sendEvent (event: SatoriEmitEventContent): Promise<void> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.config.token) {
|
||||
headers['Authorization'] = `Bearer ${this.config.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.logError(`[Satori] WebHook发送失败: ${response.status} ${response.statusText}`);
|
||||
} else if (this.config.debug) {
|
||||
this.logger.logDebug(`[Satori] WebHook发送成功: ${event.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Satori] WebHook发送错误: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
284
packages/napcat-satori/network/websocket-server.ts
Normal file
284
packages/napcat-satori/network/websocket-server.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createServer, Server, IncomingMessage } from 'http';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatSatoriAdapter } from '../index';
|
||||
import { SatoriActionMap } from '../action';
|
||||
import { SatoriWebSocketServerConfig } from '../config/config';
|
||||
import {
|
||||
ISatoriNetworkAdapter,
|
||||
SatoriEmitEventContent,
|
||||
SatoriNetworkReloadType,
|
||||
} from './adapter';
|
||||
import {
|
||||
SatoriOpcode,
|
||||
SatoriSignal,
|
||||
SatoriIdentifyBody,
|
||||
SatoriReadyBody,
|
||||
SatoriLoginStatus,
|
||||
} from '../types';
|
||||
|
||||
interface ClientInfo {
|
||||
ws: WebSocket;
|
||||
identified: boolean;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export class SatoriWebSocketServerAdapter extends ISatoriNetworkAdapter<SatoriWebSocketServerConfig> {
|
||||
private server: Server | null = null;
|
||||
private wss: WebSocketServer | null = null;
|
||||
private clients: Map<WebSocket, ClientInfo> = new Map();
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private eventSequence: number = 0;
|
||||
|
||||
constructor (
|
||||
name: string,
|
||||
config: SatoriWebSocketServerConfig,
|
||||
core: NapCatCore,
|
||||
satoriContext: NapCatSatoriAdapter,
|
||||
actions: SatoriActionMap
|
||||
) {
|
||||
super(name, config, core, satoriContext, actions);
|
||||
}
|
||||
|
||||
async open (): Promise<void> {
|
||||
if (this.isEnable) return;
|
||||
|
||||
try {
|
||||
this.server = createServer();
|
||||
this.wss = new WebSocketServer({
|
||||
server: this.server,
|
||||
path: this.config.path || '/v1/events',
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
||||
this.handleConnection(ws, req);
|
||||
});
|
||||
|
||||
this.wss.on('error', (error) => {
|
||||
this.logger.logError(`[Satori] WebSocket服务器错误: ${error.message}`);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.listen(this.config.port, this.config.host, () => {
|
||||
this.logger.log(`[Satori] WebSocket服务器已启动: ws://${this.config.host}:${this.config.port}${this.config.path}`);
|
||||
resolve();
|
||||
});
|
||||
this.server!.on('error', reject);
|
||||
});
|
||||
|
||||
this.startHeartbeat();
|
||||
this.isEnable = true;
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Satori] WebSocket服务器启动失败: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
if (!this.isEnable) return;
|
||||
|
||||
this.stopHeartbeat();
|
||||
|
||||
for (const [ws] of this.clients) {
|
||||
ws.close(1000, 'Server shutting down');
|
||||
}
|
||||
this.clients.clear();
|
||||
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
if (this.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.server!.close(() => resolve());
|
||||
});
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
this.isEnable = false;
|
||||
this.logger.log(`[Satori] WebSocket服务器已关闭`);
|
||||
}
|
||||
|
||||
async reload (config: SatoriWebSocketServerConfig): Promise<SatoriNetworkReloadType> {
|
||||
const needRestart =
|
||||
this.config.host !== config.host ||
|
||||
this.config.port !== config.port ||
|
||||
this.config.path !== config.path;
|
||||
|
||||
this.config = structuredClone(config);
|
||||
|
||||
if (!config.enable) {
|
||||
return SatoriNetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
if (needRestart && this.isEnable) {
|
||||
await this.close();
|
||||
await this.open();
|
||||
}
|
||||
|
||||
return SatoriNetworkReloadType.Normal;
|
||||
}
|
||||
|
||||
|
||||
async onEvent<T extends SatoriEmitEventContent> (event: T): Promise<void> {
|
||||
if (!this.isEnable) return;
|
||||
|
||||
this.eventSequence++;
|
||||
const signal: SatoriSignal<T> = {
|
||||
op: SatoriOpcode.EVENT,
|
||||
body: {
|
||||
...event,
|
||||
id: this.eventSequence,
|
||||
} as T,
|
||||
};
|
||||
|
||||
const message = JSON.stringify(signal);
|
||||
let sentCount = 0;
|
||||
|
||||
this.logger.logDebug(`[Satori] onEvent triggered. Current clients: ${this.clients.size}`);
|
||||
|
||||
for (const [ws, clientInfo] of this.clients) {
|
||||
const ip = (ws as any)._socket?.remoteAddress || 'unknown';
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (clientInfo.identified) {
|
||||
ws.send(message);
|
||||
clientInfo.sequence = this.eventSequence;
|
||||
sentCount++;
|
||||
if (this.config.debug) {
|
||||
this.logger.logDebug(`[Satori] 发送事件: ${event.type} to ${ip}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.logDebug(`[Satori] 客户端未认证,跳过发送. IP: ${ip}, Identified: ${clientInfo.identified}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.logDebug(`[Satori] 客户端连接非 OPEN. State: ${ws.readyState}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnection (ws: WebSocket, req: IncomingMessage): void {
|
||||
const clientInfo: ClientInfo = {
|
||||
ws,
|
||||
identified: false,
|
||||
sequence: 0,
|
||||
};
|
||||
this.clients.set(ws, clientInfo);
|
||||
|
||||
this.logger.log(`[Satori] 新客户端连接: ${req.socket.remoteAddress}`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
this.handleMessage(ws, data.toString());
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(ws);
|
||||
this.logger.log(`[Satori] 客户端断开连接`);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
this.logger.logError(`[Satori] 客户端错误: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage (ws: WebSocket, data: string): void {
|
||||
try {
|
||||
const signal = JSON.parse(data) as SatoriSignal | { op?: number; };
|
||||
const clientInfo = this.clients.get(ws);
|
||||
if (!clientInfo) return;
|
||||
|
||||
if (typeof signal?.op === 'undefined') {
|
||||
this.logger.log(`[Satori] 收到无 OP 信令: ${data}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.op !== SatoriOpcode.PING) {
|
||||
this.logger.log(`[Satori] 收到信令 OP: ${signal.op}`);
|
||||
}
|
||||
|
||||
switch (signal.op) {
|
||||
case SatoriOpcode.IDENTIFY:
|
||||
this.handleIdentify(ws, clientInfo, (signal as SatoriSignal).body as SatoriIdentifyBody);
|
||||
break;
|
||||
case SatoriOpcode.PING:
|
||||
this.sendPong(ws);
|
||||
break;
|
||||
default:
|
||||
this.logger.logDebug(`[Satori] 收到未知信令: ${JSON.stringify(signal)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.logError(`[Satori] 消息解析失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleIdentify (ws: WebSocket, clientInfo: ClientInfo, body: SatoriIdentifyBody | undefined): void {
|
||||
this.logger.logDebug(`[Satori] 处理客户端认证. Token required: ${!!this.config.token}, Body present: ${!!body}`);
|
||||
|
||||
// 验证 token
|
||||
const clientToken = body?.token;
|
||||
if (this.config.token && clientToken !== this.config.token) {
|
||||
this.logger.log(`[Satori] 客户端认证失败: Token不匹配. Expected: ${this.config.token}, Received: ${clientToken}`);
|
||||
ws.close(4001, 'Invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
clientInfo.identified = true;
|
||||
if (body?.sequence) {
|
||||
clientInfo.sequence = body.sequence;
|
||||
}
|
||||
this.logger.log(`[Satori] 客户端认证通过. Sequence: ${clientInfo.sequence}`);
|
||||
|
||||
// 发送 READY 信令
|
||||
const readyBody: SatoriReadyBody = {
|
||||
logins: [{
|
||||
user: {
|
||||
id: this.core.selfInfo.uin,
|
||||
name: this.core.selfInfo.nick,
|
||||
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.core.selfInfo.uin}&s=640`,
|
||||
},
|
||||
self_id: this.core.selfInfo.uin,
|
||||
platform: this.satoriContext.configLoader.configData.platform,
|
||||
status: SatoriLoginStatus.ONLINE,
|
||||
}],
|
||||
};
|
||||
|
||||
const readySignal: SatoriSignal<SatoriReadyBody> = {
|
||||
op: SatoriOpcode.READY,
|
||||
body: readyBody,
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(readySignal));
|
||||
this.logger.log(`[Satori] 客户端认证成功`);
|
||||
}
|
||||
|
||||
private sendPong (ws: WebSocket): void {
|
||||
const pongSignal: SatoriSignal = {
|
||||
op: SatoriOpcode.PONG,
|
||||
};
|
||||
ws.send(JSON.stringify(pongSignal));
|
||||
}
|
||||
|
||||
private startHeartbeat (): void {
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
for (const [ws, clientInfo] of this.clients) {
|
||||
if (ws.readyState === WebSocket.OPEN && clientInfo.identified) {
|
||||
// 检查客户端是否还活着
|
||||
if ((ws as any).isAlive === false) {
|
||||
ws.terminate();
|
||||
this.clients.delete(ws);
|
||||
continue;
|
||||
}
|
||||
(ws as any).isAlive = false;
|
||||
ws.ping();
|
||||
}
|
||||
}
|
||||
}, this.config.heartInterval || 10000);
|
||||
}
|
||||
|
||||
private stopHeartbeat (): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
packages/napcat-satori/package.json
Normal file
39
packages/napcat-satori/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "napcat-satori",
|
||||
"version": "1.0.0",
|
||||
"description": "Satori Protocol Adapter for NapCat",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.0",
|
||||
"@sinclair/typebox": "^0.34.33",
|
||||
"ajv": "^8.17.1",
|
||||
"json5": "^2.2.3",
|
||||
"@satorijs/core": "^4.3.1",
|
||||
"@satorijs/element": "^3.1.3",
|
||||
"@satorijs/protocol": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
11
packages/napcat-satori/tsconfig.json
Normal file
11
packages/napcat-satori/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
341
packages/napcat-satori/types/index.ts
Normal file
341
packages/napcat-satori/types/index.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
// Satori Protocol Types
|
||||
// Reference: https://satori.js.org/zh-CN/protocol/
|
||||
|
||||
// ============ 基础类型 ============
|
||||
|
||||
export interface SatoriUser {
|
||||
id: string;
|
||||
name?: string;
|
||||
nick?: string;
|
||||
avatar?: string;
|
||||
is_bot?: boolean;
|
||||
}
|
||||
|
||||
export interface SatoriGuild {
|
||||
id: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface SatoriChannel {
|
||||
id: string;
|
||||
type: SatoriChannelType;
|
||||
name?: string;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
export enum SatoriChannelType {
|
||||
TEXT = 0,
|
||||
DIRECT = 1,
|
||||
CATEGORY = 2,
|
||||
VOICE = 3,
|
||||
}
|
||||
|
||||
export interface SatoriGuildMember {
|
||||
user?: SatoriUser;
|
||||
nick?: string;
|
||||
avatar?: string;
|
||||
joined_at?: number;
|
||||
}
|
||||
|
||||
export interface SatoriGuildRole {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface SatoriLogin {
|
||||
user?: SatoriUser;
|
||||
self_id?: string;
|
||||
platform?: string;
|
||||
status: SatoriLoginStatus;
|
||||
}
|
||||
|
||||
export enum SatoriLoginStatus {
|
||||
OFFLINE = 0,
|
||||
ONLINE = 1,
|
||||
CONNECT = 2,
|
||||
DISCONNECT = 3,
|
||||
RECONNECT = 4,
|
||||
}
|
||||
|
||||
// ============ 消息类型 ============
|
||||
|
||||
export interface SatoriMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
channel?: SatoriChannel;
|
||||
guild?: SatoriGuild;
|
||||
member?: SatoriGuildMember;
|
||||
user?: SatoriUser;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
// ============ 事件类型 ============
|
||||
|
||||
export interface SatoriEvent {
|
||||
id: number;
|
||||
type: string;
|
||||
platform: string;
|
||||
self_id: string;
|
||||
timestamp: number;
|
||||
argv?: SatoriArgv;
|
||||
button?: SatoriButton;
|
||||
channel?: SatoriChannel;
|
||||
guild?: SatoriGuild;
|
||||
login?: SatoriLogin;
|
||||
member?: SatoriGuildMember;
|
||||
message?: SatoriMessage;
|
||||
operator?: SatoriUser;
|
||||
role?: SatoriGuildRole;
|
||||
user?: SatoriUser;
|
||||
_type?: string;
|
||||
_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SatoriArgv {
|
||||
name: string;
|
||||
arguments: unknown[];
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SatoriButton {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// ============ API 请求/响应类型 ============
|
||||
|
||||
export interface SatoriApiRequest {
|
||||
method: string;
|
||||
body?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SatoriApiResponse<T = unknown> {
|
||||
data?: T;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriPageResult<T> {
|
||||
data: T[];
|
||||
next?: string;
|
||||
}
|
||||
|
||||
// ============ WebSocket 信令类型 ============
|
||||
|
||||
export enum SatoriOpcode {
|
||||
EVENT = 0,
|
||||
PING = 1,
|
||||
PONG = 2,
|
||||
IDENTIFY = 3,
|
||||
READY = 4,
|
||||
}
|
||||
|
||||
export interface SatoriSignal<T = unknown> {
|
||||
op: SatoriOpcode;
|
||||
body?: T;
|
||||
}
|
||||
|
||||
export interface SatoriIdentifyBody {
|
||||
token?: string;
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
export interface SatoriReadyBody {
|
||||
logins: SatoriLogin[];
|
||||
}
|
||||
|
||||
// ============ 消息元素类型 ============
|
||||
|
||||
export type SatoriElement =
|
||||
| SatoriTextElement
|
||||
| SatoriAtElement
|
||||
| SatoriSharpElement
|
||||
| SatoriAElement
|
||||
| SatoriImgElement
|
||||
| SatoriAudioElement
|
||||
| SatoriVideoElement
|
||||
| SatoriFileElement
|
||||
| SatoriBoldElement
|
||||
| SatoriItalicElement
|
||||
| SatoriUnderlineElement
|
||||
| SatoriStrikethroughElement
|
||||
| SatoriSpoilerElement
|
||||
| SatoriCodeElement
|
||||
| SatoriSupElement
|
||||
| SatoriSubElement
|
||||
| SatoriBrElement
|
||||
| SatoriParagraphElement
|
||||
| SatoriMessageElement
|
||||
| SatoriQuoteElement
|
||||
| SatoriAuthorElement
|
||||
| SatoriButtonElement;
|
||||
|
||||
export interface SatoriTextElement {
|
||||
type: 'text';
|
||||
attrs: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriAtElement {
|
||||
type: 'at';
|
||||
attrs: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriSharpElement {
|
||||
type: 'sharp';
|
||||
attrs: {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriAElement {
|
||||
type: 'a';
|
||||
attrs: {
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriImgElement {
|
||||
type: 'img';
|
||||
attrs: {
|
||||
src: string;
|
||||
title?: string;
|
||||
cache?: boolean;
|
||||
timeout?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriAudioElement {
|
||||
type: 'audio';
|
||||
attrs: {
|
||||
src: string;
|
||||
title?: string;
|
||||
cache?: boolean;
|
||||
timeout?: string;
|
||||
duration?: number;
|
||||
poster?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriVideoElement {
|
||||
type: 'video';
|
||||
attrs: {
|
||||
src: string;
|
||||
title?: string;
|
||||
cache?: boolean;
|
||||
timeout?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
poster?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriFileElement {
|
||||
type: 'file';
|
||||
attrs: {
|
||||
src: string;
|
||||
title?: string;
|
||||
cache?: boolean;
|
||||
timeout?: string;
|
||||
poster?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriBoldElement {
|
||||
type: 'b' | 'strong';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriItalicElement {
|
||||
type: 'i' | 'em';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriUnderlineElement {
|
||||
type: 'u' | 'ins';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriStrikethroughElement {
|
||||
type: 's' | 'del';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriSpoilerElement {
|
||||
type: 'spl';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriCodeElement {
|
||||
type: 'code';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriSupElement {
|
||||
type: 'sup';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriSubElement {
|
||||
type: 'sub';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriBrElement {
|
||||
type: 'br';
|
||||
}
|
||||
|
||||
export interface SatoriParagraphElement {
|
||||
type: 'p';
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriMessageElement {
|
||||
type: 'message';
|
||||
attrs?: {
|
||||
id?: string;
|
||||
forward?: boolean;
|
||||
};
|
||||
children: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriQuoteElement {
|
||||
type: 'quote';
|
||||
attrs?: {
|
||||
id?: string;
|
||||
};
|
||||
children?: SatoriElement[];
|
||||
}
|
||||
|
||||
export interface SatoriAuthorElement {
|
||||
type: 'author';
|
||||
attrs: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SatoriButtonElement {
|
||||
type: 'button';
|
||||
attrs: {
|
||||
id?: string;
|
||||
type?: 'action' | 'link' | 'input';
|
||||
href?: string;
|
||||
text?: string;
|
||||
theme?: string;
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
|
||||
import qrcode from 'napcat-qrcode/lib/main';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
import { ProtocolManager } from 'napcat-protocol';
|
||||
import { InitWebUi } from 'napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
@@ -428,6 +428,7 @@ export async function NCoreInitShell () {
|
||||
export class NapCatShell {
|
||||
readonly core: NapCatCore;
|
||||
readonly context: InstanceContext;
|
||||
public protocolManager?: ProtocolManager;
|
||||
|
||||
constructor (
|
||||
wrapper: WrapperNodeApi,
|
||||
@@ -452,11 +453,29 @@ export class NapCatShell {
|
||||
|
||||
async InitNapCat () {
|
||||
await this.core.initCore();
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
oneBotAdapter.InitOneBot()
|
||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||
|
||||
// 使用协议管理器初始化所有协议
|
||||
this.protocolManager = new ProtocolManager(this.core, this.context, this.context.pathWrapper);
|
||||
WebUiDataRuntime.setProtocolManager(this.protocolManager);
|
||||
|
||||
// 初始化所有协议
|
||||
await this.protocolManager.initAllProtocols();
|
||||
|
||||
// 获取适配器并注册到 WebUiDataRuntime
|
||||
const onebotAdapter = this.protocolManager.getOneBotAdapter();
|
||||
const satoriAdapter = this.protocolManager.getSatoriAdapter();
|
||||
|
||||
if (onebotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(onebotAdapter.getRawAdapter());
|
||||
}
|
||||
|
||||
if (satoriAdapter) {
|
||||
WebUiDataRuntime.setSatoriContext(satoriAdapter.getRawAdapter());
|
||||
WebUiDataRuntime.setOnSatoriConfigChanged(async (newConfig) => {
|
||||
const prev = satoriAdapter.getConfigLoader().configData;
|
||||
await this.protocolManager!.reloadProtocolConfig('satori', prev, newConfig);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"name": "napcat-shell",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-shell",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-protocol": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ const ShellBaseConfig = (source_map: boolean = false) =>
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/image-size': resolve(__dirname, '../image-size'),
|
||||
'@/napcat-satori': resolve(__dirname, '../napcat-satori'),
|
||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
201
packages/napcat-webui-backend/src/api/ProtocolConfig.ts
Normal file
201
packages/napcat-webui-backend/src/api/ProtocolConfig.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import json5 from 'json5';
|
||||
import { getSupportedProtocols } from 'napcat-common/src/protocol';
|
||||
|
||||
// 获取支持的协议列表
|
||||
export const GetSupportedProtocolsHandler: RequestHandler = (_req, res) => {
|
||||
const protocols = getSupportedProtocols();
|
||||
return sendSuccess(res, protocols);
|
||||
};
|
||||
|
||||
// 获取协议启用状态
|
||||
export const GetProtocolStatusHandler: RequestHandler = (_req, res) => {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
|
||||
const pm = WebUiDataRuntime.getProtocolManager();
|
||||
const status: Record<string, boolean> = {};
|
||||
|
||||
if (pm) {
|
||||
const protocols = pm.getRegisteredProtocols();
|
||||
for (const p of protocols) {
|
||||
status[p.id] = p.enabled;
|
||||
}
|
||||
return sendSuccess(res, status);
|
||||
}
|
||||
|
||||
return sendError(res, 'ProtocolManager not ready');
|
||||
};
|
||||
|
||||
// 获取 Satori 配置
|
||||
export const SatoriGetConfigHandler: RequestHandler = (_req, res) => {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
|
||||
const uin = WebUiDataRuntime.getQQLoginUin();
|
||||
const configFilePath = resolve(webUiPathWrapper.configPath, `./satori_${uin}.json`);
|
||||
|
||||
try {
|
||||
let configData: any = {
|
||||
network: {
|
||||
websocketServers: [],
|
||||
httpServers: [],
|
||||
webhookClients: [],
|
||||
},
|
||||
platform: 'qq',
|
||||
selfId: uin,
|
||||
};
|
||||
|
||||
if (existsSync(configFilePath)) {
|
||||
const content = readFileSync(configFilePath, 'utf-8');
|
||||
configData = json5.parse(content);
|
||||
}
|
||||
|
||||
return sendSuccess(res, configData);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Get Error: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
// 写入 Satori 配置
|
||||
export const SatoriSetConfigHandler: RequestHandler = async (req, res) => {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
|
||||
if (isEmpty(req.body.config)) {
|
||||
return sendError(res, 'config is empty');
|
||||
}
|
||||
|
||||
try {
|
||||
const config = json5.parse(req.body.config);
|
||||
await WebUiDataRuntime.setSatoriConfig(config);
|
||||
return sendSuccess(res, null);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Error: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取指定协议配置
|
||||
export const GetProtocolConfigHandler: RequestHandler = async (req, res) => {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
|
||||
const { name } = req.params;
|
||||
const uin = WebUiDataRuntime.getQQLoginUin();
|
||||
const protocolId = name === 'onebot11' ? 'onebot11' : name; // Normalize if needed
|
||||
|
||||
const pm = WebUiDataRuntime.getProtocolManager();
|
||||
if (pm) {
|
||||
try {
|
||||
const config = await pm.getProtocolConfig(protocolId, uin);
|
||||
return sendSuccess(res, config);
|
||||
} catch (e) {
|
||||
return sendError(res, 'ProtocolManager Get Error: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
return sendError(res, 'ProtocolManager not ready');
|
||||
};
|
||||
|
||||
// 设置指定协议配置
|
||||
export const SetProtocolConfigHandler: RequestHandler = async (req, res) => {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
|
||||
const { name } = req.params;
|
||||
const uin = WebUiDataRuntime.getQQLoginUin();
|
||||
const protocolId = name === 'onebot11' ? 'onebot11' : name;
|
||||
|
||||
if (isEmpty(req.body.config)) {
|
||||
return sendError(res, 'config is empty');
|
||||
}
|
||||
|
||||
try {
|
||||
const config = json5.parse(req.body.config);
|
||||
const pm = WebUiDataRuntime.getProtocolManager();
|
||||
|
||||
if (pm) {
|
||||
await pm.setProtocolConfig(protocolId, uin, config);
|
||||
return sendSuccess(res, null);
|
||||
}
|
||||
|
||||
return sendError(res, 'ProtocolManager not active');
|
||||
} catch (e) {
|
||||
return sendError(res, 'Error: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换指定协议启用状态
|
||||
export const ToggleProtocolHandler: RequestHandler = async (req, res) => {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
|
||||
const { name } = req.params;
|
||||
const protocolId = name === 'onebot11' ? 'onebot11' : name;
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return sendError(res, 'enabled param must be boolean');
|
||||
}
|
||||
|
||||
try {
|
||||
const pm = WebUiDataRuntime.getProtocolManager();
|
||||
if (pm) {
|
||||
await pm.setProtocolEnabled(protocolId, enabled);
|
||||
return sendSuccess(res, null);
|
||||
}
|
||||
return sendError(res, 'ProtocolManager not ready');
|
||||
} catch (e) {
|
||||
return sendError(res, 'Error: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取所有协议配置
|
||||
export const GetAllProtocolConfigsHandler: RequestHandler = (_req, res) => {
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (!isLogin) {
|
||||
return sendError(res, 'Not Login');
|
||||
}
|
||||
|
||||
const uin = WebUiDataRuntime.getQQLoginUin();
|
||||
const protocols = getSupportedProtocols();
|
||||
const configs: Record<string, any> = {};
|
||||
|
||||
for (const protocol of protocols) {
|
||||
const configPath = resolve(
|
||||
webUiPathWrapper.configPath,
|
||||
`./${protocol.id === 'onebot11' ? 'onebot11' : protocol.id}_${uin}.json`
|
||||
);
|
||||
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
configs[protocol.id] = json5.parse(content);
|
||||
} catch {
|
||||
configs[protocol.id] = null;
|
||||
}
|
||||
} else {
|
||||
configs[protocol.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, configs);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
},
|
||||
QQVersion: 'unknown',
|
||||
OneBotContext: null,
|
||||
SatoriContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
@@ -25,6 +26,9 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
NapCatHelper: {
|
||||
onOB11ConfigChanged: async () => {
|
||||
|
||||
},
|
||||
onSatoriConfigChanged: async () => {
|
||||
|
||||
},
|
||||
onQuickLoginRequested: async () => {
|
||||
return { result: false, message: '' };
|
||||
@@ -34,8 +38,8 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
},
|
||||
NapCatVersion: napCatVersion,
|
||||
WebUiConfigQuickFunction: async () => {
|
||||
|
||||
},
|
||||
ProtocolManager: null,
|
||||
};
|
||||
export const WebUiDataRuntime = {
|
||||
setWorkingEnv (env: NapCatCoreWorkingEnv): void {
|
||||
@@ -163,4 +167,28 @@ export const WebUiDataRuntime = {
|
||||
getOneBotContext (): any | null {
|
||||
return LoginRuntime.OneBotContext;
|
||||
},
|
||||
|
||||
setSatoriContext (context: any): void {
|
||||
LoginRuntime.SatoriContext = context;
|
||||
},
|
||||
|
||||
getSatoriContext (): any | null {
|
||||
return LoginRuntime.SatoriContext;
|
||||
},
|
||||
|
||||
setOnSatoriConfigChanged (func: LoginRuntimeType['NapCatHelper']['onSatoriConfigChanged']): void {
|
||||
LoginRuntime.NapCatHelper.onSatoriConfigChanged = func;
|
||||
},
|
||||
|
||||
setSatoriConfig: function (config) {
|
||||
return LoginRuntime.NapCatHelper.onSatoriConfigChanged(config);
|
||||
} as LoginRuntimeType['NapCatHelper']['onSatoriConfigChanged'],
|
||||
|
||||
setProtocolManager (pm: any): void {
|
||||
LoginRuntime.ProtocolManager = pm;
|
||||
},
|
||||
|
||||
getProtocolManager (): any | null {
|
||||
return LoginRuntime.ProtocolManager;
|
||||
},
|
||||
};
|
||||
|
||||
33
packages/napcat-webui-backend/src/router/ProtocolConfig.ts
Normal file
33
packages/napcat-webui-backend/src/router/ProtocolConfig.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
GetSupportedProtocolsHandler,
|
||||
GetProtocolStatusHandler,
|
||||
SatoriGetConfigHandler,
|
||||
SatoriSetConfigHandler,
|
||||
GetAllProtocolConfigsHandler,
|
||||
GetProtocolConfigHandler,
|
||||
SetProtocolConfigHandler,
|
||||
ToggleProtocolHandler,
|
||||
} from '@/napcat-webui-backend/src/api/ProtocolConfig';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 获取支持的协议列表
|
||||
router.get('/protocols', GetSupportedProtocolsHandler);
|
||||
|
||||
// 获取协议启用状态
|
||||
router.get('/status', GetProtocolStatusHandler);
|
||||
|
||||
// 获取所有协议配置
|
||||
router.get('/all', GetAllProtocolConfigsHandler);
|
||||
|
||||
// Satori 配置 (Reserved for backward compatibility or specific usage)
|
||||
router.get('/satori', SatoriGetConfigHandler);
|
||||
router.post('/satori', SatoriSetConfigHandler);
|
||||
|
||||
// 通用协议配置路由
|
||||
router.get('/:name/config', GetProtocolConfigHandler);
|
||||
router.post('/:name/config', SetProtocolConfigHandler);
|
||||
router.post('/:name/toggle', ToggleProtocolHandler);
|
||||
|
||||
export { router as ProtocolConfigRouter };
|
||||
@@ -16,6 +16,7 @@ import { FileRouter } from './File';
|
||||
import { WebUIConfigRouter } from './WebUIConfig';
|
||||
import { UpdateNapCatRouter } from './UpdateNapCat';
|
||||
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
import { ProtocolConfigRouter } from './ProtocolConfig';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -44,5 +45,7 @@ router.use('/WebUIConfig', WebUIConfigRouter);
|
||||
router.use('/UpdateNapCat', UpdateNapCatRouter);
|
||||
// router:调试相关路由
|
||||
router.use('/Debug', DebugRouter);
|
||||
// router:协议配置相关路由
|
||||
router.use('/ProtocolConfig', ProtocolConfigRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { OneBotConfig } from '@/napcat-webui-backend/src/onebot/config';
|
||||
|
||||
export interface SatoriConfig {
|
||||
network: {
|
||||
websocketServers: SatoriWebSocketServerConfig[];
|
||||
httpServers: SatoriHttpServerConfig[];
|
||||
webhookClients: SatoriWebHookClientConfig[];
|
||||
};
|
||||
platform: string;
|
||||
selfId: string;
|
||||
}
|
||||
|
||||
export interface SatoriWebSocketServerConfig {
|
||||
name: string;
|
||||
enable: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
path: string;
|
||||
debug: boolean;
|
||||
heartInterval: number;
|
||||
}
|
||||
|
||||
export interface SatoriHttpServerConfig {
|
||||
name: string;
|
||||
enable: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
path: string;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export interface SatoriWebHookClientConfig {
|
||||
name: string;
|
||||
enable: boolean;
|
||||
url: string;
|
||||
token: string;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export interface LoginListItem {
|
||||
uin: string;
|
||||
uid: string;
|
||||
@@ -48,13 +88,16 @@ export interface LoginRuntimeType {
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
SatoriContext: any | null; // Satori 上下文
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
onSatoriConfigChanged: (config: SatoriConfig) => Promise<void>;
|
||||
QQLoginList: string[];
|
||||
NewQQLoginList: LoginListItem[];
|
||||
};
|
||||
NapCatVersion: string;
|
||||
ProtocolManager: any | null;
|
||||
}
|
||||
|
||||
export default {};
|
||||
|
||||
@@ -24,6 +24,7 @@ const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'));
|
||||
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'));
|
||||
const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
|
||||
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
|
||||
const ProtocolPage = lazy(() => import('@/pages/dashboard/protocol'));
|
||||
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
|
||||
|
||||
function App () {
|
||||
@@ -42,7 +43,7 @@ function App () {
|
||||
);
|
||||
}
|
||||
|
||||
function AuthChecker ({ children }: { children: React.ReactNode }) {
|
||||
function AuthChecker ({ children }: { children: React.ReactNode; }) {
|
||||
const { isAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -68,6 +69,7 @@ function AppRoutes () {
|
||||
<Route path='/' element={<IndexPage />}>
|
||||
<Route index element={<DashboardIndexPage />} />
|
||||
<Route path='network' element={<NetworkPage />} />
|
||||
<Route path='protocol' element={<ProtocolPage />} />
|
||||
<Route path='config' element={<ConfigPage />} />
|
||||
<Route path='logs' element={<LogsPage />} />
|
||||
<Route path='debug' element={<DebugPage />}>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
} from '@heroui/dropdown';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { FaRegCircleQuestion } from 'react-icons/fa6';
|
||||
import { IoAddCircleOutline } from 'react-icons/io5';
|
||||
import { LuGlobe, LuServer, LuWebhook } from 'react-icons/lu';
|
||||
|
||||
import { PlusIcon } from '../icons';
|
||||
|
||||
export interface SatoriAddButtonProps {
|
||||
onOpen: (key: SatoriNetworkConfigKey) => void;
|
||||
}
|
||||
|
||||
const SatoriAddButton: React.FC<SatoriAddButtonProps> = (props) => {
|
||||
const { onOpen } = props;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md',
|
||||
}}
|
||||
placement='right'
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||
startContent={<IoAddCircleOutline className='text-2xl' />}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label='Create Satori Network Config'
|
||||
color='default'
|
||||
variant='flat'
|
||||
onAction={(key) => {
|
||||
onOpen(key as SatoriNetworkConfigKey);
|
||||
}}
|
||||
>
|
||||
<DropdownItem
|
||||
key='title'
|
||||
isReadOnly
|
||||
className='cursor-default hover:!bg-transparent'
|
||||
textValue='title'
|
||||
>
|
||||
<div className='flex items-center gap-2 justify-center'>
|
||||
<div className='w-5 h-5 -ml-3'>
|
||||
<PlusIcon />
|
||||
</div>
|
||||
<div className='text-primary-400'>新建 Satori 网络配置</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='websocketServers'
|
||||
textValue='websocketServers'
|
||||
startContent={<LuServer className='w-5 h-5' />}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
WebSocket 服务器
|
||||
<Tooltip
|
||||
content='创建一个 Satori WebSocket 服务器,用于推送事件和接收指令。客户端通过 WebSocket 连接到此服务器接收实时事件。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='httpServers'
|
||||
textValue='httpServers'
|
||||
startContent={<LuGlobe className='w-5 h-5' />}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
HTTP 服务器
|
||||
<Tooltip
|
||||
content='创建一个 Satori HTTP API 服务器,提供 RESTful API 接口。客户端可以通过 HTTP 请求调用各种 API。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='webhookClients'
|
||||
textValue='webhookClients'
|
||||
startContent={<LuWebhook className='w-5 h-5' />}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
WebHook 客户端
|
||||
<Tooltip
|
||||
content='配置一个 WebHook 上报地址,NapCat 会将事件通过 HTTP POST 请求发送到指定的 URL。'
|
||||
showArrow
|
||||
className='max-w-64'
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
className='w-4 h-4 min-w-0'
|
||||
>
|
||||
<FaRegCircleQuestion />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SatoriAddButton;
|
||||
@@ -2,9 +2,9 @@ import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPServerFormProps {
|
||||
data?: OneBotConfig['network']['httpServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>
|
||||
data?: OneBotConfig['network']['httpServers'][0];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>;
|
||||
}
|
||||
|
||||
type HTTPServerFormType = OneBotConfig['network']['httpServers'];
|
||||
@@ -20,7 +20,7 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
enableWebsocket: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
|
||||
@@ -2,11 +2,11 @@ import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPServerSSEFormProps {
|
||||
data?: OneBotConfig['network']['httpSseServers'][0]
|
||||
onClose: () => void
|
||||
data?: OneBotConfig['network']['httpSseServers'][0];
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['httpSseServers'][0]
|
||||
) => Promise<void>
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers'];
|
||||
@@ -22,7 +22,7 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
enableWebsocket: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Modal, ModalContent, ModalHeader } from '@heroui/modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import useConfig from '@/hooks/use-config';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
import HTTPClientForm from './http_client';
|
||||
import HTTPServerForm from './http_server';
|
||||
@@ -31,23 +32,57 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
|
||||
) => {
|
||||
const { isOpen, onOpenChange, field, data } = props;
|
||||
const { createNetworkConfig, updateNetworkConfig } = useConfig();
|
||||
const dialog = useDialog();
|
||||
const isCreate = !data;
|
||||
|
||||
const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createNetworkConfig(field, data);
|
||||
} else {
|
||||
await updateNetworkConfig(field, data);
|
||||
const saveData = async (dataToSave: OneBotConfig['network'][typeof field][0]) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createNetworkConfig(field, dataToSave);
|
||||
} else {
|
||||
await updateNetworkConfig(field, dataToSave);
|
||||
}
|
||||
toast.success('保存配置成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`保存配置失败: ${msg}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
toast.success('保存配置成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
};
|
||||
|
||||
toast.error(`保存配置失败: ${msg}`);
|
||||
|
||||
throw error;
|
||||
if (['httpServers', 'httpSseServers', 'websocketServers'].includes(field)) {
|
||||
const serverData = data as any;
|
||||
if (!serverData.token) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '安全警告',
|
||||
content: (
|
||||
<div>
|
||||
<p>检测到未配置Token,这可能导致安全风险。确认要继续吗?</p>
|
||||
<p className='text-sm text-gray-500 mt-2'>(未配置Token时,Host将被强制限制为 127.0.0.1)</p>
|
||||
</div>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
serverData.host = '127.0.0.1';
|
||||
try {
|
||||
await saveData(serverData);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
reject(new Error('Cancelled'));
|
||||
},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveData(data);
|
||||
};
|
||||
|
||||
const renderFormComponent = (onClose: () => void) => {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
|
||||
import { Button } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import { CgDebug } from 'react-icons/cg';
|
||||
import { FiEdit3 } from 'react-icons/fi';
|
||||
import { MdDeleteForever } from 'react-icons/md';
|
||||
|
||||
import DisplayCardContainer from '@/components/display_card/container';
|
||||
|
||||
export interface SatoriDisplayCardField {
|
||||
label: string;
|
||||
value: string | number | boolean | undefined;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface SatoriDisplayCardProps {
|
||||
data: SatoriWebSocketServerConfig | SatoriHttpServerConfig | SatoriWebHookClientConfig;
|
||||
typeLabel: string;
|
||||
fields: SatoriDisplayCardField[];
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
showType?: boolean;
|
||||
}
|
||||
|
||||
const SatoriDisplayCard: React.FC<SatoriDisplayCardProps> = ({
|
||||
data,
|
||||
typeLabel,
|
||||
fields,
|
||||
onEdit,
|
||||
onEnable,
|
||||
onDelete,
|
||||
onEnableDebug,
|
||||
showType,
|
||||
}) => {
|
||||
const { name, enable, debug } = data;
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const handleEnable = () => {
|
||||
setEditing(true);
|
||||
onEnable().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setEditing(true);
|
||||
onDelete().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const handleEnableDebug = () => {
|
||||
setEditing(true);
|
||||
onEnableDebug().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const isFullWidthField = (label: string) => ['WebHook URL', 'Token', '路径'].includes(label);
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className='w-full'
|
||||
tag={showType ? typeLabel : undefined}
|
||||
action={
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors'
|
||||
startContent={<FiEdit3 size={16} />}
|
||||
onPress={onEdit}
|
||||
isDisabled={editing}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className={clsx(
|
||||
'flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors',
|
||||
debug
|
||||
? 'hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary'
|
||||
: 'hover:bg-success/20 hover:text-success data-[hover=true]:text-success'
|
||||
)}
|
||||
startContent={<CgDebug size={16} />}
|
||||
onPress={handleEnableDebug}
|
||||
isDisabled={editing}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleDelete}
|
||||
isDisabled={editing}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
enableSwitch={
|
||||
<Switch
|
||||
isDisabled={editing}
|
||||
isSelected={enable}
|
||||
onChange={handleEnable}
|
||||
classNames={{
|
||||
wrapper: 'group-data-[selected=true]:bg-primary-400',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={name}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{(() => {
|
||||
const targetFullField = fields.find(f => isFullWidthField(f.label));
|
||||
|
||||
if (targetFullField) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>类型</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{targetFullField.render
|
||||
? targetFullField.render(targetFullField.value)
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
|
||||
)}
|
||||
>
|
||||
{String(targetFullField.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
const displayFields = fields.slice(0, 3);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>类型</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
{displayFields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{field.render
|
||||
? (
|
||||
field.render(field.value)
|
||||
)
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
|
||||
)}
|
||||
>
|
||||
{String(field.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SatoriDisplayCard;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { LuPencil, LuTrash2, LuGlobe } from 'react-icons/lu';
|
||||
|
||||
interface Props {
|
||||
data: SatoriHttpServerConfig;
|
||||
onDelete: () => Promise<void>;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function SatoriHttpServerCard ({
|
||||
data,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onEnable,
|
||||
onEnableDebug,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<LuGlobe className="w-4 h-4" />
|
||||
<span className="font-semibold truncate">{data.name}</span>
|
||||
</div>
|
||||
<Chip
|
||||
color={data.enable ? 'success' : 'default'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{data.enable ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
</CardHeader>
|
||||
<CardBody className="gap-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-default-500">地址: </span>
|
||||
<span>{data.host}:{data.port}{data.path}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-default-500">Token: </span>
|
||||
<span>{data.token ? '******' : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={data.enable}
|
||||
onValueChange={onEnable}
|
||||
>
|
||||
启用
|
||||
</Switch>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={data.debug}
|
||||
onValueChange={onEnableDebug}
|
||||
>
|
||||
调试
|
||||
</Switch>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
startContent={<LuPencil className="w-4 h-4" />}
|
||||
onPress={onEdit}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
startContent={<LuTrash2 className="w-4 h-4" />}
|
||||
onPress={onDelete}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@heroui/modal';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Props {
|
||||
data?: SatoriWebSocketServerConfig | SatoriHttpServerConfig | SatoriWebHookClientConfig;
|
||||
field: SatoriNetworkConfigKey;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onCreate: (field: SatoriNetworkConfigKey, data: any) => Promise<any>;
|
||||
onUpdate: (field: SatoriNetworkConfigKey, data: any) => Promise<any>;
|
||||
}
|
||||
|
||||
const defaultWSServer: SatoriWebSocketServerConfig = {
|
||||
name: '',
|
||||
enable: true,
|
||||
host: '127.0.0.1',
|
||||
port: 5500,
|
||||
path: '/v1/events',
|
||||
token: '',
|
||||
debug: false,
|
||||
heartInterval: 10000,
|
||||
};
|
||||
|
||||
const defaultHttpServer: SatoriHttpServerConfig = {
|
||||
name: '',
|
||||
enable: true,
|
||||
host: '127.0.0.1',
|
||||
port: 5501,
|
||||
path: '/v1',
|
||||
token: '',
|
||||
debug: false,
|
||||
};
|
||||
|
||||
const defaultWebhookClient: SatoriWebHookClientConfig = {
|
||||
name: '',
|
||||
enable: true,
|
||||
url: 'http://localhost:8080/webhook',
|
||||
token: '',
|
||||
debug: false,
|
||||
};
|
||||
|
||||
export default function SatoriNetworkFormModal ({
|
||||
data,
|
||||
field,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<any>(null);
|
||||
|
||||
const isEdit = !!data;
|
||||
const title = isEdit ? '编辑配置' : '新建配置';
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (data) {
|
||||
setFormData({ ...data });
|
||||
} else {
|
||||
switch (field) {
|
||||
case 'websocketServers':
|
||||
setFormData({ ...defaultWSServer });
|
||||
break;
|
||||
case 'httpServers':
|
||||
setFormData({ ...defaultHttpServer });
|
||||
break;
|
||||
case 'webhookClients':
|
||||
setFormData({ ...defaultWebhookClient });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, data, field]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name) {
|
||||
toast.error('请输入配置名称');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await onUpdate(field, formData);
|
||||
} else {
|
||||
await onCreate(field, formData);
|
||||
}
|
||||
toast.success(isEdit ? '更新成功' : '创建成功');
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (key: string, value: any) => {
|
||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
if (!formData) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="lg">
|
||||
<ModalContent>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalBody className="gap-4">
|
||||
<Input
|
||||
label="配置名称"
|
||||
placeholder="请输入配置名称"
|
||||
value={formData.name}
|
||||
onValueChange={(v) => updateField('name', v)}
|
||||
isDisabled={isEdit}
|
||||
/>
|
||||
|
||||
{(field === 'websocketServers' || field === 'httpServers') && (
|
||||
<>
|
||||
<Input
|
||||
label="主机地址"
|
||||
placeholder="127.0.0.1"
|
||||
value={formData.host}
|
||||
onValueChange={(v) => updateField('host', v)}
|
||||
/>
|
||||
<Input
|
||||
label="端口"
|
||||
type="number"
|
||||
placeholder="5500"
|
||||
value={String(formData.port)}
|
||||
onValueChange={(v) => updateField('port', parseInt(v) || 0)}
|
||||
/>
|
||||
<Input
|
||||
label="路径"
|
||||
placeholder="/v1/events"
|
||||
value={formData.path}
|
||||
onValueChange={(v) => updateField('path', v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{field === 'webhookClients' && (
|
||||
<Input
|
||||
label="WebHook URL"
|
||||
placeholder="http://localhost:8080/webhook"
|
||||
value={formData.url}
|
||||
onValueChange={(v) => updateField('url', v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Token"
|
||||
placeholder="可选,用于鉴权"
|
||||
value={formData.token}
|
||||
onValueChange={(v) => updateField('token', v)}
|
||||
/>
|
||||
|
||||
{field === 'websocketServers' && (
|
||||
<Input
|
||||
label="心跳间隔 (ms)"
|
||||
type="number"
|
||||
placeholder="10000"
|
||||
value={String(formData.heartInterval)}
|
||||
onValueChange={(v) => updateField('heartInterval', parseInt(v) || 10000)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Switch
|
||||
isSelected={formData.enable}
|
||||
onValueChange={(v) => updateField('enable', v)}
|
||||
>
|
||||
启用
|
||||
</Switch>
|
||||
<Switch
|
||||
isSelected={formData.debug}
|
||||
onValueChange={(v) => updateField('debug', v)}
|
||||
>
|
||||
调试模式
|
||||
</Switch>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="flat" onPress={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" isLoading={loading} onPress={handleSubmit}>
|
||||
{isEdit ? '保存' : '创建'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { LuPencil, LuTrash2, LuWebhook } from 'react-icons/lu';
|
||||
|
||||
interface Props {
|
||||
data: SatoriWebHookClientConfig;
|
||||
onDelete: () => Promise<void>;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function SatoriWebhookClientCard ({
|
||||
data,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onEnable,
|
||||
onEnableDebug,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<LuWebhook className="w-4 h-4" />
|
||||
<span className="font-semibold truncate">{data.name}</span>
|
||||
</div>
|
||||
<Chip
|
||||
color={data.enable ? 'success' : 'default'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{data.enable ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
</CardHeader>
|
||||
<CardBody className="gap-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-default-500">URL: </span>
|
||||
<span className="break-all">{data.url}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-default-500">Token: </span>
|
||||
<span>{data.token ? '******' : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={data.enable}
|
||||
onValueChange={onEnable}
|
||||
>
|
||||
启用
|
||||
</Switch>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={data.debug}
|
||||
onValueChange={onEnableDebug}
|
||||
>
|
||||
调试
|
||||
</Switch>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
startContent={<LuPencil className="w-4 h-4" />}
|
||||
onPress={onEdit}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
startContent={<LuTrash2 className="w-4 h-4" />}
|
||||
onPress={onDelete}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { LuPencil, LuTrash2, LuServer } from 'react-icons/lu';
|
||||
|
||||
interface Props {
|
||||
data: SatoriWebSocketServerConfig;
|
||||
onDelete: () => Promise<void>;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function SatoriWSServerCard ({
|
||||
data,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onEnable,
|
||||
onEnableDebug,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<LuServer className="w-4 h-4" />
|
||||
<span className="font-semibold truncate">{data.name}</span>
|
||||
</div>
|
||||
<Chip
|
||||
color={data.enable ? 'success' : 'default'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{data.enable ? '已启用' : '未启用'}
|
||||
</Chip>
|
||||
</CardHeader>
|
||||
<CardBody className="gap-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-default-500">地址: </span>
|
||||
<span>{data.host}:{data.port}{data.path}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-default-500">心跳间隔: </span>
|
||||
<span>{data.heartInterval}ms</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-default-500">Token: </span>
|
||||
<span>{data.token ? '******' : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={data.enable}
|
||||
onValueChange={onEnable}
|
||||
>
|
||||
启用
|
||||
</Switch>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={data.debug}
|
||||
onValueChange={onEnableDebug}
|
||||
>
|
||||
调试
|
||||
</Switch>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
startContent={<LuPencil className="w-4 h-4" />}
|
||||
onPress={onEdit}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
startContent={<LuTrash2 className="w-4 h-4" />}
|
||||
onPress={onDelete}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
LuFolderOpen,
|
||||
LuInfo,
|
||||
LuLayoutDashboard,
|
||||
LuPlug,
|
||||
LuSettings,
|
||||
LuSignal,
|
||||
LuTerminal,
|
||||
@@ -34,6 +35,11 @@ export const siteConfig = {
|
||||
icon: <LuSignal className='w-5 h-5' />,
|
||||
href: '/network',
|
||||
},
|
||||
{
|
||||
label: '协议配置',
|
||||
icon: <LuPlug className='w-5 h-5' />,
|
||||
href: '/protocol',
|
||||
},
|
||||
{
|
||||
label: '其他配置',
|
||||
icon: <LuSettings className='w-5 h-5' />,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { serverRequest } from '@/utils/request';
|
||||
|
||||
const ProtocolManager = {
|
||||
async getSupportedProtocols (): Promise<ProtocolInfo[]> {
|
||||
const res = await serverRequest.get<ServerResponse<ProtocolInfo[]>>(
|
||||
'/ProtocolConfig/protocols'
|
||||
);
|
||||
if (res.data.code !== 0) {
|
||||
throw new Error(res.data.message);
|
||||
}
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
async getProtocolStatus (): Promise<Record<string, boolean>> {
|
||||
const res = await serverRequest.get<ServerResponse<Record<string, boolean>>>(
|
||||
'/ProtocolConfig/status'
|
||||
);
|
||||
if (res.data.code !== 0) {
|
||||
throw new Error(res.data.message);
|
||||
}
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
async getSatoriConfig (): Promise<SatoriConfig> {
|
||||
const res = await serverRequest.get<ServerResponse<SatoriConfig>>(
|
||||
'/ProtocolConfig/satori'
|
||||
);
|
||||
if (res.data.code !== 0) {
|
||||
throw new Error(res.data.message);
|
||||
}
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
async setSatoriConfig (config: SatoriConfig): Promise<void> {
|
||||
const res = await serverRequest.post<ServerResponse<null>>(
|
||||
'/ProtocolConfig/satori',
|
||||
{ config: JSON.stringify(config) }
|
||||
);
|
||||
if (res.data.code !== 0) {
|
||||
throw new Error(res.data.message);
|
||||
}
|
||||
},
|
||||
async toggleProtocol (name: string, enabled: boolean): Promise<void> {
|
||||
const res = await serverRequest.post<ServerResponse<null>>(
|
||||
`/ProtocolConfig/${name}/toggle`,
|
||||
{ enabled }
|
||||
);
|
||||
if (res.data.code !== 0) {
|
||||
throw new Error(res.data.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default ProtocolManager;
|
||||
152
packages/napcat-webui-frontend/src/hooks/use-protocol-config.ts
Normal file
152
packages/napcat-webui-frontend/src/hooks/use-protocol-config.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import ProtocolManager from '@/controllers/protocol_manager';
|
||||
import { deepClone } from '@/utils/object';
|
||||
|
||||
const useProtocolConfig = () => {
|
||||
const [protocols, setProtocols] = useState<ProtocolInfo[]>([]);
|
||||
const [protocolStatus, setProtocolStatus] = useState<Record<string, boolean>>({});
|
||||
const [satoriConfig, setSatoriConfig] = useState<SatoriConfig | null>(null);
|
||||
|
||||
const refreshProtocols = useCallback(async () => {
|
||||
const [protocolList, status] = await Promise.all([
|
||||
ProtocolManager.getSupportedProtocols(),
|
||||
ProtocolManager.getProtocolStatus(),
|
||||
]);
|
||||
setProtocols(protocolList);
|
||||
setProtocolStatus(status);
|
||||
}, []);
|
||||
|
||||
const refreshSatoriConfig = useCallback(async () => {
|
||||
const config = await ProtocolManager.getSatoriConfig();
|
||||
setSatoriConfig(config);
|
||||
}, []);
|
||||
|
||||
const createSatoriNetworkConfig = async <T extends SatoriNetworkConfigKey> (
|
||||
key: T,
|
||||
value: SatoriNetworkConfig[T][0]
|
||||
) => {
|
||||
if (!satoriConfig) throw new Error('配置未加载');
|
||||
|
||||
const allNames = Object.keys(satoriConfig.network).reduce((acc, k) => {
|
||||
const _key = k as SatoriNetworkConfigKey;
|
||||
return acc.concat(satoriConfig.network[_key].map((item) => item.name));
|
||||
}, [] as string[]);
|
||||
|
||||
if (value.name && allNames.includes(value.name)) {
|
||||
throw new Error('已存在相同名称的配置项');
|
||||
}
|
||||
|
||||
const newConfig = deepClone(satoriConfig);
|
||||
(newConfig.network[key] as (typeof value)[]).push(value);
|
||||
|
||||
await ProtocolManager.setSatoriConfig(newConfig);
|
||||
setSatoriConfig(newConfig);
|
||||
await refreshSatoriConfig();
|
||||
return newConfig;
|
||||
};
|
||||
|
||||
const updateSatoriNetworkConfig = async <T extends SatoriNetworkConfigKey> (
|
||||
key: T,
|
||||
value: SatoriNetworkConfig[T][0]
|
||||
) => {
|
||||
if (!satoriConfig) throw new Error('配置未加载');
|
||||
|
||||
const newConfig = deepClone(satoriConfig);
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === value.name);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项');
|
||||
}
|
||||
|
||||
newConfig.network[key][index] = value;
|
||||
|
||||
await ProtocolManager.setSatoriConfig(newConfig);
|
||||
setSatoriConfig(newConfig);
|
||||
await refreshSatoriConfig();
|
||||
return newConfig;
|
||||
};
|
||||
|
||||
const deleteSatoriNetworkConfig = async <T extends SatoriNetworkConfigKey> (
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
if (!satoriConfig) throw new Error('配置未加载');
|
||||
|
||||
const newConfig = deepClone(satoriConfig);
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项');
|
||||
}
|
||||
|
||||
newConfig.network[key].splice(index, 1);
|
||||
|
||||
await ProtocolManager.setSatoriConfig(newConfig);
|
||||
setSatoriConfig(newConfig);
|
||||
await refreshSatoriConfig();
|
||||
return newConfig;
|
||||
};
|
||||
|
||||
const enableSatoriNetworkConfig = async <T extends SatoriNetworkConfigKey> (
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
if (!satoriConfig) throw new Error('配置未加载');
|
||||
|
||||
const newConfig = deepClone(satoriConfig);
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项');
|
||||
}
|
||||
|
||||
newConfig.network[key][index].enable = !newConfig.network[key][index].enable;
|
||||
|
||||
await ProtocolManager.setSatoriConfig(newConfig);
|
||||
setSatoriConfig(newConfig);
|
||||
await refreshSatoriConfig();
|
||||
return newConfig;
|
||||
};
|
||||
|
||||
const enableSatoriDebugConfig = async <T extends SatoriNetworkConfigKey> (
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
if (!satoriConfig) throw new Error('配置未加载');
|
||||
|
||||
const newConfig = deepClone(satoriConfig);
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项');
|
||||
}
|
||||
|
||||
newConfig.network[key][index].debug = !newConfig.network[key][index].debug;
|
||||
|
||||
await ProtocolManager.setSatoriConfig(newConfig);
|
||||
setSatoriConfig(newConfig);
|
||||
await refreshSatoriConfig();
|
||||
return newConfig;
|
||||
};
|
||||
|
||||
return {
|
||||
protocols,
|
||||
protocolStatus,
|
||||
satoriConfig,
|
||||
refreshProtocols,
|
||||
refreshSatoriConfig,
|
||||
createSatoriNetworkConfig,
|
||||
updateSatoriNetworkConfig,
|
||||
deleteSatoriNetworkConfig,
|
||||
enableSatoriNetworkConfig,
|
||||
enableSatoriDebugConfig,
|
||||
toggleProtocol: async (name: string, enabled: boolean) => {
|
||||
await ProtocolManager.toggleProtocol(name, enabled);
|
||||
await refreshProtocols();
|
||||
// If we enable/disable a protocol, maybe we should also refresh satori config if it was satori?
|
||||
// Since toggleProtocol refreshes status, it's fine.
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default useProtocolConfig;
|
||||
396
packages/napcat-webui-frontend/src/pages/dashboard/protocol.tsx
Normal file
396
packages/napcat-webui-frontend/src/pages/dashboard/protocol.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { useDisclosure } from '@heroui/modal';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
import { LuPlug } from 'react-icons/lu';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import SatoriAddButton from '@/components/button/satori_add_button';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import SatoriNetworkFormModal from '@/components/protocol_edit/satori_modal';
|
||||
import SatoriDisplayCard, { SatoriDisplayCardField } from '@/components/protocol_edit/satori_common_card';
|
||||
|
||||
import useProtocolConfig from '@/hooks/use-protocol-config';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
export interface EmptySectionProps {
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('text-default-400', {
|
||||
hidden: !isEmpty,
|
||||
})}
|
||||
>
|
||||
暂时还没有配置项哈
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ProtocolPage () {
|
||||
const {
|
||||
protocols,
|
||||
protocolStatus,
|
||||
satoriConfig,
|
||||
refreshProtocols,
|
||||
refreshSatoriConfig,
|
||||
createSatoriNetworkConfig,
|
||||
updateSatoriNetworkConfig,
|
||||
deleteSatoriNetworkConfig,
|
||||
enableSatoriNetworkConfig,
|
||||
enableSatoriDebugConfig,
|
||||
toggleProtocol,
|
||||
} = useProtocolConfig();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeProtocol, setActiveProtocol] = useState<string>('satori');
|
||||
const [activeSatoriField, setActiveSatoriField] = useState<SatoriNetworkConfigKey>('websocketServers');
|
||||
const [activeSatoriName, setActiveSatoriName] = useState<string>('');
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const dialog = useDialog();
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await refreshProtocols();
|
||||
await refreshSatoriConfig();
|
||||
} catch (error) {
|
||||
toast.error(`刷新失败: ${(error as Error).message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickCreate = (key: SatoriNetworkConfigKey) => {
|
||||
if (!protocolStatus['satori']) {
|
||||
dialog.confirm({
|
||||
title: '启用 Satori 协议',
|
||||
content: '检测到 Satori 协议未启用,是否立即启用并继续创建配置?',
|
||||
onConfirm: async () => {
|
||||
const loadingToast = toast.loading('正在启用协议...');
|
||||
try {
|
||||
await toggleProtocol('satori', true);
|
||||
await refreshSatoriConfig();
|
||||
toast.success('协议已启用');
|
||||
|
||||
setActiveSatoriField(key);
|
||||
setActiveSatoriName('');
|
||||
onOpen();
|
||||
} catch (error) {
|
||||
toast.error(`启用失败: ${(error as Error).message}`);
|
||||
} finally {
|
||||
toast.dismiss(loadingToast);
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
setActiveSatoriField(key);
|
||||
setActiveSatoriName('');
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const onDelete = async (field: SatoriNetworkConfigKey, name: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '删除配置',
|
||||
content: `确定要删除配置「${name}」吗?`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteSatoriNetworkConfig(field, name);
|
||||
toast.success('删除配置成功');
|
||||
resolve();
|
||||
} catch (error) {
|
||||
toast.error(`删除配置失败: ${(error as Error).message}`);
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
onCancel: () => resolve(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onEnable = async (field: SatoriNetworkConfigKey, name: string) => {
|
||||
try {
|
||||
await enableSatoriNetworkConfig(field, name);
|
||||
toast.success('更新配置成功');
|
||||
} catch (error) {
|
||||
toast.error(`更新配置失败: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const onEnableDebug = async (field: SatoriNetworkConfigKey, name: string) => {
|
||||
try {
|
||||
await enableSatoriDebugConfig(field, name);
|
||||
toast.success('更新配置成功');
|
||||
} catch (error) {
|
||||
toast.error(`更新配置失败: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = (field: SatoriNetworkConfigKey, name: string) => {
|
||||
setActiveSatoriField(field);
|
||||
setActiveSatoriName(name);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const activeSatoriData = useMemo(() => {
|
||||
return satoriConfig?.network[activeSatoriField]?.find(
|
||||
(item: SatoriAdapterConfig) => item.name === activeSatoriName
|
||||
);
|
||||
}, [satoriConfig, activeSatoriField, activeSatoriName]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const renderSatoriCard = (
|
||||
type: SatoriNetworkConfigKey,
|
||||
item: SatoriAdapterConfig,
|
||||
typeLabel: string
|
||||
) => {
|
||||
let fields: SatoriDisplayCardField[] = [];
|
||||
|
||||
if (type === 'websocketServers') {
|
||||
const data = item as SatoriWebSocketServerConfig;
|
||||
fields = [
|
||||
{ label: '主机', value: data.host },
|
||||
{ label: '端口', value: data.port },
|
||||
{ label: '路径', value: data.path },
|
||||
{ label: '心跳间隔', value: `${data.heartInterval}ms` },
|
||||
{ label: 'Token', value: data.token ? '******' : '未设置' },
|
||||
];
|
||||
} else if (type === 'httpServers') {
|
||||
const data = item as SatoriHttpServerConfig;
|
||||
fields = [
|
||||
{ label: '主机', value: data.host },
|
||||
{ label: '端口', value: data.port },
|
||||
{ label: '路径', value: data.path },
|
||||
{ label: 'Token', value: data.token ? '******' : '未设置' },
|
||||
];
|
||||
} else if (type === 'webhookClients') {
|
||||
const data = item as SatoriWebHookClientConfig;
|
||||
fields = [
|
||||
{ label: 'WebHook URL', value: data.url },
|
||||
{ label: 'Token', value: data.token ? '******' : '未设置' },
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<SatoriDisplayCard
|
||||
key={item.name}
|
||||
typeLabel={typeLabel}
|
||||
data={item as any}
|
||||
fields={fields}
|
||||
onDelete={() => onDelete(type, item.name)}
|
||||
onEdit={() => onEdit(type, item.name)}
|
||||
onEnable={() => onEnable(type, item.name)}
|
||||
onEnableDebug={() => onEnableDebug(type, item.name)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const satoriTabs = useMemo(() => {
|
||||
if (!satoriConfig) return [];
|
||||
|
||||
const wsItems = satoriConfig.network.websocketServers.map((item) =>
|
||||
renderSatoriCard('websocketServers', item, 'WS服务器')
|
||||
);
|
||||
const httpItems = satoriConfig.network.httpServers.map((item) =>
|
||||
renderSatoriCard('httpServers', item, 'HTTP服务器')
|
||||
);
|
||||
const webhookItems = satoriConfig.network.webhookClients.map((item) =>
|
||||
renderSatoriCard('webhookClients', item, 'WebHook客户端')
|
||||
);
|
||||
|
||||
const allItems = [...wsItems, ...httpItems, ...webhookItems];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
title: '全部',
|
||||
items: allItems,
|
||||
},
|
||||
{
|
||||
key: 'websocketServers',
|
||||
title: 'WebSocket 服务器',
|
||||
items: wsItems,
|
||||
},
|
||||
{
|
||||
key: 'httpServers',
|
||||
title: 'HTTP 服务器',
|
||||
items: httpItems,
|
||||
},
|
||||
{
|
||||
key: 'webhookClients',
|
||||
title: 'WebHook 客户端',
|
||||
items: webhookItems,
|
||||
},
|
||||
];
|
||||
}, [satoriConfig]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>协议配置 - NapCat WebUI</title>
|
||||
<div className="p-4 md:p-6 relative max-w-[1920px] mx-auto min-h-screen">
|
||||
<PageLoading loading={loading} />
|
||||
<SatoriNetworkFormModal
|
||||
data={activeSatoriData}
|
||||
field={activeSatoriField}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
onCreate={createSatoriNetworkConfig}
|
||||
onUpdate={updateSatoriNetworkConfig}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Protocol Selectors */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{protocols.map((protocol: ProtocolInfo) => {
|
||||
const isActive = activeProtocol === protocol.id;
|
||||
const isEnabled = protocolStatus[protocol.id];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={protocol.id}
|
||||
isPressable
|
||||
onPress={() => setActiveProtocol(protocol.id)}
|
||||
className={clsx(
|
||||
"border transition-all duration-300",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 shadow-md"
|
||||
: "border-transparent bg-default-100/50 hover:bg-default-200/50"
|
||||
)}
|
||||
shadow={isActive ? "sm" : "none"}
|
||||
>
|
||||
<CardBody className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
isActive ? "bg-primary text-white" : "bg-default-200 text-default-500"
|
||||
)}>
|
||||
<LuPlug size={20} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className={clsx(
|
||||
"font-bold text-base",
|
||||
isActive ? "text-primary" : "text-default-700"
|
||||
)}>
|
||||
{protocol.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
isEnabled ? "bg-success animate-pulse" : "bg-default-300"
|
||||
)} />
|
||||
<span className="text-xs text-default-400">
|
||||
{isEnabled ? "运行中" : "已停止"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
size="sm"
|
||||
color={isEnabled ? "success" : "default"}
|
||||
isSelected={isEnabled}
|
||||
onValueChange={(val) => toggleProtocol(protocol.id, val)}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1">
|
||||
{activeProtocol === 'onebot11' && (
|
||||
<div className="flex flex-col items-center justify-center p-16 text-center border border-dashed border-default-200 dark:border-default-100 rounded-3xl bg-default-50/30 min-h-[400px]">
|
||||
<div className="p-4 rounded-full bg-default-100 mb-6">
|
||||
<LuPlug className="w-12 h-12 text-default-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-default-700 mb-2">OneBot 11 协议配置</h3>
|
||||
<p className="text-default-500 mb-8 max-w-md leading-relaxed">
|
||||
OneBot 11 协议的网络配置已集成在系统的基础网络设置中,请前往网络配置页面进行管理。
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
as={Link}
|
||||
to="/dashboard/network"
|
||||
color="primary"
|
||||
variant="shadow"
|
||||
className="font-medium px-8"
|
||||
radius="full"
|
||||
>
|
||||
前往网络配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="flat"
|
||||
className="font-medium px-8"
|
||||
radius="full"
|
||||
startContent={<IoMdRefresh size={18} />}
|
||||
onPress={refresh}
|
||||
>
|
||||
刷新状态
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeProtocol === 'satori' && satoriConfig && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
{/* Satori Content Header - Optional, maybe just space */}
|
||||
<div className="hidden md:block"></div>
|
||||
<div className="flex items-center gap-2 self-end md:self-auto">
|
||||
<SatoriAddButton onOpen={handleClickCreate} />
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md shadow-sm"
|
||||
onPress={refresh}
|
||||
>
|
||||
<IoMdRefresh size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
aria-label="Satori Network Configs"
|
||||
className="w-full"
|
||||
items={satoriTabs}
|
||||
classNames={{
|
||||
tabList: 'bg-default-100/50 dark:bg-default-50/20 backdrop-blur-md p-1 rounded-2xl mb-6 w-full md:w-auto',
|
||||
cursor: 'bg-background shadow-sm rounded-xl',
|
||||
tab: 'h-9 px-4',
|
||||
tabContent: 'text-default-500 group-data-[selected=true]:text-primary font-medium',
|
||||
panel: 'pt-0'
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<Tab key={item.key} title={item.title}>
|
||||
<EmptySection isEmpty={!item.items?.length} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 md:gap-6 pb-8">
|
||||
{item.items}
|
||||
</div>
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
packages/napcat-webui-frontend/src/types/protocol.d.ts
vendored
Normal file
48
packages/napcat-webui-frontend/src/types/protocol.d.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
// 协议相关类型定义
|
||||
|
||||
interface ProtocolInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Satori 配置类型
|
||||
interface SatoriAdapterConfig {
|
||||
name: string;
|
||||
enable: boolean;
|
||||
debug: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface SatoriWebSocketServerConfig extends SatoriAdapterConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
heartInterval: number;
|
||||
}
|
||||
|
||||
interface SatoriHttpServerConfig extends SatoriAdapterConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SatoriWebHookClientConfig extends SatoriAdapterConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface SatoriNetworkConfig {
|
||||
websocketServers: SatoriWebSocketServerConfig[];
|
||||
httpServers: SatoriHttpServerConfig[];
|
||||
webhookClients: SatoriWebHookClientConfig[];
|
||||
}
|
||||
|
||||
interface SatoriConfig {
|
||||
network: SatoriNetworkConfig;
|
||||
platform: string;
|
||||
selfId: string;
|
||||
}
|
||||
|
||||
type SatoriNetworkConfigKey = keyof SatoriNetworkConfig;
|
||||
547
pnpm-lock.yaml
generated
547
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
Reference in New Issue
Block a user