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
100 changed files with 6907 additions and 1065 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

@@ -21,4 +21,4 @@ export interface IStatusHelperSubscription {
on (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
off (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
emit (event: 'statusUpdate', status: SystemStatus): boolean;
}
}

View File

@@ -513,10 +513,7 @@
},
"9.9.26-44498": {
"appid": 537337416,
"offset": "0x1809C2810",
"qua": "V1_WIN_NQ_9.9.26_44498_GW_B"
},
"9.9.26-44725": {
"appid": 537337569,
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
}
}

View File

@@ -87,10 +87,6 @@
"send": "23B0330",
"recv": "0957648"
},
"3.2.21-42086-arm64": {
"send": "3D6D98C",
"recv": "14797C8"
},
"3.2.21-42086-x64": {
"send": "5B42CF0",
"recv": "2FDA6F0"
@@ -150,9 +146,5 @@
"9.9.26-44498-x64": {
"send": "0A1051C",
"recv": "1D3BC0D"
},
"9.9.26-44725-x64": {
"send": "0A18D0C",
"recv": "1D4BF0D"
}
}

View File

@@ -658,9 +658,5 @@
"9.9.26-44498-x64": {
"send": "2CDAE40",
"recv": "2CDE3C0"
},
"9.9.26-44725-x64": {
"send": "2CEBB20",
"recv": "2CEF0A0"
}
}

View File

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

View File

