Compare commits

...

9 Commits

Author SHA1 Message Date
手瓜一十雪
323dc71d4e Improve Satori WebSocket logging and event handling
Enhanced debug logging in the Satori WebSocket server for better traceability of client events and authentication. Improved handling of client identification, including more robust checks and detailed logs for token validation. Removed unused MessageUnique logic from NapCatSatoriAdapter and added additional debug logs for event emission and message processing. Added a new onNtMsgSyncContactUnread method stub in NodeIKernelMsgListener.
2026-01-14 18:47:10 +08:00
手瓜一十雪
b0d88d3705 Refactor Satori actions with schema validation and router
Refactored all Satori action classes to use TypeBox schemas for payload validation and unified action naming via a new router. Added schema-based parameter checking to the SatoriAction base class. Introduced new actions for guild and member approval, and login retrieval. Centralized action name constants and types in a new router module. Enhanced event and message APIs with more structured event types and parsing logic. Added helper utilities for XML parsing. Updated exports and registration logic to support the new structure.
2026-01-14 17:52:38 +08:00
手瓜一十雪
32c0c93f3b Remove redundant private modifiers from constructor params
Eliminated unnecessary 'private' access modifiers from constructor parameters in OneBotProtocolAdapter and SatoriProtocolAdapter. This change clarifies parameter usage and avoids creating unused private fields.
2026-01-14 17:23:02 +08:00
手瓜一十雪
ea399c8017 Add protocol enable/disable and config management APIs
Introduces persistent protocol enable/disable state and related API endpoints in the backend, and adds frontend support for toggling protocols and managing protocol configs. Refactors protocol config storage to use per-protocol files, updates ProtocolManager to handle config and status, and enhances the Satori protocol UI with unified card components and improved state refresh. Removes the obsolete PROTOCOL_REFACTOR.md documentation.
2026-01-14 17:04:13 +08:00
手瓜一十雪
26d38bebe7 Refactor imports and add generic protocol config API
Replaced all '@/napcat-satori/...' imports with relative paths for consistency and compatibility. Added generic protocol config get/set handlers and routes in the web UI backend to support extensible protocol configuration management. Improved error handling and default value logic for Satori protocol configuration.
2026-01-14 16:01:29 +08:00
手瓜一十雪
506358e01a Refactor protocol management with napcat-protocol package
Introduced the new napcat-protocol package to unify protocol adapter management for OneBot and Satori. Updated napcat-framework and napcat-shell to use ProtocolManager instead of direct adapter instantiation. Added protocol info definitions to napcat-common, and integrated protocol configuration and management APIs into the web UI backend and frontend. This refactor improves maintainability, extensibility, and encapsulation of protocol logic, while maintaining backward compatibility.
2026-01-14 15:41:47 +08:00
手瓜一十雪
7cd0e5b2a4 Add isActive property to plugin adapters
Introduces an isActive getter to OB11PluginAdapter and OB11PluginMangerAdapter, which returns true only if the adapter is enabled and has loaded plugins. Updates event emission logic to use isActive instead of isEnable, ensuring events are only sent to active adapters.
2026-01-14 13:18:37 +08:00
手瓜一十雪
76447a385f Add onLoginRecordUpdate method to listener
Introduces the onLoginRecordUpdate method to NodeIKernelLoginListener, preparing for future handling of login record updates.
2026-01-14 13:13:18 +08:00
手瓜一十雪
5047b03303 Refactor network adapter activation and message handling
Introduces isActive property to network adapters for more accurate activation checks, refactors message dispatch logic to use only active adapters, and improves heartbeat management for WebSocket adapters. Also sets default enableWebsocket to false in config and frontend forms, and adds a security dialog for missing tokens in the web UI.
2026-01-14 13:11:17 +08:00
87 changed files with 7188 additions and 195 deletions

View 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;
}

View File

@@ -11,6 +11,7 @@ export const NapcatConfigSchema = Type.Object({
packetBackend: Type.String({ default: 'auto' }), packetBackend: Type.String({ default: 'auto' }),
packetServer: Type.String({ default: '' }), packetServer: Type.String({ default: '' }),
o3HookMode: Type.Number({ default: 0 }), o3HookMode: Type.Number({ default: 0 }),
protocols: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
}); });
export type NapcatConfig = Static<typeof NapcatConfigSchema>; export type NapcatConfig = Static<typeof NapcatConfigSchema>;

View File

@@ -53,6 +53,8 @@ export class NodeIKernelLoginListener {
onLoginState (..._args: any[]): any { onLoginState (..._args: any[]): any {
} }
onLoginRecordUpdate (..._args: any[]): any {
}
} }
export interface QRCodeLoginSucceedResult { export interface QRCodeLoginSucceedResult {

View File

@@ -382,5 +382,8 @@ export class NodeIKernelMsgListener {
// 第一次发现于Win 9.9.9-23159 // 第一次发现于Win 9.9.9-23159
onBroadcastHelperProgerssUpdate (..._args: unknown[]): any { onBroadcastHelperProgerssUpdate (..._args: unknown[]): any {
}
onNtMsgSyncContactUnread (..._args: unknown[]): any {
} }
} }

View File

@@ -1,6 +1,6 @@
import { NapCatPathWrapper } from 'napcat-common/src/path'; import { NapCatPathWrapper } from 'napcat-common/src/path';
import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/index'; 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 { NativePacketHandler } from 'napcat-core/packet/handler/client';
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg'; import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
import { logSubscription, LogWrapper } from 'napcat-core/helper/log'; import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
@@ -39,22 +39,12 @@ export async function NCoreInitFramework (
await applyPendingUpdates(pathWrapper, logger); await applyPendingUpdates(pathWrapper, logger);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion()); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用 const nativePacketHandler = new NativePacketHandler({ logger });
// nativePacketHandler.onAll((packet) => {
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
// });
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion()); await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
// 在 init 之后注册监听器
// 初始化 FFmpeg 服务 // 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger); 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 selfInfo = await new Promise<SelfInfo>((resolve) => {
const loginListener = new NodeIKernelLoginListener(); const loginListener = new NodeIKernelLoginListener();
loginListener.onQRCodeLoginSucceed = async (loginResult) => { loginListener.onQRCodeLoginSucceed = async (loginResult) => {
@@ -64,14 +54,13 @@ export async function NCoreInitFramework (
resolve({ resolve({
uid: loginResult.uid, uid: loginResult.uid,
uin: loginResult.uin, uin: loginResult.uin,
nick: '', // 获取不到 nick: '',
online: true, online: true,
}); });
}; };
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger)); loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
}); });
// 过早进入会导致addKernelMsgListener等Listener添加失败
// await sleep(2500);
// 初始化 NapCatFramework // 初始化 NapCatFramework
const loaderObject = new NapCatFramework(wrapper, session, logger, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler); const loaderObject = new NapCatFramework(wrapper, session, logger, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
await loaderObject.core.initCore(); await loaderObject.core.initCore();
@@ -79,16 +68,38 @@ export async function NCoreInitFramework (
// 启动WebUi // 启动WebUi
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework); WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e)); InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
// 初始化LLNC的Onebot实现
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper); // 使用协议管理器初始化所有协议
// 注册到 WebUiDataRuntime供调试功能使用 const protocolManager = new ProtocolManager(loaderObject.core, loaderObject.context, pathWrapper);
WebUiDataRuntime.setOneBotContext(oneBotAdapter); WebUiDataRuntime.setProtocolManager(protocolManager);
await oneBotAdapter.InitOneBot();
// 初始化所有协议
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 { export class NapCatFramework {
public core: NapCatCore; public core: NapCatCore;
context: InstanceContext; public context: InstanceContext;
public protocolManager?: ProtocolManager;
constructor ( constructor (
wrapper: WrapperNodeApi, wrapper: WrapperNodeApi,

View File

@@ -1,33 +1,33 @@
{ {
"name": "napcat-framework", "name": "napcat-framework",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./index.ts"
}, },
"exports": { "./*": {
".": { "import": "./*"
"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"
} }
},
"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"
}
} }

View File

@@ -6,7 +6,7 @@ const HttpServerConfigSchema = Type.Object({
port: Type.Number({ default: 3000 }), port: Type.Number({ default: 3000 }),
host: Type.String({ default: '127.0.0.1' }), host: Type.String({ default: '127.0.0.1' }),
enableCors: Type.Boolean({ default: true }), enableCors: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: true }), enableWebsocket: Type.Boolean({ default: false }),
messagePostFormat: Type.String({ default: 'array' }), messagePostFormat: Type.String({ default: 'array' }),
token: Type.String({ default: '' }), token: Type.String({ default: '' }),
debug: Type.Boolean({ default: false }), debug: Type.Boolean({ default: false }),
@@ -18,7 +18,7 @@ const HttpSseServerConfigSchema = Type.Object({
port: Type.Number({ default: 3000 }), port: Type.Number({ default: 3000 }),
host: Type.String({ default: '127.0.0.1' }), host: Type.String({ default: '127.0.0.1' }),
enableCors: Type.Boolean({ default: true }), enableCors: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: true }), enableWebsocket: Type.Boolean({ default: false }),
messagePostFormat: Type.String({ default: 'array' }), messagePostFormat: Type.String({ default: 'array' }),
token: Type.String({ default: '' }), token: Type.String({ default: '' }),
debug: Type.Boolean({ default: false }), debug: Type.Boolean({ default: false }),

View File

@@ -246,7 +246,7 @@ export class NapCatOneBot11Adapter {
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter); await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
} }
private async handleConfigChange<CT extends NetworkAdapterConfig>( private async handleConfigChange<CT extends NetworkAdapterConfig> (
prevConfig: NetworkAdapterConfig[], prevConfig: NetworkAdapterConfig[],
nowConfig: NetworkAdapterConfig[], nowConfig: NetworkAdapterConfig[],
adapterClass: new ( adapterClass: new (
@@ -305,6 +305,9 @@ export class NapCatOneBot11Adapter {
}; };
msgListener.onRecvMsg = async (msg) => { msgListener.onRecvMsg = async (msg) => {
if (!this.networkManager.hasActiveAdapters()) {
return;
}
for (const m of msg) { for (const m of msg) {
if (this.bootTime > parseInt(m.msgTime)) { if (this.bootTime > parseInt(m.msgTime)) {
this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`); this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`);
@@ -517,15 +520,14 @@ export class NapCatOneBot11Adapter {
} }
private async emitMsg (message: RawMessage) { private async emitMsg (message: RawMessage) {
const network = await this.networkManager.getAllConfig();
this.context.logger.logDebug('收到新消息 RawMessage', message); this.context.logger.logDebug('收到新消息 RawMessage', message);
await Promise.allSettled([ await Promise.allSettled([
this.handleMsg(message, network), this.handleMsg(message),
message.chatType === ChatType.KCHATTYPEGROUP ? this.handleGroupEvent(message) : this.handlePrivateMsgEvent(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) { if (message.msgType === NTMsgType.KMSGTYPENULL) {
return; return;
@@ -535,10 +537,36 @@ export class NapCatOneBot11Adapter {
if (ob11Msg) { if (ob11Msg) {
const isSelfMsg = this.isSelfMessage(ob11Msg); const isSelfMsg = this.isSelfMessage(ob11Msg);
this.context.logger.logDebug('转化为 OB11Message', ob11Msg); this.context.logger.logDebug('转化为 OB11Message', ob11Msg);
const msgMap = this.createMsgMap(network, ob11Msg, isSelfMsg, message); if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) {
this.handleDebugNetwork(network, msgMap, message); const targetId = parseInt(message.peerUin);
this.handleNotReportSelfNetwork(network, msgMap, isSelfMsg); ob11Msg.stringMsg.target_id = targetId;
this.networkManager.emitEventByNames(msgMap); 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) { } catch (e) {
this.context.logger.logError('constructMessage error: ', e); this.context.logger.logError('constructMessage error: ', e);
@@ -553,48 +581,6 @@ export class NapCatOneBot11Adapter {
ob11Msg.arrayMsg.user_id.toString() === this.core.selfInfo.uin; 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) { private async handleGroupEvent (message: RawMessage) {
try { try {
// 群名片修改事件解析 任何都该判断 // 群名片修改事件解析 任何都该判断

View File

@@ -23,11 +23,15 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
this.logger = core.context.logger; 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 open (): void | Promise<void>;
abstract close (): void | Promise<void>; abstract close (): void | Promise<void>;
abstract reload (config: unknown): OB11NetworkReloadType | Promise<OB11NetworkReloadType>; abstract reload (config: unknown): OB11NetworkReloadType | Promise<OB11NetworkReloadType>;
get isActive (): boolean {
return this.isEnable;
}
} }

View File

@@ -5,6 +5,10 @@ import { OB11HttpServerAdapter } from './http-server';
export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter { export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
private sseClients: Response[] = []; private sseClients: Response[] = [];
override get isActive (): boolean {
return this.isEnable && (this.sseClients.length > 0 || super.isActive);
}
override async handleRequest (req: Request, res: Response) { override async handleRequest (req: Request, res: Response) {
if (req.path === '/_events') { if (req.path === '/_events') {
this.createSseSupport(req, res); 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>[] = []; const promises: Promise<void>[] = [];
this.sseClients.forEach((res) => { this.sseClients.forEach((res) => {
promises.push(new Promise<void>((resolve, reject) => { promises.push(new Promise<void>((resolve, reject) => {

View File

@@ -1,6 +1,6 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import express, { Express, NextFunction, Request, Response } from 'express'; 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 { OB11Response } from '@/napcat-onebot/action/OneBotAction';
import cors from 'cors'; import cors from 'cors';
import { HttpServerConfig } from '@/napcat-onebot/config/config'; import { HttpServerConfig } from '@/napcat-onebot/config/config';
@@ -8,13 +8,41 @@ import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import json5 from 'json5'; import json5 from 'json5';
import { isFinished } from 'on-finished'; import { isFinished } from 'on-finished';
import typeis from 'type-is'; 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> { export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
private app: Express | undefined; private app: Express | undefined;
private server: http.Server | 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 // 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 () { open () {
@@ -36,11 +64,24 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
this.isEnable = false; this.isEnable = false;
this.server?.close(); this.server?.close();
this.app = undefined; 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 () { private initializeServer () {
this.app = express(); this.app = express();
this.server = http.createServer(this.app); 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(cors());
this.app.use(express.urlencoded({ extended: true, limit: '5000mb' })); 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) { async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) {
let payload = req.body; let payload = req.body;
if (req.method === 'get') { if (req.method === 'get') {
@@ -152,6 +324,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
async reload (newConfig: HttpServerConfig) { async reload (newConfig: HttpServerConfig) {
const wasEnabled = this.isEnable; const wasEnabled = this.isEnable;
const oldPort = this.config.port; const oldPort = this.config.port;
const oldEnableWebsocket = this.config.enableWebsocket;
this.config = newConfig; this.config = newConfig;
if (newConfig.enable && !wasEnabled) { if (newConfig.enable && !wasEnabled) {
@@ -162,7 +335,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
return OB11NetworkReloadType.NetWorkClose; return OB11NetworkReloadType.NetWorkClose;
} }
if (oldPort !== newConfig.port) { if (oldPort !== newConfig.port || oldEnableWebsocket !== newConfig.enableWebsocket) {
this.close(); this.close();
if (newConfig.enable) { if (newConfig.enable) {
this.open(); this.open();

View File

@@ -21,7 +21,7 @@ export class OB11NetworkManager {
async emitEvent (event: OB11EmitEventContent) { async emitEvent (event: OB11EmitEventContent) {
return Promise.all(Array.from(this.adapters.values()).map(async adapter => { return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
if (adapter.isEnable) { if (adapter.isActive) {
return await adapter.onEvent(event); return await adapter.onEvent(event);
} }
})); }));
@@ -34,7 +34,7 @@ export class OB11NetworkManager {
async emitEventByName (names: string[], event: OB11EmitEventContent) { async emitEventByName (names: string[], event: OB11EmitEventContent) {
return Promise.all(names.map(async name => { return Promise.all(names.map(async name => {
const adapter = this.adapters.get(name); const adapter = this.adapters.get(name);
if (adapter && adapter.isEnable) { if (adapter && adapter.isActive) {
return await adapter.onEvent(event); return await adapter.onEvent(event);
} }
})); }));
@@ -43,29 +43,29 @@ export class OB11NetworkManager {
async emitEventByNames (map: Map<string, OB11EmitEventContent>) { async emitEventByNames (map: Map<string, OB11EmitEventContent>) {
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => { return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
const adapter = this.adapters.get(name); const adapter = this.adapters.get(name);
if (adapter && adapter.isEnable) { if (adapter && adapter.isActive) {
return await adapter.onEvent(event); 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); 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); this.registerAdapter(adapter);
await adapter.open(); await adapter.open();
} }
async closeSomeAdapters<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) { async closeSomeAdapters<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
for (const adapter of adaptersToClose) { for (const adapter of adaptersToClose) {
this.adapters.delete(adapter.name); this.adapters.delete(adapter.name);
await adapter.close(); await adapter.close();
} }
} }
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) { async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
for (const adapter of adaptersToClose) { for (const adapter of adaptersToClose) {
this.adapters.delete(adapter.name); this.adapters.delete(adapter.name);
if (adapter.isEnable) { if (adapter.isEnable) {
@@ -88,17 +88,21 @@ export class OB11NetworkManager {
this.adapters.clear(); this.adapters.clear();
} }
async readloadAdapter<T>(name: string, config: T) { async readloadAdapter<T> (name: string, config: T) {
const adapter = this.adapters.get(name); const adapter = this.adapters.get(name);
if (adapter) { if (adapter) {
await adapter.reload(config); 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))); 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 () { async getAllConfig () {
return Array.from(this.adapters.values()).map(adapter => adapter.config); return Array.from(this.adapters.values()).map(adapter => adapter.config);
} }

View File

@@ -33,6 +33,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string; private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map(); private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig; declare config: PluginConfig;
override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0;
}
constructor ( constructor (
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap 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}`); 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) { if (!this.isEnable) {
return; return;
} }
@@ -359,7 +363,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 重新加载插件 // 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() && const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath; plugin.pluginPath !== this.pluginPath;
if (isDirectory) { if (isDirectory) {
const dirname = path.basename(plugin.pluginPath); const dirname = path.basename(plugin.pluginPath);

View File

@@ -33,6 +33,10 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string; private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map(); private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig; declare config: PluginConfig;
override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0;
}
constructor ( constructor (
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) { ) {

View File

@@ -13,6 +13,10 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
private connection: WebSocket | null = null; private connection: WebSocket | null = null;
private heartbeatRef: NodeJS.Timeout | 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) { async onEvent<T extends OB11EmitEventContent> (event: T) {
if (this.connection && this.connection.readyState === WebSocket.OPEN) { if (this.connection && this.connection.readyState === WebSocket.OPEN) {
this.connection.send(JSON.stringify(event)); this.connection.send(JSON.stringify(event));

View File

@@ -21,6 +21,10 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
private heartbeatIntervalId: NodeJS.Timeout | null = null; private heartbeatIntervalId: NodeJS.Timeout | null = null;
wsClientWithEvent: WebSocket[] = []; wsClientWithEvent: WebSocket[] = [];
override get isActive (): boolean {
return this.isEnable && this.wsClientWithEvent.length > 0;
}
constructor ( constructor (
name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) { ) {
@@ -70,6 +74,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
if (EventIndex !== -1) { if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1); this.wsClientWithEvent.splice(EventIndex, 1);
} }
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
}); });
}); });
await this.wsClientsMutex.runExclusive(async () => { await this.wsClientsMutex.runExclusive(async () => {
@@ -77,6 +84,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.wsClientWithEvent.push(wsClient); this.wsClientWithEvent.push(wsClient);
} }
this.wsClients.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)); }).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.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
this.isEnable = true; this.isEnable = true;
if (this.config.heartInterval > 0) {
this.registerHeartBeat();
}
} }
async close () { async close () {
@@ -128,10 +135,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.logger.log('[OneBot] [WebSocket Server] Server Closed'); this.logger.log('[OneBot] [WebSocket Server] Server Closed');
} }
}); });
if (this.heartbeatIntervalId) { this.stopHeartbeat();
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
await this.wsClientsMutex.runExclusive(async () => { await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => { this.wsClients.forEach((wsClient) => {
wsClient.close(); 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.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => { this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => { this.wsClientWithEvent.forEach((wsClient) => {
@@ -153,6 +158,13 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}, this.config.heartInterval); }, this.config.heartInterval);
} }
private stopHeartbeat () {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
}
private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) { private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
if (!token || token.length === 0) return true;// 客户端未设置密钥 if (!token || token.length === 0) return true;// 客户端未设置密钥
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`); 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 (oldHeartbeatInterval !== newConfig.heartInterval) {
if (this.heartbeatIntervalId) { this.stopHeartbeat();
clearInterval(this.heartbeatIntervalId); if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) {
this.heartbeatIntervalId = null; this.startHeartbeat();
}
if (newConfig.heartInterval > 0 && this.isEnable) {
this.registerHeartBeat();
} }
return OB11NetworkReloadType.NetWorkReload; return OB11NetworkReloadType.NetWorkReload;
} }

View 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 主项目保持一致

View File

@@ -0,0 +1,2 @@
export * from './onebot';
export * from './satori';

View 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);
}
}

View 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);
}
}

View 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';

View 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());
}
}

View 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"
}
}

View 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"
]
}

View 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>;

View 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;
}
}

View 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}`);
}
}

View 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],
};
}
}

View 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);
}
}

View 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`,
};
}
}

View 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,
};
}
}

View 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);
}
}

View 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,
};
}
}

View 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,
''
);
}
}

View 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,
};
}
}

View 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 }]
);
}
}

View 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';

View 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,
};
}
}

View 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];
}
}

View 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);
}
}

View 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;
}
}