@@ -125,7 +125,7 @@ export class NapCatCore {
container.bind(TypedEventEmitter).toConstantValue(this.event);
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
container.bind(ServiceClass).toSelf();
// console.log(`Registering service handler for: ${serviceName}`);
//console.log(`Registering service handler for: ${serviceName}`);
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
const serviceInstance = container.get(ServiceClass);
return serviceInstance.handler(seq, hex_data);
@@ -177,10 +177,8 @@ export class NapCatCore {
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
// 下线通知
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`;
this.context.logger.logError(tips);
this.context.logger.logError('[KickedOffLine] [' + Info.tipsTitle + '] ' + Info.tipsDesc);
this.selfInfo.online = false;
this.event.emit('KickedOffLine', tips);
};
msgListener.onRecvMsg = (msgs) => {
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));

View File

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

View File

@@ -1,7 +1,6 @@
import { TypedEventEmitter } from './typeEvent';
export interface AppEvents {
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; };
KickedOffLine: string;
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number };
}
export const appEvent = new TypedEventEmitter<AppEvents>();

View File

@@ -73,8 +73,6 @@ async function copyAll () {
process.env.NAPCAT_QQ_PACKAGE_INFO_PATH = path.join(TARGET_DIR, 'package.json');
process.env.NAPCAT_QQ_VERSION_CONFIG_PATH = path.join(TARGET_DIR, 'config.json');
process.env.NAPCAT_DISABLE_PIPE = '1';
// 禁用重启和多进程功能
process.env.NAPCAT_DISABLE_MULTI_PROCESS = '1';
process.env.NAPCAT_WORKDIR = TARGET_DIR;
// 开发环境使用固定密钥
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';

View File

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

View File

@@ -1,33 +1,33 @@
{
"name": "napcat-framework",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"build": "vite build",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
"name": "napcat-framework",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"build": "vite build",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./index.ts"
},
"exports": {
".": {
"import": "./index.ts"
},
"./*": {
"import": "./*"
}
},
"dependencies": {
"napcat-core": "workspace:*",
"napcat-common": "workspace:*",
"napcat-onebot": "workspace:*",
"napcat-webui-backend": "workspace:*",
"napcat-vite": "workspace:*",
"napcat-qrcode": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
"./*": {
"import": "./*"
}
},
"dependencies": {
"napcat-core": "workspace:*",
"napcat-common": "workspace:*",
"napcat-protocol": "workspace:*",
"napcat-webui-backend": "workspace:*",
"napcat-vite": "workspace:*",
"napcat-qrcode": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -86,7 +86,6 @@ import { GetGroupMemberList } from './group/GetGroupMemberList';
import { GetGroupFileUrl } from '@/napcat-onebot/action/file/GetGroupFileUrl';
import { GetPacketStatus } from '@/napcat-onebot/action/packet/GetPacketStatus';
import { GetCredentials } from './system/GetCredentials';
import { SetRestart } from './system/SetRestart';
import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign';
import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain';
import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely';
@@ -267,7 +266,6 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
new GetGroupFileSystemInfo(obContext, core),
new GetGroupFilesByFolder(obContext, core),
new GetPacketStatus(obContext, core),
new SetRestart(obContext, core),
new GroupPoke(obContext, core),
new FriendPoke(obContext, core),
new GetUserStatus(obContext, core),

View File

@@ -81,7 +81,7 @@ export const ActionName = {
CanSendRecord: 'can_send_record',
GetStatus: 'get_status',
GetVersionInfo: 'get_version_info',
Reboot: 'set_restart',
// Reboot : 'set_restart',
CleanCache: 'clean_cache',
Exit: 'bot_exit',
// go-cqhttp

View File

@@ -1,14 +0,0 @@
import { ActionName } from '@/napcat-onebot/action/router';
import { OneBotAction } from '../OneBotAction';
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
export class SetRestart extends OneBotAction<void, void> {
override actionName = ActionName.Reboot;
async _handle () {
const result = await WebUiDataRuntime.requestRestartProcess();
if (!result.result) {
throw new Error(result.message || '进程重启失败');
}
}
}

View File

@@ -387,7 +387,6 @@ export class NapCatOneBot11Adapter {
}
};
msgListener.onKickedOffLine = async (kick) => {
WebUiDataRuntime.setQQLoginStatus(false);
const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc);
this.networkManager
.emitEvent(event)

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,13 +22,14 @@ import fs from 'fs';
import os from 'os';
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
import qrcode from 'napcat-qrcode/lib/main';
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
import { ProtocolManager } from 'napcat-protocol';
import { InitWebUi } from 'napcat-webui-backend/index';
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
import { napCatVersion } from 'napcat-common/src/version';
import { NodeIO3MiscListener } from 'napcat-core/listeners/NodeIO3MiscListener';
import { sleep } from 'napcat-common/src/helper';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { connectToNamedPipe } from './pipe';
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
@@ -127,13 +128,10 @@ async function handleLogin (
const loginListener = new NodeIKernelLoginListener();
loginListener.onUserLoggedIn = (userid: string) => {
const tips = `当前账号(${userid})已登录,无法重复登录`;
logger.logError(tips);
WebUiDataRuntime.setQQLoginError(tips);
logger.logError(`当前账号(${userid})已登录,无法重复登录`);
};
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
context.isLogined = true;
WebUiDataRuntime.setQQLoginStatus(true);
inner_resolve({
uid: loginResult.uid,
uin: loginResult.uin,
@@ -172,16 +170,13 @@ async function handleLogin (
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
if (errType === 1 && errCode === 3) {
// 二维码过期刷新
WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新');
}
loginService.getQRCodePicture();
}
};
loginListener.onLoginFailed = (...args) => {
const errInfo = JSON.stringify(args);
logger.logError('[Core] [Login] Login Error , ErrInfo: ', errInfo);
WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`);
logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
};
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
@@ -189,29 +184,17 @@ async function handleLogin (
return await selfInfo;
}
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
// 注册刷新二维码回调
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
loginService.getQRCodePicture();
});
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
return await new Promise((resolve) => {
if (uin) {
logger.log('正在快速登录 ', uin);
loginService.quickLoginWithUin(uin).then(res => {
if (res.loginErrorInfo.errMsg) {
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: res.loginErrorInfo.errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
}
resolve({ result: true, message: '' });
}).catch((e) => {
logger.logError(e);
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '快速登录发生错误' });
});
} else {
@@ -226,7 +209,6 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
.then(result => {
if (result.loginErrorInfo.errMsg) {
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
if (!context.isLogined) loginService.getQRCodePicture();
}
})
@@ -342,9 +324,9 @@ export async function NCoreInitShell () {
// 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger);
// if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') {
// await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
// }
if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') {
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
}
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
@@ -446,6 +428,7 @@ export async function NCoreInitShell () {
export class NapCatShell {
readonly core: NapCatCore;
readonly context: InstanceContext;
public protocolManager?: ProtocolManager;
constructor (
wrapper: WrapperNodeApi,
@@ -470,14 +453,29 @@ export class NapCatShell {
async InitNapCat () {
await this.core.initCore();
// 监听下线通知并同步到 WebUI
this.core.event.on('KickedOffLine', (tips: string) => {
WebUiDataRuntime.setQQLoginError(tips);
});
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
// 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
oneBotAdapter.InitOneBot()
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
// 使用协议管理器初始化所有协议
this.protocolManager = new ProtocolManager(this.core, this.context, this.context.pathWrapper);
WebUiDataRuntime.setProtocolManager(this.protocolManager);
// 初始化所有协议
await this.protocolManager.initAllProtocols();
// 获取适配器并注册到 WebUiDataRuntime
const onebotAdapter = this.protocolManager.getOneBotAdapter();
const satoriAdapter = this.protocolManager.getSatoriAdapter();
if (onebotAdapter) {
WebUiDataRuntime.setOneBotContext(onebotAdapter.getRawAdapter());
}
if (satoriAdapter) {
WebUiDataRuntime.setSatoriContext(satoriAdapter.getRawAdapter());
WebUiDataRuntime.setOnSatoriConfigChanged(async (newConfig) => {
const prev = satoriAdapter.getConfigLoader().configData;
await this.protocolManager!.reloadProtocolConfig('satori', prev, newConfig);
});
}
}
}

View File

@@ -1,319 +1,2 @@
import { NCoreInitShell } from './base';
import { NapCatPathWrapper } from '@/napcat-common/src/path';
import { LogWrapper } from '@/napcat-core/helper/log';
import { connectToNamedPipe } from './pipe';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
import path from 'path';
import { fileURLToPath } from 'url';
// ES 模块中获取 __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 环境变量配置
const ENV = {
isWorkerProcess: process.env['NAPCAT_WORKER_PROCESS'] === '1',
isMultiProcessDisabled: process.env['NAPCAT_DISABLE_MULTI_PROCESS'] === '1',
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
} as const;
// 初始化日志
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);
// 进程管理器和当前 Worker 进程引用
let processManager: IProcessManager | null = null;
let currentWorker: IWorkerProcess | null = null;
let isElectron = false;
/**
* 获取进程类型名称(用于日志)
*/
function getProcessTypeName (): string {
return isElectron ? 'UtilityProcess' : 'Fork';
}
/**
* 获取 Worker 脚本路径
*/
function getWorkerScriptPath (): string {
return __filename.endsWith('.mjs')
? path.join(__dirname, 'napcat.mjs')
: path.join(__dirname, 'napcat.js');
}
/**
* 检查进程是否存在
*/
function isProcessAlive (pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* 强制终止进程
*/
function forceKillProcess (pid: number): void {
try {
process.kill(pid, 'SIGKILL');
logger.log(`[NapCat] [Process] 已强制终止进程 ${pid}`);
} catch (error) {
logger.logError(`[NapCat] [Process] 强制终止进程失败:`, error);
}
}
/**
* 重启 Worker 进程
*/
export async function restartWorker (): Promise<void> {
logger.log('[NapCat] [Process] 正在重启Worker进程...');
if (!currentWorker) {
logger.logWarn('[NapCat] [Process] 没有运行中的Worker进程');
await startWorker();
return;
}
const workerPid = currentWorker.pid;
logger.log(`[NapCat] [Process] 准备关闭Worker进程PID: ${workerPid}`);
// 1. 通知旧进程准备重启(旧进程会自行退出)
currentWorker.postMessage({ type: 'restart-prepare' });
// 2. 等待进程退出(最多 5 秒,给更多时间让进程自行清理)
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
logger.logWarn('[NapCat] [Process] Worker进程未在 5 秒内退出,尝试发送强制关闭信号');
currentWorker?.postMessage({ type: 'shutdown' });
// 再等待 2 秒
setTimeout(() => {
logger.logWarn('[NapCat] [Process] Worker进程仍未退出尝试 kill');
currentWorker?.kill();
resolve();
}, 2000);
}, 5000);
currentWorker?.once('exit', () => {
clearTimeout(timeout);
logger.log('[NapCat] [Process] Worker进程已正常退出');
resolve();
});
});
// 3. 二次确认进程是否真的被终止(兜底检查)
if (workerPid) {
logger.log(`[NapCat] [Process] 检查进程 ${workerPid} 是否已终止...`);
if (isProcessAlive(workerPid)) {
logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉(兜底)`);
forceKillProcess(workerPid);
// 等待 1 秒后再次检查
await new Promise(resolve => setTimeout(resolve, 1000));
if (isProcessAlive(workerPid)) {
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`);
} else {
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已被强制终止`);
}
} else {
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已确认终止`);
}
}
// 4. 等待 3 秒后启动新进程
logger.log('[NapCat] [Process] Worker进程已关闭等待 3 秒后启动新进程...');
await new Promise(resolve => setTimeout(resolve, 3000));
// 5. 启动新进程
await startWorker();
logger.log('[NapCat] [Process] Worker进程重启完成');
}
/**
* 启动 Worker 进程
*/
async function startWorker (): Promise<void> {
if (!processManager) {
throw new Error('进程管理器未初始化');
}
const workerScript = getWorkerScriptPath();
const processType = getProcessTypeName();
const child = processManager.createWorker(workerScript, [], {
env: {
...process.env,
NAPCAT_WORKER_PROCESS: '1',
},
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
});
currentWorker = child;
// 监听标准输出(直接转发)
if (child.stdout) {
child.stdout.on('data', (data: Buffer) => {
process.stdout.write(data);
});
}
// 监听标准错误(直接转发)
if (child.stderr) {
child.stderr.on('data', (data: Buffer) => {
process.stderr.write(data);
});
}
// 监听子进程消息
child.on('message', (msg: unknown) => {
logger.log(`[NapCat] [${processType}] 收到Worker消息:`, msg);
// 处理重启请求
if (typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === 'restart') {
logger.log(`[NapCat] [${processType}] 收到重启请求正在重启Worker进程...`);
restartWorker().catch(e => {
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
});
}
});
// 监听子进程退出
child.on('exit', (code: unknown) => {
const exitCode = typeof code === 'number' ? code : 0;
if (exitCode !== 0) {
logger.logError(`[NapCat] [${processType}] Worker进程退出退出码: ${exitCode}`);
} else {
logger.log(`[NapCat] [${processType}] Worker进程正常退出`);
}
});
child.on('spawn', () => {
logger.log(`[NapCat] [${processType}] Worker进程已生成`);
});
}
/**
* 启动 Master 进程
*/
async function startMasterProcess (): Promise<void> {
const processType = getProcessTypeName();
logger.log(`[NapCat] [${processType}] Master进程启动PID: ${process.pid}`);
// 连接命名管道(可通过环境变量禁用)
if (!ENV.isPipeDisabled) {
await connectToNamedPipe(logger).catch(e =>
logger.logError('命名管道连接失败', e)
);
} else {
logger.log(`[NapCat] [${processType}] 命名管道已禁用 (NAPCAT_DISABLE_PIPE=1)`);
}
// 启动 Worker 进程
await startWorker();
// 优雅关闭处理
const shutdown = (signal: string) => {
logger.log(`[NapCat] [Process] 收到${signal}信号,正在关闭...`);
if (currentWorker) {
currentWorker.postMessage({ type: 'shutdown' });
setTimeout(() => {
currentWorker?.kill();
process.exit(0);
}, 1000);
} else {
process.exit(0);
}
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
/**
* 启动 Worker 进程(子进程入口)
*/
async function startWorkerProcess (): Promise<void> {
if (!processManager) {
throw new Error('进程管理器未初始化');
}
const processType = getProcessTypeName();
logger.log(`[NapCat] [${processType}] Worker进程启动PID: ${process.pid}`);
// 监听来自父进程的消息
processManager.onParentMessage((msg: unknown) => {
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
if (msg.type === 'restart-prepare') {
logger.log(`[NapCat] [${processType}] 收到重启准备信号,正在主动退出...`);
setTimeout(() => {
process.exit(0);
}, 100);
} else if (msg.type === 'shutdown') {
logger.log(`[NapCat] [${processType}] 收到关闭信号,正在退出...`);
process.exit(0);
}
}
});
// 注册重启进程函数到 WebUI
WebUiDataRuntime.setRestartProcessCall(async () => {
try {
const success = processManager!.sendToParent({ type: 'restart' });
if (success) {
return { result: true, message: '进程重启请求已发送' };
} else {
return { result: false, message: '无法与主进程通信' };
}
} catch (e) {
logger.logError('[NapCat] [Process] 发送重启请求失败:', e);
return {
result: false,
message: '发送重启请求失败: ' + (e as Error).message
};
}
});
// 启动 NapCat 核心
await NCoreInitShell();
}
/**
* 主入口
*/
async function main (): Promise<void> {
// 单进程模式:直接启动核心
if (ENV.isMultiProcessDisabled) {
logger.log('[NapCat] [SingleProcess] 多进程模式已禁用,直接启动核心');
await NCoreInitShell();
return;
}
// 多进程模式:初始化进程管理器
const result = await createProcessManager();
processManager = result.manager;
isElectron = result.isElectron;
logger.log(`[NapCat] [Process] 检测到 ${isElectron ? 'Electron' : 'Node.js'} 环境`);
// 根据进程类型启动
if (ENV.isWorkerProcess) {
await startWorkerProcess();
} else {
await startMasterProcess();
}
}
// 启动应用
main().catch((e: Error) => {
logger.logError('[NapCat] [Process] 启动失败:', e);
process.exit(1);
});
NCoreInitShell();

View File

@@ -1,34 +1,34 @@
{
"name": "napcat-shell",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"build": "vite build",
"build:dev": "vite build --mode development",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
"name": "napcat-shell",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"build": "vite build",
"build:dev": "vite build --mode development",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./index.ts"
},
"exports": {
".": {
"import": "./index.ts"
},
"./*": {
"import": "./*"
}
},
"dependencies": {
"napcat-core": "workspace:*",
"napcat-common": "workspace:*",
"napcat-onebot": "workspace:*",
"napcat-webui-backend": "workspace:*",
"napcat-qrcode": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1",
"napcat-vite": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
"./*": {
"import": "./*"
}
},
"dependencies": {
"napcat-core": "workspace:*",
"napcat-common": "workspace:*",
"napcat-protocol": "workspace:*",
"napcat-webui-backend": "workspace:*",
"napcat-qrcode": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1",
"napcat-vite": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -1,178 +0,0 @@
import type { Readable } from 'stream';
import type { fork as forkType } from 'child_process';
// 扩展 Process 类型以支持 parentPort
declare global {
namespace NodeJS {
interface Process {
parentPort?: {
on (event: 'message', listener: (e: { data: unknown; }) => void): void;
postMessage (message: unknown): void;
};
}
}
}
/**
* 统一的进程接口
*/
export interface IWorkerProcess {
readonly pid: number | undefined;
readonly stdout: Readable | null;
readonly stderr: Readable | null;
postMessage (message: unknown): void;
kill (): boolean;
on (event: string, listener: (...args: unknown[]) => void): void;
once (event: string, listener: (...args: unknown[]) => void): void;
}
/**
* 进程创建选项
*/
export interface ProcessOptions {
env: NodeJS.ProcessEnv;
stdio: 'pipe' | 'ignore' | 'inherit' | Array<'pipe' | 'ignore' | 'inherit' | 'ipc'>;
}
/**
* 进程管理器接口
*/
export interface IProcessManager {
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess;
onParentMessage (handler: (message: unknown) => void): void;
sendToParent (message: unknown): boolean;
}
/**
* Electron utilityProcess 包装器
*/
class ElectronProcessManager implements IProcessManager {
private utilityProcess: {
fork (modulePath: string, args: string[], options: unknown): unknown;
};
constructor (utilityProcess: { fork (modulePath: string, args: string[], options: unknown): unknown; }) {
this.utilityProcess = utilityProcess;
}
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess {
const child: any = this.utilityProcess.fork(modulePath, args, options);
return {
pid: child.pid as number | undefined,
stdout: child.stdout as Readable | null,
stderr: child.stderr as Readable | null,
postMessage (message: unknown): void {
child.postMessage(message);
},
kill (): boolean {
return child.kill() as boolean;
},
on (event: string, listener: (...args: unknown[]) => void): void {
child.on(event, listener);
},
once (event: string, listener: (...args: unknown[]) => void): void {
child.once(event, listener);
},
};
}
onParentMessage (handler: (message: unknown) => void): void {
if (process.parentPort) {
process.parentPort.on('message', (e: { data: unknown; }) => {
handler(e.data);
});
}
}
sendToParent (message: unknown): boolean {
if (process.parentPort) {
process.parentPort.postMessage(message);
return true;
}
return false;
}
}
/**
* Node.js child_process 包装器
*/
class NodeProcessManager implements IProcessManager {
private forkFn: typeof forkType;
constructor (forkFn: typeof forkType) {
this.forkFn = forkFn;
}
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess {
const child = this.forkFn(modulePath, args, options as any);
return {
pid: child.pid,
stdout: child.stdout,
stderr: child.stderr,
postMessage (message: unknown): void {
if (child.send) {
child.send(message as any);
}
},
kill (): boolean {
return child.kill();
},
on (event: string, listener: (...args: unknown[]) => void): void {
child.on(event, listener);
},
once (event: string, listener: (...args: unknown[]) => void): void {
child.once(event, listener);
},
};
}
onParentMessage (handler: (message: unknown) => void): void {
process.on('message', (message: unknown) => {
handler(message);
});
}
sendToParent (message: unknown): boolean {
if (process.send) {
process.send(message as any);
return true;
}
return false;
}
}
/**
* 检测运行环境并创建对应的进程管理器
*/
export async function createProcessManager (): Promise<{
manager: IProcessManager;
isElectron: boolean;
}> {
const isElectron = typeof process.versions['electron'] !== 'undefined';
if (isElectron) {
// @ts-ignore - electron 运行时存在但类型声明可能缺失
const electron = await import('electron');
return {
manager: new ElectronProcessManager(electron.utilityProcess),
isElectron: true,
};
} else {
const { fork } = await import('child_process');
return {
manager: new NodeProcessManager(fork),
isElectron: false,
};
}
}

View File

@@ -11,7 +11,6 @@ import react from '@vitejs/plugin-react-swc';
const external = [
'ws',
'express',
'electron'
];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
@@ -47,6 +46,8 @@ const ShellBaseConfig = (source_map: boolean = false) =>
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
'@/image-size': resolve(__dirname, '../image-size'),
'@/napcat-satori': resolve(__dirname, '../napcat-satori'),
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
},
},
build: {

View File

@@ -1,24 +1,24 @@
{
"name": "napcat-vite",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"_build": "vite build"
},
"exports": {
".": {
"import": "./index.ts"
"name": "napcat-vite",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"build": "vite build"
},
"./*": {
"import": "./*"
"exports": {
".": {
"import": "./index.ts"
},
"./*": {
"import": "./*"
}
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -1,21 +0,0 @@
import type { Request, Response } from 'express';
import { WebUiDataRuntime } from '../helper/Data';
import { sendError, sendSuccess } from '../utils/response';
/**
* 重启进程处理器
* POST /api/Process/Restart
*/
export async function RestartProcessHandler (_req: Request, res: Response) {
try {
const result = await WebUiDataRuntime.requestRestartProcess();
if (result.result) {
return sendSuccess(res, { message: result.message || '进程重启请求已发送' });
} else {
return sendError(res, result.message || '进程重启失败');
}
} catch (e) {
return sendError(res, '重启进程时发生错误: ' + (e as Error).message);
}
}

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

@@ -1,9 +1,9 @@
import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { WebUiConfig } from '@/napcat-webui-backend/index';
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index';
// 获取QQ登录二维码
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
@@ -27,17 +27,9 @@ export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
// 获取QQ登录状态
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => {
// 从 OneBot 上下文获取实时的 selfInfo.online 状态
const oneBotContext = WebUiDataRuntime.getOneBotContext();
const selfInfo = oneBotContext?.core?.selfInfo;
const isOnline = selfInfo?.online;
const qqLoginStatus = WebUiDataRuntime.getQQLoginStatus();
// 必须同时满足已登录且在线online 必须明确为 true
const isLogin = qqLoginStatus && isOnline === true;
const data = {
isLogin,
isLogin: WebUiDataRuntime.getQQLoginStatus(),
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
loginError: WebUiDataRuntime.getQQLoginError(),
};
return sendSuccess(res, data);
};
@@ -96,15 +88,3 @@ export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
await WebUiConfig.UpdateAutoLoginAccount(uin);
return sendSuccess(res, null);
};
// 刷新QQ登录二维码
export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
// 判断是否已经登录
if (WebUiDataRuntime.getQQLoginStatus()) {
// 已经登录
return sendError(res, 'QQ Is Logined');
}
// 刷新二维码
await WebUiDataRuntime.refreshQRCode();
return sendSuccess(res, null);
};

View File

@@ -14,35 +14,32 @@ const LoginRuntime: LoginRuntimeType = {
uin: '',
nick: '',
},
QQLoginError: '',
QQVersion: 'unknown',
OneBotContext: null,
SatoriContext: null,
onQQLoginStatusChange: async (status: boolean) => {
LoginRuntime.QQLoginStatus = status;
},
onWebUiTokenChange: async (_token: string) => {
},
onRefreshQRCode: async () => {
// 默认空实现,由 shell 注册真实回调
},
NapCatHelper: {
onOB11ConfigChanged: async () => {
},
onSatoriConfigChanged: async () => {
},
onQuickLoginRequested: async () => {
return { result: false, message: '' };
},
onRestartProcessRequested: async () => {
return { result: false, message: '重启功能未初始化' };
},
QQLoginList: [],
NewQQLoginList: [],
},
NapCatVersion: napCatVersion,
WebUiConfigQuickFunction: async () => {
},
ProtocolManager: null,
};
export const WebUiDataRuntime = {
setWorkingEnv (env: NapCatCoreWorkingEnv): void {
@@ -171,32 +168,27 @@ export const WebUiDataRuntime = {
return LoginRuntime.OneBotContext;
},
setRestartProcessCall (func: () => Promise<{ result: boolean; message: string; }>): void {
LoginRuntime.NapCatHelper.onRestartProcessRequested = func;
setSatoriContext (context: any): void {
LoginRuntime.SatoriContext = context;
},
requestRestartProcess: async function () {
return await LoginRuntime.NapCatHelper.onRestartProcessRequested();
getSatoriContext (): any | null {
return LoginRuntime.SatoriContext;
},
setQQLoginError (error: string): void {
LoginRuntime.QQLoginError = error;
setOnSatoriConfigChanged (func: LoginRuntimeType['NapCatHelper']['onSatoriConfigChanged']): void {
LoginRuntime.NapCatHelper.onSatoriConfigChanged = func;
},
getQQLoginError (): string {
return LoginRuntime.QQLoginError;
setSatoriConfig: function (config) {
return LoginRuntime.NapCatHelper.onSatoriConfigChanged(config);
} as LoginRuntimeType['NapCatHelper']['onSatoriConfigChanged'],
setProtocolManager (pm: any): void {
LoginRuntime.ProtocolManager = pm;
},
setRefreshQRCodeCallback (func: () => Promise<void>): void {
LoginRuntime.onRefreshQRCode = func;
},
getRefreshQRCodeCallback (): () => Promise<void> {
return LoginRuntime.onRefreshQRCode;
},
refreshQRCode: async function () {
LoginRuntime.QQLoginError = '';
await LoginRuntime.onRefreshQRCode();
getProtocolManager (): any | null {
return LoginRuntime.ProtocolManager;
},
};

View File

@@ -1,9 +0,0 @@
import { Router } from 'express';
import { RestartProcessHandler } from '../api/Process';
const router = Router();
// POST /api/Process/Restart - 重启进程
router.post('/Restart', RestartProcessHandler);
export { router as ProcessRouter };

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

@@ -9,7 +9,6 @@ import {
getQQLoginInfoHandler,
getAutoLoginAccountHandler,
setAutoLoginAccountHandler,
QQRefreshQRcodeHandler,
} from '@/napcat-webui-backend/src/api/QQLogin';
const router = Router();
@@ -29,7 +28,5 @@ router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
// router:设置自动登录QQ账号
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
// router:刷新QQ登录二维码
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
export { router as QQLoginRouter };

View File

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

View File

@@ -1,4 +1,44 @@
import type { OneBotConfig } from '@/napcat-webui-backend/src/onebot/config';
export interface SatoriConfig {
network: {
websocketServers: SatoriWebSocketServerConfig[];
httpServers: SatoriHttpServerConfig[];
webhookClients: SatoriWebHookClientConfig[];
};
platform: string;
selfId: string;
}
export interface SatoriWebSocketServerConfig {
name: string;
enable: boolean;
host: string;
port: number;
token: string;
path: string;
debug: boolean;
heartInterval: number;
}
export interface SatoriHttpServerConfig {
name: string;
enable: boolean;
host: string;
port: number;
token: string;
path: string;
debug: boolean;
}
export interface SatoriWebHookClientConfig {
name: string;
enable: boolean;
url: string;
token: string;
debug: boolean;
}
export interface LoginListItem {
uin: string;
uid: string;
@@ -43,21 +83,21 @@ export interface LoginRuntimeType {
QQQRCodeURL: string;
QQLoginUin: string;
QQLoginInfo: SelfInfo;
QQLoginError: string;
QQVersion: string;
onQQLoginStatusChange: (status: boolean) => Promise<void>;
onWebUiTokenChange: (token: string) => Promise<void>;
onRefreshQRCode: () => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>;
OneBotContext: any | null; // OneBot 上下文,用于调试功能
SatoriContext: any | null; // Satori 上下文
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
onSatoriConfigChanged: (config: SatoriConfig) => Promise<void>;
QQLoginList: string[];
NewQQLoginList: LoginListItem[];
};
NapCatVersion: string;
ProtocolManager: any | null;
}
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 LogsPage = lazy(() => import('@/pages/dashboard/logs'));
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const ProtocolPage = lazy(() => import('@/pages/dashboard/protocol'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
function App () {
@@ -42,7 +43,7 @@ function App () {
);
}
function AuthChecker ({ children }: { children: React.ReactNode }) {
function AuthChecker ({ children }: { children: React.ReactNode; }) {
const { isAuth } = useAuth();
const navigate = useNavigate();
@@ -68,6 +69,7 @@ function AppRoutes () {
<Route path='/' element={<IndexPage />}>
<Route index element={<DashboardIndexPage />} />
<Route path='network' element={<NetworkPage />} />
<Route path='protocol' element={<ProtocolPage />} />
<Route path='config' element={<ConfigPage />} />
<Route path='logs' element={<LogsPage />} />
<Route path='debug' element={<DebugPage />}>

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

@@ -52,7 +52,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
onNativeClose();
}}
classNames={{
backdrop: 'z-[99] backdrop-blur-sm',
backdrop: 'z-[99]',
wrapper: 'z-[99]',
}}
{...rest}

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

@@ -1,70 +1,22 @@
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner';
import { QRCodeSVG } from 'qrcode.react';
import { IoAlertCircle, IoRefresh } from 'react-icons/io5';
interface QrCodeLoginProps {
qrcode: string;
loginError?: string;
onRefresh?: () => void;
qrcode: string
}
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode, loginError, onRefresh }) => {
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
return (
<div className='flex flex-col items-center'>
{loginError
? (
<div className='flex flex-col items-center py-4'>
<div className='w-full flex justify-center mb-6'>
<div className='p-4 bg-danger-50 rounded-full'>
<IoAlertCircle className='text-danger' size={64} />
</div>
</div>
<div className='text-center space-y-2 px-4'>
<div className='text-xl font-bold text-danger'></div>
<div className='text-default-600 text-sm leading-relaxed max-w-[300px]'>
{loginError}
</div>
</div>
{onRefresh && (
<Button
className='mt-8 min-w-[160px]'
variant='solid'
color='primary'
size='lg'
startContent={<IoRefresh />}
onPress={onRefresh}
>
</Button>
)}
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
{!qrcode && (
<div className='absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center'>
<Spinner color='primary' />
</div>
)
: (
<>
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
{!qrcode && (
<div className='absolute left-0 top-0 right-0 bottom-0 bg-white dark:bg-zinc-900 bg-opacity-90 backdrop-blur-sm flex items-center justify-center z-10'>
<Spinner color='primary' />
</div>
)}
<QRCodeSVG size={180} value={qrcode || ' '} />
</div>
<div className='mt-5 text-center text-default-500 text-sm'>使QQ或者TIM扫描上方二维码</div>
{onRefresh && qrcode && (
<Button
className='mt-4'
variant='flat'
color='primary'
size='sm'
startContent={<IoRefresh />}
onPress={onRefresh}
>
</Button>
)}
</>
)}
<QRCodeSVG size={180} value={qrcode} />
</div>
<div className='mt-5 text-center'>使QQ或者TIM扫描上方二维码</div>
</div>
);
};

View File

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

View File

@@ -1,14 +0,0 @@
import { serverRequest } from '@/utils/request';
export default class ProcessManager {
/**
* 重启进程
*/
public static async restartProcess () {
const data = await serverRequest.post<ServerResponse<{ message: string; }>>(
'/Process/Restart'
);
return data.data.data;
}
}

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

@@ -1,4 +1,3 @@
import { AxiosRequestConfig } from 'axios';
import { serverRequest } from '@/utils/request';
import { SelfInfo } from '@/types/user';
@@ -21,8 +20,8 @@ export default class QQManager {
public static async checkQQLoginStatus () {
const data = await serverRequest.post<
ServerResponse<{
isLogin: string;
qrcodeurl: string;
isLogin: string
qrcodeurl: string
}>
>('/QQLogin/CheckLoginStatus');
@@ -31,20 +30,16 @@ export default class QQManager {
public static async checkQQLoginStatusWithQrcode () {
const data = await serverRequest.post<
ServerResponse<{ qrcodeurl: string; isLogin: string; loginError?: string; }>
ServerResponse<{ qrcodeurl: string; isLogin: string }>
>('/QQLogin/CheckLoginStatus');
return data.data.data;
}
public static async refreshQRCode () {
await serverRequest.post<ServerResponse<null>>('/QQLogin/RefreshQRcode');
}
public static async getQQLoginQrcode () {
const data = await serverRequest.post<
ServerResponse<{
qrcode: string;
qrcode: string
}>
>('/QQLogin/GetQQLoginQrcode');
@@ -72,11 +67,9 @@ export default class QQManager {
});
}
public static async getQQLoginInfo (config?: AxiosRequestConfig) {
public static async getQQLoginInfo () {
const data = await serverRequest.post<ServerResponse<SelfInfo>>(
'/QQLogin/GetQQLoginInfo',
{},
config
'/QQLogin/GetQQLoginInfo'
);
return data.data.data;
}

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

@@ -3,7 +3,7 @@ import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { MdMenu, MdMenuOpen } from 'react-icons/md';
import { useLocation, useNavigate } from 'react-router-dom';
@@ -11,17 +11,14 @@ import { useLocation, useNavigate } from 'react-router-dom';
import key from '@/const/key';
import errorFallbackRender from '@/components/error_fallback';
import PageLoading from '@/components/page_loading';
// import PageLoading from "@/components/Loading/PageLoading";
import SideBar from '@/components/sidebar';
import useAuth from '@/hooks/auth';
import useDialog from '@/hooks/use-dialog';
import type { MenuItem } from '@/config/site';
import { siteConfig } from '@/config/site';
import QQManager from '@/controllers/qq_manager';
import ProcessManager from '@/controllers/process_manager';
import { waitForBackendReady } from '@/utils/process_utils';
const menus: MenuItem[] = siteConfig.navItems;
@@ -51,67 +48,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
const [openSideBar, setOpenSideBar] = useLocalStorage(key.sideBarOpen, true);
const [b64img] = useLocalStorage(key.backgroundImage, '');
const navigate = useNavigate();
const { isAuth, revokeAuth } = useAuth();
const dialog = useDialog();
const isOnlineRef = useRef(true);
const [isRestarting, setIsRestarting] = useState(false);
// 定期检查 QQ 在线状态,掉线时弹窗提示
useEffect(() => {
if (!isAuth) return;
const checkOnlineStatus = async () => {
const currentPath = location.pathname;
if (currentPath === '/qq_login' || currentPath === '/web_login') return;
try {
const info = await QQManager.getQQLoginInfo();
if (info?.online === false && isOnlineRef.current === true) {
isOnlineRef.current = false;
dialog.confirm({
title: '账号已离线',
content: '您的 QQ 账号已下线,请重新登录。',
confirmText: '重新登陆',
cancelText: '退出账户',
onConfirm: async () => {
setIsRestarting(true);
try {
await ProcessManager.restartProcess();
} catch (_e) {
// 忽略错误,因为后端正在重启关闭连接
}
// 轮询探测后端是否恢复
await waitForBackendReady(
15000, // 15秒超时
() => {
setIsRestarting(false);
window.location.reload();
},
() => {
setIsRestarting(false);
dialog.alert({
title: '启动超时',
content: '后端在 15 秒内未响应,请检查 NapCat 运行日志或手动重启。',
});
}
);
},
onCancel: () => {
revokeAuth();
navigate('/web_login');
},
});
} else if (info?.online === true) {
isOnlineRef.current = true;
}
} catch (_e) {
// 忽略请求错误
}
};
const timer = setInterval(checkOnlineStatus, 5000);
checkOnlineStatus();
return () => clearInterval(timer);
}, [isAuth, location.pathname]);
const { isAuth } = useAuth();
const checkIsQQLogin = async () => {
try {
const result = await QQManager.checkQQLoginStatus();
@@ -149,7 +86,6 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
backgroundPosition: 'center',
}}
>
<PageLoading loading={isRestarting} />
<SideBar
items={menus}
open={openSideBar}

View File

@@ -1,7 +1,6 @@
import { Input } from '@heroui/input';
import { Button } from '@heroui/button';
import { useRequest } from 'ahooks';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
@@ -9,11 +8,8 @@ import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading';
import QQManager from '@/controllers/qq_manager';
import ProcessManager from '@/controllers/process_manager';
import { waitForBackendReady } from '@/utils/process_utils';
const LoginConfigCard = () => {
const [isRestarting, setIsRestarting] = useState(false);
const {
data: quickLoginData,
loading: quickLoginLoading,
@@ -57,35 +53,6 @@ const LoginConfigCard = () => {
}
};
const onRestartProcess = async () => {
setIsRestarting(true);
try {
const result = await ProcessManager.restartProcess();
toast.success(result.message || '进程重启请求已发送');
// 轮询探测后端是否恢复
const isReady = await waitForBackendReady(
30000, // 30秒超时
() => {
setIsRestarting(false);
toast.success('进程重启完成');
},
() => {
setIsRestarting(false);
toast.error('后端在 30 秒内未响应,请检查 NapCat 运行日志');
}
);
if (!isReady) {
setIsRestarting(false);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`进程重启失败: ${msg}`);
setIsRestarting(false);
}
};
useEffect(() => {
reset();
}, [quickLoginData]);
@@ -115,22 +82,6 @@ const LoginConfigCard = () => {
isSubmitting={isSubmitting || quickLoginLoading}
refresh={onRefresh}
/>
<div className='flex-shrink-0 w-full mt-6 pt-6 border-t border-divider'>
<div className='mb-3 text-sm text-default-600'></div>
<Button
color='warning'
variant='flat'
onPress={onRestartProcess}
isLoading={isRestarting}
isDisabled={isRestarting}
fullWidth
>
{isRestarting ? '正在重启进程...' : '重启进程'}
</Button>
<div className='mt-2 text-xs text-default-500'>
Worker 3
</div>
</div>
</>
);
};

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

@@ -16,39 +16,14 @@ import type { QQItem } from '@/components/quick_login';
import { ThemeSwitch } from '@/components/theme-switch';
import QQManager from '@/controllers/qq_manager';
import useDialog from '@/hooks/use-dialog';
import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react';
const parseLoginError = (errorStr: string) => {
if (errorStr.startsWith('登录失败: ')) {
const jsonPart = errorStr.substring('登录失败: '.length);
try {
const parsed = JSON.parse(jsonPart);
if (Array.isArray(parsed) && parsed[1]) {
const info = parsed[1];
const codeStr = info.serverErrorCode ? ` (错误码: ${info.serverErrorCode})` : '';
return `${info.message || errorStr}${codeStr}`;
}
} catch (e) {
// 忽略解析错误
}
}
return errorStr;
};
export default function QQLoginPage () {
const navigate = useNavigate();
const dialog = useDialog();
const [uinValue, setUinValue] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [qrcode, setQrcode] = useState<string>('');
const [loginError, setLoginError] = useState<string>('');
const lastErrorRef = useRef<string>('');
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
const [refresh, setRefresh] = useState<boolean>(false);
const firstLoad = useRef<boolean>(true);
@@ -86,20 +61,6 @@ export default function QQLoginPage () {
navigate('/', { replace: true });
} else {
setQrcode(data.qrcodeurl);
if (data.loginError && data.loginError !== lastErrorRef.current) {
lastErrorRef.current = data.loginError;
setLoginError(data.loginError);
const friendlyMsg = parseLoginError(data.loginError);
dialog.alert({
title: '登录失败',
content: friendlyMsg,
confirmText: '确定',
});
} else if (!data.loginError) {
lastErrorRef.current = '';
setLoginError('');
}
}
} catch (error) {
const msg = (error as Error).message;
@@ -138,18 +99,6 @@ export default function QQLoginPage () {
setUinValue(e.target.value);
};
const onRefreshQRCode = async () => {
try {
lastErrorRef.current = '';
setLoginError('');
await QQManager.refreshQRCode();
toast.success('已发送刷新请求');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新二维码失败: ${msg}`);
}
};
useEffect(() => {
const timer = setInterval(() => {
onUpdateQrCode();
@@ -210,11 +159,7 @@ export default function QQLoginPage () {
/>
</Tab>
<Tab key='qrcode' title='扫码登录'>
<QrCodeLogin
loginError={parseLoginError(loginError)}
qrcode={qrcode}
onRefresh={onRefreshQRCode}
/>
<QrCodeLogin qrcode={qrcode} />
</Tab>
</Tabs>
<Button

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;

View File

@@ -1,35 +0,0 @@
import QQManager from '@/controllers/qq_manager';
/**
* 轮询等待后端进程恢复
* @param maxWaitTime 最大等待时间,单位毫秒
* @param onSuccess 成功回调
* @param onTimeout 超时回调
*/
export async function waitForBackendReady (
maxWaitTime: number = 15000,
onSuccess?: () => void,
onTimeout?: () => void
): Promise<boolean> {
const startTime = Date.now();
return new Promise<boolean>((resolve) => {
const timer = setInterval(async () => {
try {
// 尝试请求后端,设置一个较短的请求超时避免挂起
await QQManager.getQQLoginInfo({ timeout: 500 });
// 如果能走到这一步说明请求成功了
clearInterval(timer);
onSuccess?.();
resolve(true);
} catch (_e) {
// 如果请求失败(后端没起来),检查是否超时
if (Date.now() - startTime > maxWaitTime) {
clearInterval(timer);
onTimeout?.();
resolve(false);
}
}
}, 500); // 每 500ms 探测一次
});
}

View File

@@ -1,6 +1,6 @@
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
// import viteCompression from 'vite-plugin-compression';
import viteCompression from 'vite-plugin-compression';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import tsconfigPaths from 'vite-tsconfig-paths';
@@ -13,7 +13,7 @@ export default defineConfig(({ mode }) => {
plugins: [
react(),
tsconfigPaths(),
ViteImageOptimizer({}),
ViteImageOptimizer({})
],
base: '/webui/',
server: {

547
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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