View 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];

View 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}`;
}
}

View 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);
}
}

View 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,
};
}
}

View 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`,
};
}
}

View 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 };

View 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';

View 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;
}
}

View 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;
}

View 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';

View File

@@ -0,0 +1 @@
export * from './xml';

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* XML 反转义
*/
static unescapeXml (str: string): string {
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/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 };

View 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';

View 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;
}
}

View 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 路由`);
}
}
}

View 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';

View 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}`);
}
}
}

View 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;
}
}
}

View 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"
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"*.ts",
"**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View 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;
};
}

View File

@@ -22,7 +22,7 @@ import fs from 'fs';
import os from 'os'; import os from 'os';
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services'; import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
import qrcode from 'napcat-qrcode/lib/main'; 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 { InitWebUi } from 'napcat-webui-backend/index';
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data'; import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
import { napCatVersion } from 'napcat-common/src/version'; import { napCatVersion } from 'napcat-common/src/version';
@@ -428,6 +428,7 @@ export async function NCoreInitShell () {
export class NapCatShell { export class NapCatShell {
readonly core: NapCatCore; readonly core: NapCatCore;
readonly context: InstanceContext; readonly context: InstanceContext;
public protocolManager?: ProtocolManager;
constructor ( constructor (
wrapper: WrapperNodeApi, wrapper: WrapperNodeApi,
@@ -452,11 +453,29 @@ export class NapCatShell {
async InitNapCat () { async InitNapCat () {
await this.core.initCore(); await this.core.initCore();
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
// 注册到 WebUiDataRuntime供调试功能使用 // 使用协议管理器初始化所有协议
WebUiDataRuntime.setOneBotContext(oneBotAdapter); this.protocolManager = new ProtocolManager(this.core, this.context, this.context.pathWrapper);
oneBotAdapter.InitOneBot() WebUiDataRuntime.setProtocolManager(this.protocolManager);
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
// 初始化所有协议
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);
});
}
} }
} }

View File

@@ -1,34 +1,34 @@
{ {
"name": "napcat-shell", "name": "napcat-shell",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./index.ts"
}, },
"exports": { "./*": {
".": { "import": "./*"
"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"
} }
},
"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"
}
} }

View File

@@ -46,6 +46,8 @@ const ShellBaseConfig = (source_map: boolean = false) =>
'@/napcat-pty': resolve(__dirname, '../napcat-pty'), '@/napcat-pty': resolve(__dirname, '../napcat-pty'),
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'), '@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
'@/image-size': resolve(__dirname, '../image-size'), '@/image-size': resolve(__dirname, '../image-size'),
'@/napcat-satori': resolve(__dirname, '../napcat-satori'),
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
}, },
}, },
build: { build: {

View 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);
};

View File

@@ -16,6 +16,7 @@ const LoginRuntime: LoginRuntimeType = {
}, },
QQVersion: 'unknown', QQVersion: 'unknown',
OneBotContext: null, OneBotContext: null,
SatoriContext: null,
onQQLoginStatusChange: async (status: boolean) => { onQQLoginStatusChange: async (status: boolean) => {
LoginRuntime.QQLoginStatus = status; LoginRuntime.QQLoginStatus = status;
}, },
@@ -25,6 +26,9 @@ const LoginRuntime: LoginRuntimeType = {
NapCatHelper: { NapCatHelper: {
onOB11ConfigChanged: async () => { onOB11ConfigChanged: async () => {
},
onSatoriConfigChanged: async () => {
}, },
onQuickLoginRequested: async () => { onQuickLoginRequested: async () => {
return { result: false, message: '' }; return { result: false, message: '' };
@@ -34,8 +38,8 @@ const LoginRuntime: LoginRuntimeType = {
}, },
NapCatVersion: napCatVersion, NapCatVersion: napCatVersion,
WebUiConfigQuickFunction: async () => { WebUiConfigQuickFunction: async () => {
}, },
ProtocolManager: null,
}; };
export const WebUiDataRuntime = { export const WebUiDataRuntime = {
setWorkingEnv (env: NapCatCoreWorkingEnv): void { setWorkingEnv (env: NapCatCoreWorkingEnv): void {
@@ -163,4 +167,28 @@ export const WebUiDataRuntime = {
getOneBotContext (): any | null { getOneBotContext (): any | null {
return LoginRuntime.OneBotContext; 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;
},
}; };

View 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 };

View File

@@ -16,6 +16,7 @@ import { FileRouter } from './File';
import { WebUIConfigRouter } from './WebUIConfig'; import { WebUIConfigRouter } from './WebUIConfig';
import { UpdateNapCatRouter } from './UpdateNapCat'; import { UpdateNapCatRouter } from './UpdateNapCat';
import DebugRouter from '@/napcat-webui-backend/src/api/Debug'; import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
import { ProtocolConfigRouter } from './ProtocolConfig';
const router = Router(); const router = Router();
@@ -44,5 +45,7 @@ router.use('/WebUIConfig', WebUIConfigRouter);
router.use('/UpdateNapCat', UpdateNapCatRouter); router.use('/UpdateNapCat', UpdateNapCatRouter);
// router:调试相关路由 // router:调试相关路由
router.use('/Debug', DebugRouter); router.use('/Debug', DebugRouter);
// router:协议配置相关路由
router.use('/ProtocolConfig', ProtocolConfigRouter);
export { router as ALLRouter }; export { router as ALLRouter };

View File

@@ -1,4 +1,44 @@
import type { OneBotConfig } from '@/napcat-webui-backend/src/onebot/config'; 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 { export interface LoginListItem {
uin: string; uin: string;
uid: string; uid: string;
@@ -48,13 +88,16 @@ export interface LoginRuntimeType {
onWebUiTokenChange: (token: string) => Promise<void>; onWebUiTokenChange: (token: string) => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>; WebUiConfigQuickFunction: () => Promise<void>;
OneBotContext: any | null; // OneBot 上下文,用于调试功能 OneBotContext: any | null; // OneBot 上下文,用于调试功能
SatoriContext: any | null; // Satori 上下文
NapCatHelper: { NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>; onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>; onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onSatoriConfigChanged: (config: SatoriConfig) => Promise<void>;
QQLoginList: string[]; QQLoginList: string[];
NewQQLoginList: LoginListItem[]; NewQQLoginList: LoginListItem[];
}; };
NapCatVersion: string; NapCatVersion: string;
ProtocolManager: any | null;
} }
export default {}; export default {};

View File

@@ -24,6 +24,7 @@ const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'));
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager')); const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'));
const LogsPage = lazy(() => import('@/pages/dashboard/logs')); const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
const NetworkPage = lazy(() => import('@/pages/dashboard/network')); const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const ProtocolPage = lazy(() => import('@/pages/dashboard/protocol'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal')); const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
function App () { function App () {
@@ -42,7 +43,7 @@ function App () {
); );
} }
function AuthChecker ({ children }: { children: React.ReactNode }) { function AuthChecker ({ children }: { children: React.ReactNode; }) {
const { isAuth } = useAuth(); const { isAuth } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -68,6 +69,7 @@ function AppRoutes () {
<Route path='/' element={<IndexPage />}> <Route path='/' element={<IndexPage />}>
<Route index element={<DashboardIndexPage />} /> <Route index element={<DashboardIndexPage />} />
<Route path='network' element={<NetworkPage />} /> <Route path='network' element={<NetworkPage />} />
<Route path='protocol' element={<ProtocolPage />} />
<Route path='config' element={<ConfigPage />} /> <Route path='config' element={<ConfigPage />} />
<Route path='logs' element={<LogsPage />} /> <Route path='logs' element={<LogsPage />} />
<Route path='debug' element={<DebugPage />}> <Route path='debug' element={<DebugPage />}>

View File

@@ -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;

View File

@@ -2,9 +2,9 @@ import GenericForm, { random_token } from './generic_form';
import type { Field } from './generic_form'; import type { Field } from './generic_form';
export interface HTTPServerFormProps { export interface HTTPServerFormProps {
data?: OneBotConfig['network']['httpServers'][0] data?: OneBotConfig['network']['httpServers'][0];
onClose: () => void onClose: () => void;
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void> onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>;
} }
type HTTPServerFormType = OneBotConfig['network']['httpServers']; type HTTPServerFormType = OneBotConfig['network']['httpServers'];
@@ -20,7 +20,7 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
host: '127.0.0.1', host: '127.0.0.1',
port: 3000, port: 3000,
enableCors: true, enableCors: true,
enableWebsocket: true, enableWebsocket: false,
messagePostFormat: 'array', messagePostFormat: 'array',
token: random_token(16), token: random_token(16),
debug: false, debug: false,

View File

@@ -2,11 +2,11 @@ import GenericForm, { random_token } from './generic_form';
import type { Field } from './generic_form'; import type { Field } from './generic_form';
export interface HTTPServerSSEFormProps { export interface HTTPServerSSEFormProps {
data?: OneBotConfig['network']['httpSseServers'][0] data?: OneBotConfig['network']['httpSseServers'][0];
onClose: () => void onClose: () => void;
onSubmit: ( onSubmit: (
data: OneBotConfig['network']['httpSseServers'][0] data: OneBotConfig['network']['httpSseServers'][0]
) => Promise<void> ) => Promise<void>;
} }
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers']; type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers'];
@@ -22,7 +22,7 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
host: '127.0.0.1', host: '127.0.0.1',
port: 3000, port: 3000,
enableCors: true, enableCors: true,
enableWebsocket: true, enableWebsocket: false,
messagePostFormat: 'array', messagePostFormat: 'array',
token: random_token(16), token: random_token(16),
debug: false, debug: false,

View File

@@ -2,6 +2,7 @@ import { Modal, ModalContent, ModalHeader } from '@heroui/modal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useConfig from '@/hooks/use-config'; import useConfig from '@/hooks/use-config';
import useDialog from '@/hooks/use-dialog';
import HTTPClientForm from './http_client'; import HTTPClientForm from './http_client';
import HTTPServerForm from './http_server'; import HTTPServerForm from './http_server';
@@ -31,23 +32,57 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
) => { ) => {
const { isOpen, onOpenChange, field, data } = props; const { isOpen, onOpenChange, field, data } = props;
const { createNetworkConfig, updateNetworkConfig } = useConfig(); const { createNetworkConfig, updateNetworkConfig } = useConfig();
const dialog = useDialog();
const isCreate = !data; const isCreate = !data;
const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => { const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => {
try { const saveData = async (dataToSave: OneBotConfig['network'][typeof field][0]) => {
if (isCreate) { try {
await createNetworkConfig(field, data); if (isCreate) {
} else { await createNetworkConfig(field, dataToSave);
await updateNetworkConfig(field, data); } 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}`); if (['httpServers', 'httpSseServers', 'websocketServers'].includes(field)) {
const serverData = data as any;
throw error; 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) => { const renderFormComponent = (onClose: () => void) => {

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -4,6 +4,7 @@ import {
LuFolderOpen, LuFolderOpen,
LuInfo, LuInfo,
LuLayoutDashboard, LuLayoutDashboard,
LuPlug,
LuSettings, LuSettings,
LuSignal, LuSignal,
LuTerminal, LuTerminal,
@@ -34,6 +35,11 @@ export const siteConfig = {
icon: <LuSignal className='w-5 h-5' />, icon: <LuSignal className='w-5 h-5' />,
href: '/network', href: '/network',
}, },
{
label: '协议配置',
icon: <LuPlug className='w-5 h-5' />,
href: '/protocol',
},
{ {
label: '其他配置', label: '其他配置',
icon: <LuSettings className='w-5 h-5' />, icon: <LuSettings className='w-5 h-5' />,

View File

@@ -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;

View 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;

View 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>
</>
);
}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"useUnknownInCatchVariables": true, "useUnknownInCatchVariables": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"useDefineForClassFields": true, "useDefineForClassFields": true,