Compare commits

..

44 Commits

Author SHA1 Message Date
手瓜一十雪
3b7ca1a08f Remove flex-wrap from tabList class in ExtensionPage
The 'flex-wrap' class was removed from the tabList classNames in the ExtensionPage component, likely to prevent tab items from wrapping onto multiple lines and to maintain a single-line tab layout.
2026-01-30 19:35:46 +08:00
手瓜一十雪
57f3c4dd31 Support nested innerPacketMsg in SendMsgBase
Adds handling for innerPacketMsg arrays within uploadReturnData, allowing nested packet messages to be included in the result. This change ensures that all relevant inner messages are processed and returned.
2026-01-30 19:25:01 +08:00
时瑾
5b20ebb7b0 fix: webui 随机token仅生成不会被url编码的随机字符 (#1565)
* fix: webui 随机token仅生成不会被url编码的随机字符

* fix: 移除调试模块中的encodeURIComponent
2026-01-30 18:51:13 +08:00
手瓜一十雪
3a3eaeec7c Add UploadForwardMsgV2 support for multi-message forwarding
Introduces UploadForwardMsgV2 transformer and integrates it into the message sending flow to support forwarding multiple messages with custom action commands. Updates related interfaces and logic to handle UUIDs and nested forwarded messages, improving flexibility and extensibility for message forwarding operations.
2026-01-30 18:47:45 +08:00
手瓜一十雪
b0cc7b6ee5 Update Vite aliases in napcat-schema config
Expanded the alias configuration in vite.config.ts to include specific paths for napcat-onebot, napcat-common, napcat-schema, and napcat-core. This improves module resolution and import clarity within the project.
2026-01-30 14:41:46 +08:00
冷曦
e5108c0427 增加判断插件启用状态显示配置提示 (#1562) 2026-01-30 14:31:56 +08:00
手瓜一十雪
927797f3d5 Add SSL certificate management to WebUI config
Introduces backend API endpoints and frontend UI for managing SSL certificates, including viewing status, uploading, and deleting cert/key files. Adds a new SSL configuration tab in the dashboard, allowing users to enable HTTPS by providing PEM-formatted certificate and key, with changes taking effect after restart.
2026-01-30 14:28:47 +08:00
手瓜一十雪
72e01f8c84 Change default host to IPv6 (::) in config schema
Updated the default value of the 'host' field in WebUiConfigSchema from '0.0.0.0' (IPv4) to '::' (IPv6) to support IPv6 by default.
2026-01-30 14:11:53 +08:00
手瓜一十雪
c38b98a0c4 Add plugin WebUI extension page and API routing support
Introduces a plugin router registry for registering plugin-specific API routes, static resources, and extension pages. Updates the plugin manager and context to expose the router, and implements backend and frontend support for serving and displaying plugin extension pages in the WebUI. Also adds a demo extension page and static resource to the builtin plugin.
2026-01-30 12:48:24 +08:00
手瓜一十雪
05d27e86ce Add local plugin import functionality
Implemented backend API and frontend UI for importing local plugin zip files. The backend now supports file uploads via a new /Plugin/Import endpoint using multer, and the frontend provides a button to upload and import plugins directly from the dashboard.

Prompt to register plugin manager if not loaded

Renames plugin_develop.ts to plugin-develop.ts for consistency. Updates the plugin import handler to prompt the user to register the plugin manager if it is not loaded, improving user experience and error handling.
2026-01-30 11:58:43 +08:00
手瓜一十雪
40409a3841 Refactor plugin manager with modular loader and types
Refactors the plugin manager by extracting configuration, loader, and type definitions into separate modules under the 'plugin' directory. Introduces a new PluginLoader class for scanning and loading plugins, and updates the main manager to use modularized logic and improved type safety. This change improves maintainability, separation of concerns, and extensibility for plugin management.
2026-01-30 11:50:22 +08:00
手瓜一十雪
65bae6b57a Introduce NapCat Protocol and adapter management
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Added the new napcat-protocol package with protocol config, event, API, and network management modules. Introduced napcat-adapter package to unify protocol adapter management, replacing direct OneBot usage in framework and shell. Updated napcat-framework and napcat-shell to use NapCatAdapterManager for protocol initialization and registration. Adjusted dependencies and Vite configs to include new packages.
2026-01-29 22:14:55 +08:00
手瓜一十雪
0b6afb66d9 Add session proxy with event wrapper integration
Introduces a session proxy mechanism in napcat-core that intercepts service method calls and routes them through an event wrapper when enabled via the NAPCAT_SESSION_PROXY environment variable. Adds helper functions for creating proxied sessions and updates NapCatCore to support the new proxy integration.
2026-01-29 21:58:27 +08:00
手瓜一十雪
52be000fdd Update napcat-types to 0.0.11 and improve config API
Upgraded napcat-types dependency from 0.0.10 to 0.0.11. Refactored the API URL config option to use the new signature supporting reactivity directly, improving code clarity and maintainability.
2026-01-29 21:01:52 +08:00
手瓜一十雪
55ce5bcfd3 Bump napcat-types version to 0.0.11 2026-01-29 21:00:11 +08:00
手瓜一十雪
29888cb38b Remove undici dependency from lockfile
The undici package and its references have been removed from pnpm-lock.yaml, indicating it is no longer required as a dependency.
2026-01-29 20:58:14 +08:00
手瓜一十雪
6ea4c9ec65 Remove undici dependency and implement proxy download
Replaces the use of the undici library for HTTP downloads with proxy support by implementing a custom httpDownloadWithProxy function using Node.js http and tls modules. The undici dependency is removed from package.json, reducing external dependencies and improving compatibility.
2026-01-29 20:54:48 +08:00
手瓜一十雪
4bec3aa597 Reapply "Add image download proxy support to OneBot"
This reverts commit 38c320d2c9.
2026-01-29 20:40:19 +08:00
手瓜一十雪
38c320d2c9 Revert "Add image download proxy support to OneBot"
This reverts commit 0779628be5.
2026-01-29 20:39:07 +08:00
手瓜一十雪
76cbd8a1c1 Add crash protection for worker process restarts
Implements a mechanism to track recent worker process crashes and prevent excessive restarts. If the worker crashes more than 3 times within 10 seconds, the main process will exit to avoid crash loops.
2026-01-29 20:38:35 +08:00
手瓜一十雪
0779628be5 Add image download proxy support to OneBot
Introduces an 'imageDownloadProxy' config option to OneBot, allowing image downloads via a specified HTTP proxy. Updates the file download logic in napcat-common to use the undici library for proxy support, and propagates the new config through backend, frontend, and type definitions. Also adds undici as a dependency.
2026-01-29 20:32:01 +08:00
手瓜一十雪
34ca919c4d Add reactive plugin config UI with SSE support
Introduces a reactive plugin configuration system with dynamic schema updates via server-sent events (SSE). Adds new fields and controller interfaces to the plugin manager, updates the built-in plugin to demonstrate dynamic config fields, and implements backend and frontend logic for real-time config UI updates. Also updates napcat-types to 0.0.10.
2026-01-29 20:18:34 +08:00
手瓜一十雪
b1b357347b Remove unused installedVersion prop from PluginStoreCard
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
The installedVersion prop was declared but not used in PluginStoreCard. This commit removes it to clean up the component's props.
2026-01-29 17:13:30 +08:00
手瓜一十雪
129d63f66e Add mirror management and selection UI
Introduces backend API and router for mirror management, including latency testing and custom mirror setting. Adds frontend components and controllers for mirror selection, speed testing, and integration into system info and plugin store pages, allowing users to select and test download/list mirrors interactively.
2026-01-29 17:11:59 +08:00
手瓜一十雪
699b46acbd Improve plugin status handling and dirname lookup
Enhanced setPluginStatus to support enabling/disabling plugins by both package name and dirname, improving robustness when plugins are not loaded. Also removed redundant directory name matching logic from findDirnameById in the web UI backend.

Register plugin after installation in PluginStore

Adds logic to immediately register a plugin with the plugin manager after installation, both in the standard and SSE install handlers. This ensures newly installed plugins are available without requiring a restart or manual reload.

Refactor plugin path handling in plugin manager

Simplifies plugin directory and data path resolution by using pluginPath from the plugin context instead of fileId. Streamlines plugin uninstall and reload logic, removing redundant file system scans and improving code clarity.

Refactor plugin API to use package id and improve UX

Standardized plugin management APIs and frontend to use 'id' (package name) instead of ambiguous 'name' or 'filename'. Added support for a 'plugin' display field in package.json and improved plugin store UI to show install/update status. Refactored backend and frontend logic for enabling, disabling, uninstalling, and configuring plugins to use consistent identifiers, and enhanced type definitions and documentation for better maintainability.
2026-01-29 16:42:15 +08:00
手瓜一十雪
7f05aee11d Add manual plugin manager registration support
Introduces backend and frontend logic to manually register the plugin manager if not already loaded. Adds a new API endpoint and frontend UI prompt to guide users through registration after plugin installation when necessary.
2026-01-29 15:44:26 +08:00
手瓜一十雪
542036f46e Refactor type build: inline external types, simplify scripts
Removed custom build scripts for copying and inlining types, consolidating all post-build logic into a single enhanced post-build.mjs script. The new script processes .d.ts files, inlines external module types, updates imports, and copies necessary files to dist, eliminating the need for external-shims and simplifying the build process. Updated package.json scripts accordingly.

Refactor type inlining: remove shims, auto-extract types

Removed external-shims.d.ts and its references, replacing manual shims with an automated script that extracts type definitions from node_modules. Updated build scripts, dependencies, and test files to support the new inlining process. The inline-types.mjs script now scans for external imports, generates inline type files, and rewrites imports as import type, eliminating the need for hand-written shims.

Add type inlining script and update build process

Introduced a new script (inline-types.mjs) to inline external type dependencies into the dist directory, updated the build process to use this script, and removed the now-unnecessary external-shims.d.ts from the copy-dist script. Added a test file to verify inlined types, updated dependencies to include ts-morph, and adjusted package.json and pnpm-lock.yaml accordingly.
2026-01-29 15:27:46 +08:00
pohgxz
b958e9e803 修复 OpenAPI 导出的相应接口缺失 stream 字段 2026-01-29 14:14:11 +08:00
冷曦
73fcfb5900 修复下载插件后插件列表显示开启 (#1560)
下载后插件应该为禁用状态,但是前端显示启用状态
2026-01-29 13:12:54 +08:00
手瓜一十雪
adabc4da46 Improve schema parsing and error handling in API debug tools
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Enhances the TypeBox schema parser to better handle deep nesting, circular references, and union truncation, and adds error handling for schema parsing and default value generation in the OneBot API debug UI. Updates the display component to show clear messages for circular or truncated schemas, and improves robustness in HTTP debug command execution. Also synchronizes the ParsedSchema type in the Zod utility for consistency.
2026-01-28 16:07:23 +08:00
手瓜一十雪
bf073b544b Refactor schema ID handling and reduce parse depth
Changed MAX_PARSE_DEPTH from 10 to 6 to limit nesting. Improved schema ID retrieval to only use $id if present, and added a utility to collect all $id values in a schema for better circular reference detection. Updated font file AaCute.woff.
2026-01-28 16:01:43 +08:00
手瓜一十雪
a71219062a Enhance TypeBox schema parsing with circular ref detection
Added detection and handling for circular references and excessive nesting in TypeBox schema parsing and default value generation. Introduced depth limits and a visited set to prevent infinite recursion, and updated the parseTypeBox and generateDefaultFromTypeBox functions accordingly.
2026-01-28 15:59:27 +08:00
手瓜一十雪
001fe01ace Add plugin logger interface and update builtin plugin
Introduces a PluginLogger interface and injects a plugin-specific logger into the plugin context for consistent logging. Refactors the builtin plugin to use the new logger instead of direct console calls. Updates napcat-types to version 0.0.9 in dependencies and lock files.
2026-01-28 15:07:06 +08:00
手瓜一十雪
0aa0c44634 Refactor plugin identification to use package name and dirname
Updated plugin manager and API to distinguish between plugin package name and directory name (dirname) for more robust plugin identification and path resolution. Adjusted context creation, status management, and API handlers to use package name for identification and dirname for filesystem operations. Also replaced console.error with console.log in builtin plugin for consistency.
2026-01-28 15:02:47 +08:00
手瓜一十雪
93126e514e Refactor builtin plugin for improved type safety
Replaced generic 'any' types with 'NetworkAdapterConfig' for better type safety in getVersionInfo and sendMessage functions. Removed redundant comments and improved code clarity. Changed a warning log to a standard log for config load failures.
2026-01-28 14:54:43 +08:00
手瓜一十雪
1ae10ae0c6 Refactor plugin to use context actions and update deps
Refactored the builtin plugin to pass actions and adapterName explicitly from context instead of relying on a global variable. Updated napcat-types dependency to version 0.0.8.
2026-01-28 14:42:44 +08:00
手瓜一十雪
4b693bf6e2 Refactor plugin manager to support only directory plugins
Removed support for single-file plugins in OB11PluginMangerAdapter, simplifying plugin identification to use directory names as unique IDs. Updated related logic in the backend API to align with this change, ensuring consistent plugin management and status handling.
2026-01-28 14:38:11 +08:00
手瓜一十雪
574c257591 Refactor plugin manager to support only directory plugins
Removed support for single-file plugins in OB11PluginMangerAdapter, simplifying plugin identification to use directory names as unique IDs. Updated related logic in the backend API to align with this change, ensuring consistent plugin management and status handling.
2026-01-28 14:18:44 +08:00
手瓜一十雪
d680328762 Add config UI and persistence to builtin plugin
Introduces a configuration UI schema and persistent config storage for the napcat-plugin-builtin. The plugin now loads and saves its configuration, supports dynamic prefix and reply toggling, and updates dependencies to napcat-types v0.0.6.
2026-01-28 14:13:48 +08:00
手瓜一十雪
d711cdecaf Add plugin config management to backend and frontend
Introduces a unified plugin configuration schema and API in the backend, with endpoints for getting and setting plugin config. Updates the frontend to support plugin config modals, including a UI for editing plugin settings. Also adds support for uninstalling plugins with optional data cleanup, and updates dependencies to use napcat-types@0.0.5.
2026-01-28 13:56:40 +08:00
手瓜一十雪
c5f1792009 Add plugin data management API and frontend support
Introduced backend API endpoints for managing plugin configuration data, including listing, reading, saving, and deleting plugin data files. Added a new PluginData API module and registered related routes. Updated the frontend plugin manager controller to support these new API methods and corresponding TypeScript interfaces. Also fixed minor typos in documentation prompts.
2026-01-28 13:39:36 +08:00
手瓜一十雪
a5e705e6a4 Fix typo in Windows deployment instructions
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Corrected a grammatical error in the Windows one-click deployment section in both the default prompt and release note prompt files.
2026-01-27 23:01:16 +08:00
手瓜一十雪
007f1db339 Update OpenAPI schema copy path in release workflow
The workflow now copies openapi.json from the dist directory instead of the package root, ensuring the built schema is used during the release process.
2026-01-27 23:00:14 +08:00
手瓜一十雪
008fb39f8f Reduce WebSocket maxPayload to 50 MB
Lowered the maxPayload limit from 1 GB to 50 MB in both the WebSocket client and server adapters to improve resource management and prevent excessively large payloads.
2026-01-27 22:56:27 +08:00
105 changed files with 7364 additions and 1354 deletions

View File

@@ -2,7 +2,7 @@
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化一键部署方案
我们提供了轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载

View File

@@ -31,7 +31,7 @@
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化一键部署方案
我们提供了轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载

View File

@@ -50,7 +50,7 @@ jobs:
- name: Copy OpenAPI Schema
run: |
mkdir -p napcat-docs/src/api/${{ env.version }}
cp packages/napcat-schema/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
cp packages/napcat-schema/dist/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
echo "OpenAPI schema copied to napcat-docs/src/api/${{ env.version }}/openapi.json"
- name: Commit and Push

View File

@@ -1,24 +0,0 @@
{
"name": "napcat-types",
"version": "0.0.1",
"type": "module",
"types": "./napcat-types/index.d.ts",
"files": [
"**/*"
],
"dependencies": {
"@types/node": "^22.10.7",
"@types/express": "^4.17.21",
"@types/ws": "^8.5.12",
"@types/cors": "^2.8.17",
"@types/multer": "^1.4.12",
"@types/winston": "^2.4.4",
"@types/yaml": "^1.9.7",
"@types/ip": "^1.1.3"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public",
"tag": "latest"
}
}

View File

@@ -0,0 +1,176 @@
import { InstanceContext, NapCatCore } from 'napcat-core';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import { NapCatOneBot11Adapter } from 'napcat-onebot';
import { NapCatProtocolAdapter } from 'napcat-protocol';
// 协议适配器类型
export type ProtocolAdapterType = 'onebot11' | 'napcat-protocol';
// 协议适配器接口
export interface IProtocolAdapter {
readonly name: string;
readonly enabled: boolean;
init (): Promise<void>;
close (): Promise<void>;
}
// 协议适配器包装器
class OneBotAdapterWrapper implements IProtocolAdapter {
readonly name = 'onebot11';
private adapter: NapCatOneBot11Adapter;
constructor (adapter: NapCatOneBot11Adapter) {
this.adapter = adapter;
}
get enabled (): boolean {
return true; // OneBot11 默认启用
}
async init (): Promise<void> {
await this.adapter.InitOneBot();
}
async close (): Promise<void> {
await this.adapter.networkManager.closeAllAdapters();
}
getAdapter (): NapCatOneBot11Adapter {
return this.adapter;
}
}
// NapCat Protocol 适配器包装器
class NapCatProtocolAdapterWrapper implements IProtocolAdapter {
readonly name = 'napcat-protocol';
private adapter: NapCatProtocolAdapter;
constructor (adapter: NapCatProtocolAdapter) {
this.adapter = adapter;
}
get enabled (): boolean {
return this.adapter.isEnabled();
}
async init (): Promise<void> {
await this.adapter.initProtocol();
}
async close (): Promise<void> {
await this.adapter.close();
}
getAdapter (): NapCatProtocolAdapter {
return this.adapter;
}
}
// 协议适配器管理器
export class NapCatAdapterManager {
private core: NapCatCore;
private context: InstanceContext;
private pathWrapper: NapCatPathWrapper;
// 协议适配器实例
private onebotAdapter: OneBotAdapterWrapper | null = null;
private napcatProtocolAdapter: NapCatProtocolAdapterWrapper | null = null;
// 所有已注册的适配器
private adapters: Map<string, IProtocolAdapter> = new Map();
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.pathWrapper = pathWrapper;
}
// 初始化所有协议适配器
async initAdapters (): Promise<void> {
this.context.logger.log('[AdapterManager] 开始初始化协议适配器...');
// 初始化 OneBot11 适配器 (默认启用)
try {
const onebot = new NapCatOneBot11Adapter(this.core, this.context, this.pathWrapper);
this.onebotAdapter = new OneBotAdapterWrapper(onebot);
this.adapters.set('onebot11', this.onebotAdapter);
await this.onebotAdapter.init();
this.context.logger.log('[AdapterManager] OneBot11 适配器初始化完成');
} catch (e) {
this.context.logger.logError('[AdapterManager] OneBot11 适配器初始化失败:', e);
}
// 初始化 NapCat Protocol 适配器 (默认关闭,需要配置启用)
try {
const napcatProtocol = new NapCatProtocolAdapter(this.core, this.context, this.pathWrapper);
this.napcatProtocolAdapter = new NapCatProtocolAdapterWrapper(napcatProtocol);
this.adapters.set('napcat-protocol', this.napcatProtocolAdapter);
if (this.napcatProtocolAdapter.enabled) {
await this.napcatProtocolAdapter.init();
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器初始化完成');
} else {
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器未启用,跳过初始化');
}
} catch (e) {
this.context.logger.logError('[AdapterManager] NapCat Protocol 适配器初始化失败:', e);
}
this.context.logger.log(`[AdapterManager] 协议适配器初始化完成,已加载 ${this.adapters.size} 个适配器`);
}
// 获取 OneBot11 适配器
getOneBotAdapter (): NapCatOneBot11Adapter | null {
return this.onebotAdapter?.getAdapter() ?? null;
}
// 获取 NapCat Protocol 适配器
getNapCatProtocolAdapter (): NapCatProtocolAdapter | null {
return this.napcatProtocolAdapter?.getAdapter() ?? null;
}
// 获取指定适配器
getAdapter (name: ProtocolAdapterType): IProtocolAdapter | undefined {
return this.adapters.get(name);
}
// 获取所有已启用的适配器
getEnabledAdapters (): IProtocolAdapter[] {
return Array.from(this.adapters.values()).filter(adapter => adapter.enabled);
}
// 获取所有适配器
getAllAdapters (): IProtocolAdapter[] {
return Array.from(this.adapters.values());
}
// 关闭所有适配器
async closeAllAdapters (): Promise<void> {
this.context.logger.log('[AdapterManager] 开始关闭所有协议适配器...');
for (const [name, adapter] of this.adapters) {
try {
await adapter.close();
this.context.logger.log(`[AdapterManager] ${name} 适配器已关闭`);
} catch (e) {
this.context.logger.logError(`[AdapterManager] 关闭 ${name} 适配器失败:`, e);
}
}
this.adapters.clear();
this.context.logger.log('[AdapterManager] 所有协议适配器已关闭');
}
// 重新加载指定适配器
async reloadAdapter (name: ProtocolAdapterType): Promise<void> {
const adapter = this.adapters.get(name);
if (adapter) {
await adapter.close();
await adapter.init();
this.context.logger.log(`[AdapterManager] ${name} 适配器已重新加载`);
}
}
}
export { NapCatOneBot11Adapter } from 'napcat-onebot';
export { NapCatProtocolAdapter } from 'napcat-protocol';

View File

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

View File

@@ -1,28 +1,28 @@
{
"name": "napcat-common",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
"name": "napcat-common",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./src/index.ts"
},
"exports": {
".": {
"import": "./src/index.ts"
},
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {
"ajv": "^8.13.0",
"file-type": "^21.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {
"ajv": "^8.13.0",
"file-type": "^21.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -2,11 +2,14 @@ import fs from 'fs';
import { stat } from 'fs/promises';
import crypto, { randomUUID } from 'crypto';
import path from 'node:path';
import http from 'node:http';
import tls from 'node:tls';
import { solveProblem } from '@/napcat-common/src/helper';
export interface HttpDownloadOptions {
url: string;
headers?: Record<string, string> | string;
proxy?: string;
}
type Uri2LocalRes = {
@@ -96,6 +99,7 @@ export function calculateFileMD5 (filePath: string): Promise<string> {
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
let url: string;
let proxy: string | undefined;
let headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
};
@@ -104,6 +108,7 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
headers['Host'] = new URL(url).hostname;
} else {
url = options.url;
proxy = options.proxy;
if (options.headers) {
if (typeof options.headers === 'string') {
headers = JSON.parse(options.headers);
@@ -115,6 +120,18 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
if (useReferer && !headers['Referer']) {
headers['Referer'] = url;
}
// 如果配置了代理,使用代理下载
if (proxy) {
try {
const response = await httpDownloadWithProxy(url, headers, proxy);
return new Response(response, { status: 200, statusText: 'OK' });
} catch (proxyError) {
// 如果代理失败,记录错误并尝试直接下载
console.error('代理下载失败,尝试直接下载:', proxyError);
}
}
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
if (err.cause) {
throw err.cause;
@@ -124,6 +141,220 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
return fetchRes;
}
/**
* 使用 HTTP/HTTPS 代理下载文件
*/
function httpDownloadWithProxy (url: string, headers: Record<string, string>, proxy: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const targetUrl = new URL(url);
const proxyUrl = new URL(proxy);
const isTargetHttps = targetUrl.protocol === 'https:';
const proxyPort = parseInt(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80);
// 代理认证头
const proxyAuthHeader = proxyUrl.username && proxyUrl.password
? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString('base64') }
: {};
if (isTargetHttps) {
// HTTPS 目标:需要通过 CONNECT 建立隧道
const connectReq = http.request({
host: proxyUrl.hostname,
port: proxyPort,
method: 'CONNECT',
path: `${targetUrl.hostname}:${targetUrl.port || 443}`,
headers: {
'Host': `${targetUrl.hostname}:${targetUrl.port || 443}`,
...proxyAuthHeader,
},
});
connectReq.on('connect', (res, socket) => {
if (res.statusCode !== 200) {
socket.destroy();
reject(new Error(`代理 CONNECT 失败: ${res.statusCode} ${res.statusMessage}`));
return;
}
// 在隧道上建立 TLS 连接
const tlsSocket = tls.connect({
socket: socket,
servername: targetUrl.hostname,
rejectUnauthorized: true,
}, () => {
// TLS 握手成功,发送 HTTP 请求
const requestPath = targetUrl.pathname + targetUrl.search;
const requestHeaders = {
...headers,
'Host': targetUrl.hostname,
'Connection': 'close',
};
const headerLines = Object.entries(requestHeaders)
.map(([key, value]) => `${key}: ${value}`)
.join('\r\n');
const httpRequest = `GET ${requestPath} HTTP/1.1\r\n${headerLines}\r\n\r\n`;
tlsSocket.write(httpRequest);
});
// 解析 HTTP 响应
let responseData = Buffer.alloc(0);
let headersParsed = false;
let statusCode = 0;
let isChunked = false;
let bodyData = Buffer.alloc(0);
let redirectLocation: string | null = null;
tlsSocket.on('data', (chunk: Buffer) => {
responseData = Buffer.concat([responseData, chunk]);
if (!headersParsed) {
const headerEndIndex = responseData.indexOf('\r\n\r\n');
if (headerEndIndex !== -1) {
headersParsed = true;
const headerStr = responseData.subarray(0, headerEndIndex).toString();
const headerLines = headerStr.split('\r\n');
// 解析状态码
const statusLine = headerLines[0];
const statusMatch = statusLine?.match(/HTTP\/\d\.\d\s+(\d+)/);
statusCode = statusMatch ? parseInt(statusMatch[1]!) : 0;
// 解析响应头
for (const line of headerLines.slice(1)) {
const [key, ...valueParts] = line.split(':');
const value = valueParts.join(':').trim();
if (key?.toLowerCase() === 'transfer-encoding' && value.toLowerCase() === 'chunked') {
isChunked = true;
} else if (key?.toLowerCase() === 'location') {
redirectLocation = value;
}
}
bodyData = responseData.subarray(headerEndIndex + 4);
}
} else {
bodyData = Buffer.concat([bodyData, chunk]);
}
});
tlsSocket.on('end', () => {
// 处理重定向
if (statusCode >= 300 && statusCode < 400 && redirectLocation) {
const redirectUrl = redirectLocation.startsWith('http')
? redirectLocation
: `${targetUrl.protocol}//${targetUrl.host}${redirectLocation}`;
httpDownloadWithProxy(redirectUrl, headers, proxy).then(resolve).catch(reject);
return;
}
if (statusCode !== 200) {
reject(new Error(`下载失败: ${statusCode}`));
return;
}
// 处理 chunked 编码
if (isChunked) {
resolve(parseChunkedBody(bodyData));
} else {
resolve(bodyData);
}
});
tlsSocket.on('error', (err) => {
reject(new Error(`TLS 连接错误: ${err.message}`));
});
});
connectReq.on('error', (err) => {
reject(new Error(`代理连接错误: ${err.message}`));
});
connectReq.end();
} else {
// HTTP 目标:直接通过代理请求
const req = http.request({
host: proxyUrl.hostname,
port: proxyPort,
method: 'GET',
path: url, // 完整 URL
headers: {
...headers,
'Host': targetUrl.hostname,
...proxyAuthHeader,
},
}, (response) => {
handleResponse(response, resolve, reject, url, headers, proxy);
});
req.on('error', (err) => {
reject(new Error(`代理请求错误: ${err.message}`));
});
req.end();
}
});
}
/**
* 解析 chunked 编码的响应体
*/
function parseChunkedBody (data: Buffer): Buffer {
const chunks: Buffer[] = [];
let offset = 0;
while (offset < data.length) {
// 查找 chunk 大小行的结束
const lineEnd = data.indexOf('\r\n', offset);
if (lineEnd === -1) break;
const sizeStr = data.subarray(offset, lineEnd).toString().split(';')[0]; // 忽略 chunk 扩展
const chunkSize = parseInt(sizeStr!, 16);
if (chunkSize === 0) break; // 最后一个 chunk
const chunkStart = lineEnd + 2;
const chunkEnd = chunkStart + chunkSize;
if (chunkEnd > data.length) break;
chunks.push(data.subarray(chunkStart, chunkEnd));
offset = chunkEnd + 2; // 跳过 chunk 数据后的 \r\n
}
return Buffer.concat(chunks);
}
/**
* 处理 HTTP 响应
*/
function handleResponse (
response: http.IncomingMessage,
resolve: (value: Buffer) => void,
reject: (reason: Error) => void,
_url: string,
headers: Record<string, string>,
proxy: string
): void {
// 处理重定向
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
httpDownloadWithProxy(response.headers.location, headers, proxy).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`下载失败: ${response.statusCode} ${response.statusMessage}`));
return;
}
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => resolve(Buffer.concat(chunks)));
response.on('error', reject);
}
export async function httpDownload (options: string | HttpDownloadOptions): Promise<Buffer> {
const useReferer = typeof options === 'string';
let resp = await tryDownload(options);
@@ -176,7 +407,7 @@ export async function checkUriType (Uri: string) {
return { Uri, Type: FileUriType.Unknown };
}
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> {
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>, proxy?: string): Promise<Uri2LocalRes> {
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
const filePath = path.join(dir, filename);
@@ -191,7 +422,7 @@ export async function uriToLocalFile (dir: string, uri: string, filename: string
}
case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {}, proxy });
fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath };
}

View File

@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
import { PacketMsg } from '@/napcat-core/packet/message/message';
interface ForwardMsgJson {
app: string
app: string;
config: ForwardMsgJsonConfig,
desc: string,
extra: ForwardMsgJsonExtra,
meta: ForwardMsgJsonMeta,
prompt: string,
ver: string,
view: string
view: string;
}
interface ForwardMsgJsonConfig {
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
forward: number,
round: number,
type: string,
width: number
width: number;
}
interface ForwardMsgJsonExtra {
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
}
interface ForwardMsgJsonMeta {
detail: ForwardMsgJsonMetaDetail
detail: ForwardMsgJsonMetaDetail;
}
interface ForwardMsgJsonMetaDetail {
news: {
text: string
text: string;
}[],
resid: string,
source: string,
summary: string,
uniseq: string
uniseq: string;
}
interface ForwardAdaptMsg {
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
}
export class ForwardMsgBuilder {
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
const id = crypto.randomUUID();
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
const id = uuid ?? crypto.randomUUID();
const isGroupMsg = msg.some(m => m.isGroupMsg);
if (!source) {
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
@@ -104,13 +104,19 @@ export class ForwardMsgBuilder {
return this.build(resId, []);
}
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
return this.build(resId, packetMsg.map(msg => ({
senderName: msg.senderName,
isGroupMsg: msg.groupId !== undefined,
msg: msg.msg.map(m => ({
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
})),
})), source, news, summary, prompt);
})),
source,
news,
summary,
prompt,
uuid,
);
}
}

View File

@@ -0,0 +1,140 @@
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
import { ServiceNamingMapping } from '@/napcat-core/services/index';
import { NTEventWrapper } from './event';
/**
* 创建 Service 方法的代理
* 拦截所有方法调用,通过 EventWrapper 进行调用
*/
function createServiceMethodProxy<S extends keyof ServiceNamingMapping>(
serviceName: S,
originalService: ServiceNamingMapping[S],
eventWrapper: NTEventWrapper
): ServiceNamingMapping[S] {
return new Proxy(originalService as object, {
get(target, prop, receiver) {
const originalValue = Reflect.get(target, prop, receiver);
// 如果不是函数,直接返回原始值
if (typeof originalValue !== 'function') {
return originalValue;
}
const methodName = prop as string;
// 返回一个包装函数,通过 EventWrapper 调用
return function (this: unknown, ...args: unknown[]) {
// 构造 EventWrapper 需要的路径格式: ServiceName/MethodName
const eventPath = `${serviceName}/${methodName}`;
// 尝试通过 EventWrapper 调用
try {
// 使用 callNoListenerEvent 的底层实现逻辑
const eventFunc = (eventWrapper as any).createEventFunction(eventPath);
if (eventFunc) {
return eventFunc(...args);
}
} catch {
// 如果 EventWrapper 调用失败,回退到原始调用
}
// 回退到原始方法调用
return originalValue.apply(originalService, args);
};
},
}) as ServiceNamingMapping[S];
}
/**
* 创建 Session 的双层代理
* 第一层:拦截 getXXXService 方法
* 第二层:拦截 Service 上的具体方法调用
*/
export function createSessionProxy(
session: NodeIQQNTWrapperSession,
eventWrapper: NTEventWrapper
): NodeIQQNTWrapperSession {
// 缓存已代理的 Service避免重复创建
const serviceProxyCache = new Map<string, unknown>();
return new Proxy(session, {
get(target, prop, receiver) {
const propName = prop as string;
// 检查是否是 getXXXService 方法
if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) {
// 提取 Service 名称: getMsgService -> NodeIKernelMsgService
const servicePart = propName.slice(3); // 移除 'get' 前缀
const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping;
// 返回一个函数,该函数返回代理后的 Service
return function () {
// 检查缓存
if (serviceProxyCache.has(serviceName)) {
return serviceProxyCache.get(serviceName);
}
// 获取原始 Service
const originalGetter = Reflect.get(target, prop, receiver) as () => unknown;
const originalService = originalGetter.call(target);
// 检查是否在 ServiceNamingMapping 中定义
if (isKnownService(serviceName)) {
// 创建 Service 方法代理
const proxiedService = createServiceMethodProxy(
serviceName,
originalService as ServiceNamingMapping[typeof serviceName],
eventWrapper
);
serviceProxyCache.set(serviceName, proxiedService);
return proxiedService;
}
// 未知的 Service直接返回原始对象
serviceProxyCache.set(serviceName, originalService);
return originalService;
};
}
// 非 getXXXService 方法,直接返回原始值
return Reflect.get(target, prop, receiver);
},
});
}
/**
* 检查 Service 名称是否在已知的映射中
*/
function isKnownService(serviceName: string): serviceName is keyof ServiceNamingMapping {
const knownServices: string[] = [
'NodeIKernelAvatarService',
'NodeIKernelBuddyService',
'NodeIKernelFileAssistantService',
'NodeIKernelGroupService',
'NodeIKernelLoginService',
'NodeIKernelMsgService',
'NodeIKernelOnlineStatusService',
'NodeIKernelProfileLikeService',
'NodeIKernelProfileService',
'NodeIKernelTicketService',
'NodeIKernelStorageCleanService',
'NodeIKernelRobotService',
'NodeIKernelRichMediaService',
'NodeIKernelDbToolsService',
'NodeIKernelTipOffService',
'NodeIKernelSearchService',
'NodeIKernelCollectionService',
];
return knownServices.includes(serviceName);
}
/**
* 创建带有 EventWrapper 集成的 InstanceContext
* 这是推荐的使用方式,在创建 context 时自动代理 session
*/
export function createProxiedSession(
session: NodeIQQNTWrapperSession,
eventWrapper: NTEventWrapper
): NodeIQQNTWrapperSession {
return createSessionProxy(session, eventWrapper);
}

View File

@@ -25,6 +25,7 @@ import path from 'node:path';
import fs from 'node:fs';
import { hostname, systemName, systemVersion } from 'napcat-common/src/system';
import { NTEventWrapper } from '@/napcat-core/helper/event';
import { createSessionProxy } from '@/napcat-core/helper/session-proxy';
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
import { NapCatConfigLoader, NapcatConfigSchema } from '@/napcat-core/helper/config';
import os from 'node:os';
@@ -44,7 +45,9 @@ export * from './helper/log';
export * from './helper/qq-basic-info';
export * from './helper/event';
export * from './helper/config';
export * from './helper/config-base';
export * from './helper/proxy-handler';
export * from './helper/session-proxy';
export enum NapCatCoreWorkingEnv {
Unknown = 0,
@@ -118,9 +121,19 @@ export class NapCatCore {
// 通过构造器递过去的 runtime info 应该尽量少
constructor (context: InstanceContext, selfInfo: SelfInfo) {
this.selfInfo = selfInfo;
this.context = context;
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
// 先用原始 session 创建 eventWrapper
this.eventWrapper = new NTEventWrapper(context.session);
// 通过环境变量 NAPCAT_SESSION_PROXY 开启 session 代理
if (process.env['NAPCAT_SESSION_PROXY'] === '1') {
const proxiedSession = createSessionProxy(context.session, this.eventWrapper);
this.context = {
...context,
session: proxiedSession,
};
} else {
this.context = context;
}
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath, NapcatConfigSchema);
this.apis = {
FileApi: new NTQQFileApi(this.context, this),

View File

@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib';
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
export class PacketOperationContext {
private readonly context: PacketContext;
@@ -26,7 +27,7 @@ export class PacketOperationContext {
this.context = context;
}
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
return await this.context.client.sendOidbPacket(pkt, rsp);
}
@@ -224,7 +225,15 @@ export class PacketOperationContext {
const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId;
}
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
//await this.SendPreprocess(msg, groupUin);
// 遍历上传资源
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId;
}
async MoveGroupFile (
groupUin: number,
fileUUID: string,

View File

@@ -0,0 +1,51 @@
import zlib from 'node:zlib';
import * as proto from '@/napcat-core/packet/transformer/proto';
import { NapProtoMsg } from 'napcat-protobuf';
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/napcat-core/packet/transformer/base';
import { PacketMsg } from '@/napcat-core/packet/message/message';
export interface UploadForwardMsgParams {
actionCommand: string;
actionMsg: PacketMsg[];
}
class UploadForwardMsgV2 extends PacketTransformer<typeof proto.SendLongMsgResp> {
build (selfUid: string, msg: UploadForwardMsgParams[], groupUin: number = 0): OidbPacket {
const reqdata = msg.map((item) => ({
actionCommand: item.actionCommand,
actionData: {
msgBody: this.msgBuilder.buildFakeMsg(selfUid, item.actionMsg),
}
}));
const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode(
{
action: reqdata,
}
);
const payload = zlib.gzipSync(Buffer.from(longMsgResultData));
const req = new NapProtoMsg(proto.SendLongMsgReq).encode(
{
info: {
type: groupUin === 0 ? 1 : 3,
uid: {
uid: groupUin === 0 ? selfUid : groupUin.toString(),
},
groupUin,
payload,
},
settings: {
field1: 4, field2: 1, field3: 7, field4: 0,
},
}
);
return {
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
data: PacketBufBuilder(req),
};
}
parse (data: Buffer) {
return new NapProtoMsg(proto.SendLongMsgResp).decode(data);
}
}
export default new UploadForwardMsgV2();

View File

@@ -2,3 +2,4 @@ export { default as UploadForwardMsg } from './UploadForwardMsg';
export { default as FetchGroupMessage } from './FetchGroupMessage';
export { default as FetchC2CMessage } from './FetchC2CMessage';
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';

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 { NapCatAdapterManager } from 'napcat-adapter';
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
@@ -79,11 +79,14 @@ 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();
// 使用 NapCatAdapterManager 统一管理协议适配器
const adapterManager = new NapCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper);
await adapterManager.initAdapters();
// 注册 OneBot 适配器到 WebUiDataRuntime供调试功能使用
const oneBotAdapter = adapterManager.getOneBotAdapter();
if (oneBotAdapter) {
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
}
}
export class NapCatFramework {

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-adapter": "workspace:*",
"napcat-webui-backend": "workspace:*",
"napcat-vite": "workspace:*",
"napcat-qrcode": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -50,6 +50,8 @@ const FrameworkBaseConfig = () =>
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
'@/image-size': resolve(__dirname, '../image-size'),
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
},
},
build: {

View File

@@ -22,7 +22,7 @@ export class CreateFlashTask extends OneBotAction<CreateFlashTaskPayload, any> {
override actionName = ActionName.CreateFlashTask;
override payloadSchema = CreateFlashTaskPayloadSchema;
override returnSchema = Type.Any({ description: '任务创建结果' });
override actionSummary = '创建闪任务';
override actionSummary = '创建闪任务';
override actionTags = ['文件扩展'];
override payloadExample = {
files: 'C:\\test.jpg',

View File

@@ -12,7 +12,7 @@ export class GetFlashFileList extends OneBotAction<GetFlashFileListPayload, any>
override actionName = ActionName.GetFlashFileList;
override payloadSchema = GetFlashFileListPayloadSchema;
override returnSchema = Type.Any({ description: '文件列表' });
override actionSummary = '获取闪文件列表';
override actionSummary = '获取闪文件列表';
override actionTags = ['文件扩展'];
override payloadExample = {
fileset_id: 'set_123'

View File

@@ -14,7 +14,7 @@ export class GetFlashFileUrl extends OneBotAction<GetFlashFileUrlPayload, any> {
override actionName = ActionName.GetFlashFileUrl;
override payloadSchema = GetFlashFileUrlPayloadSchema;
override returnSchema = Type.Any({ description: '文件下载链接' });
override actionSummary = '获取闪文件链接';
override actionSummary = '获取闪文件链接';
override actionTags = ['文件扩展'];
override payloadExample = {
fileset_id: 'set_123'

View File

@@ -15,7 +15,7 @@ export class SendFlashMsg extends OneBotAction<SendFlashMsgPayload, any> {
override actionName = ActionName.SendFlashMsg;
override payloadSchema = SendFlashMsgPayloadSchema;
override returnSchema = Type.Any({ description: '发送结果' });
override actionSummary = '发送闪消息';
override actionSummary = '发送闪消息';
override actionTags = ['文件扩展'];
override payloadExample = {
fileset_id: 'set_123',

View File

@@ -17,6 +17,7 @@ import { rawMsgWithSendMsg } from 'napcat-core/packet/message/converter';
import { Static, Type } from '@sinclair/typebox';
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
export const SendMsgPayloadSchema = Type.Object({
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
@@ -211,10 +212,14 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
}, dp: number = 0): Promise<{
finallySendElements: SendArkElement,
res_id?: string,
uuid?: string,
packetMsg: PacketMsg[],
deleteAfterSentFiles: string[],
innerPacketMsg?: Array<{ uuid: string, packetMsg: PacketMsg[]; }>;
} | null> {
const packetMsg: PacketMsg[] = [];
const delFiles: string[] = [];
const innerMsg: Array<{ uuid: string, packetMsg: PacketMsg[]; }> = new Array();
for (const node of messageNodes) {
if (dp >= 3) {
this.core.context.logger.logWarn('转发消息深度超过3层将停止解析');
@@ -232,6 +237,13 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
}, dp + 1);
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
if (uploadReturnData?.uuid) {
innerMsg.push({ uuid: uploadReturnData.uuid, packetMsg: uploadReturnData.packetMsg });
uploadReturnData.innerPacketMsg?.forEach(m => {
innerMsg.push({ uuid: m.uuid, packetMsg: m.packetMsg });
});
}
} else {
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
sendElements = sendElementsCreateReturn.sendElements;
@@ -273,8 +285,19 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
return null;
}
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
const uploadMsgData: UploadForwardMsgParams[] = [{
actionCommand: 'MultiMsg',
actionMsg: packetMsg,
}];
innerMsg.forEach(({ uuid, packetMsg: msg }) => {
uploadMsgData.push({
actionCommand: uuid,
actionMsg: msg,
});
});
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsgV2(uploadMsgData, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
const uuid = crypto.randomUUID();
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt, uuid);
return {
deleteAfterSentFiles: delFiles,
finallySendElements: {
@@ -285,6 +308,9 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
},
} as SendArkElement,
res_id: resid,
uuid: uuid,
packetMsg: packetMsg,
innerPacketMsg: innerMsg,
};
}

View File

@@ -1288,7 +1288,8 @@ export class OneBotMsgApi {
}
realUri = await this.handleObfuckName(realUri) ?? realUri;
try {
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri);
const proxy = this.obContext.configLoader.configData.imageDownloadProxy || undefined;
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri, undefined, undefined, proxy);
if (!success) {
this.core.context.logger.logError('文件处理失败', errMsg);
throw new Error('文件处理失败: ' + errMsg);

View File

@@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
musicSignUrl: Type.String({ default: '' }),
enableLocalFile2Url: Type.Boolean({ default: false }),
parseMultMsg: Type.Boolean({ default: false }),
imageDownloadProxy: Type.String({ default: '' }),
});
export type OneBotConfig = Static<typeof OneBotConfigSchema>;

View File

@@ -9,6 +9,7 @@ import path from 'path';
export interface PluginPackageJson {
name?: string;
plugin?: string;
version?: string;
main?: string;
}
@@ -255,7 +256,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
async onEvent<T extends OB11EmitEventContent>(event: T) {
async onEvent<T extends OB11EmitEventContent> (event: T) {
if (!this.isEnable) {
return;
}
@@ -357,7 +358,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath;
plugin.pluginPath !== this.pluginPath;
if (isDirectory) {
const dirname = path.basename(plugin.pluginPath);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { PluginConfigItem, PluginConfigSchema } from './types';
/**
* NapCat 插件配置构建器
* 提供便捷的配置项创建方法
*/
export class NapCatConfig {
static text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'string', label, default: defaultValue, description, reactive };
}
static number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'number', label, default: defaultValue, description, reactive };
}
static boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'boolean', label, default: defaultValue, description, reactive };
}
static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'select', label, options, default: defaultValue, description, reactive };
}
static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'multi-select', label, options, default: defaultValue, description, reactive };
}
static html (content: string): PluginConfigItem {
return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content };
}
static plainText (content: string): PluginConfigItem {
return { key: `_text_${Math.random().toString(36).slice(2)}`, type: 'text', label: '', default: content };
}
static combine (...items: PluginConfigItem[]): PluginConfigSchema {
return items;
}
}

View File

@@ -0,0 +1,23 @@
// 导出类型
export type {
PluginPackageJson,
PluginConfigItem,
PluginConfigSchema,
INapCatConfigStatic,
NapCatConfigClass,
IPluginManager,
PluginConfigUIController,
PluginLogger,
NapCatPluginContext,
PluginModule,
PluginRuntimeStatus,
PluginRuntime,
PluginEntry,
PluginStatusConfig,
} from './types';
// 导出配置构建器
export { NapCatConfig } from './config';
// 导出加载器
export { PluginLoader } from './loader';

View File

@@ -0,0 +1,298 @@
import fs from 'fs';
import path from 'path';
import { LogWrapper } from 'napcat-core/helper/log';
import {
PluginPackageJson,
PluginModule,
PluginEntry,
PluginStatusConfig,
} from './types';
/**
* 插件加载器
* 负责扫描、加载和导入插件模块
*/
export class PluginLoader {
constructor (
private readonly pluginPath: string,
private readonly configPath: string,
private readonly logger: LogWrapper
) { }
/**
* 加载插件状态配置
*/
loadPluginStatusConfig (): PluginStatusConfig {
if (fs.existsSync(this.configPath)) {
try {
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
} catch (e) {
this.logger.logWarn('[PluginLoader] Error parsing plugins.json', e);
}
}
return {};
}
/**
* 保存插件状态配置
*/
savePluginStatusConfig (config: PluginStatusConfig): void {
try {
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (e) {
this.logger.logError('[PluginLoader] Error saving plugins.json', e);
}
}
/**
* 扫描插件目录,收集所有有效插件条目(异步版本,验证模块有效性)
* 只有包含有效 plugin_init 函数的插件才会被收集
*/
async scanPlugins (): Promise<PluginEntry[]> {
const entries: PluginEntry[] = [];
// 确保插件目录存在
if (!fs.existsSync(this.pluginPath)) {
this.logger.logWarn(`[PluginLoader] Plugin directory does not exist: ${this.pluginPath}`);
fs.mkdirSync(this.pluginPath, { recursive: true });
return entries;
}
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
const statusConfig = this.loadPluginStatusConfig();
for (const item of items) {
if (!item.isDirectory()) {
continue;
}
const entry = this.scanDirectoryPlugin(item.name, statusConfig);
if (!entry) {
continue;
}
// 如果没有入口文件,跳过
if (!entry.entryPath) {
this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: no entry file found`);
continue;
}
// 如果插件被禁用,跳过模块验证,直接添加到列表
if (!entry.enable) {
entries.push(entry);
continue;
}
// 验证模块有效性(仅对启用的插件)
const validation = await this.validatePluginEntry(entry.entryPath);
if (!validation.valid) {
this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: ${validation.error}`);
continue;
}
entries.push(entry);
}
return entries;
}
/**
* 扫描单个目录插件
*/
private scanDirectoryPlugin (dirname: string, statusConfig: PluginStatusConfig): PluginEntry | null {
const pluginDir = path.join(this.pluginPath, dirname);
try {
// 尝试读取 package.json
let packageJson: PluginPackageJson | undefined;
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
packageJson = JSON.parse(packageContent);
} catch (error) {
this.logger.logWarn(`[PluginLoader] Invalid package.json in ${dirname}:`, error);
}
}
// 获取插件 ID包名或目录名
const pluginId = packageJson?.name || dirname;
// 确定入口文件
const entryFile = this.findEntryFile(pluginDir, packageJson);
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
// 获取启用状态(默认启用)
const enable = statusConfig[pluginId] !== false;
// 创建插件条目
const entry: PluginEntry = {
id: pluginId,
fileId: dirname,
name: packageJson?.name,
version: packageJson?.version,
description: packageJson?.description,
author: packageJson?.author,
pluginPath: pluginDir,
entryPath,
packageJson,
enable,
loaded: false,
runtime: {
status: 'unloaded',
},
};
// 如果没有入口文件,标记为错误
if (!entryPath) {
entry.runtime = {
status: 'error',
error: `No valid entry file found for plugin directory: ${dirname}`,
};
}
return entry;
} catch (error: any) {
// 创建错误条目
return {
id: dirname, // 使用目录名作为 ID
fileId: dirname,
pluginPath: path.join(this.pluginPath, dirname),
enable: statusConfig[dirname] !== false,
loaded: false,
runtime: {
status: 'error',
error: error.message || 'Unknown error during scan',
},
};
}
}
/**
* 查找插件目录的入口文件
*/
private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null {
const possibleEntries = [
packageJson?.main,
'index.mjs',
'index.js',
'main.mjs',
'main.js',
].filter(Boolean) as string[];
for (const entry of possibleEntries) {
const entryPath = path.join(pluginDir, entry);
if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) {
return entry;
}
}
return null;
}
/**
* 动态导入模块
*/
async importModule (filePath: string): Promise<any> {
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`;
return await import(fileUrlWithQuery);
}
/**
* 加载插件模块
*/
async loadPluginModule (entry: PluginEntry): Promise<PluginModule | null> {
if (!entry.entryPath) {
entry.runtime = {
status: 'error',
error: 'No entry path specified',
};
return null;
}
try {
const module = await this.importModule(entry.entryPath);
if (!this.isValidPluginModule(module)) {
entry.runtime = {
status: 'error',
error: 'Invalid plugin module: missing plugin_init function',
};
return null;
}
return module;
} catch (error: any) {
entry.runtime = {
status: 'error',
error: error.message || 'Failed to import module',
};
return null;
}
}
/**
* 检查模块是否为有效的插件模块
*/
isValidPluginModule (module: any): module is PluginModule {
return module && typeof module.plugin_init === 'function';
}
/**
* 验证插件入口文件是否包含有效的 plugin_init 函数
* 用于扫描阶段快速验证
*/
async validatePluginEntry (entryPath: string): Promise<{ valid: boolean; error?: string; }> {
try {
const module = await this.importModule(entryPath);
if (this.isValidPluginModule(module)) {
return { valid: true };
}
return { valid: false, error: 'Missing plugin_init function' };
} catch (error: any) {
return { valid: false, error: error.message || 'Failed to import module' };
}
}
/**
* 重新扫描单个插件
*/
rescanPlugin (dirname: string): PluginEntry | null {
const statusConfig = this.loadPluginStatusConfig();
return this.scanDirectoryPlugin(dirname, statusConfig);
}
/**
* 通过 ID 查找插件目录名
*/
findPluginDirById (pluginId: string): string | null {
if (!fs.existsSync(this.pluginPath)) {
return null;
}
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
for (const item of items) {
if (!item.isDirectory()) continue;
const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
if (pkg.name === pluginId) {
return item.name;
}
} catch (e) { }
}
// 如果目录名就是 ID
if (item.name === pluginId) {
return item.name;
}
}
return null;
}
}

View File

@@ -0,0 +1,513 @@
import fs from 'fs';
import path from 'path';
import { ActionMap } from '@/napcat-onebot/action';
import { NapCatCore } from 'napcat-core';
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import { PluginConfig } from '@/napcat-onebot/config/config';
import { NapCatConfig } from './config';
import { PluginLoader } from './loader';
import { PluginRouterRegistryImpl } from './router-registry';
import {
PluginEntry,
PluginLogger,
PluginStatusConfig,
NapCatPluginContext,
IPluginManager,
} from './types';
export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
private readonly pluginPath: string;
private readonly configPath: string;
private readonly loader: PluginLoader;
/** 插件注册表: ID -> 插件条目 */
private plugins: Map<string, PluginEntry> = new Map();
/** 插件路由注册表: pluginId -> PluginRouterRegistry */
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
declare config: PluginConfig;
public NapCatConfig = NapCatConfig;
override get isActive (): boolean {
return this.isEnable && this.getLoadedPlugins().length > 0;
}
constructor (
name: string,
core: NapCatCore,
obContext: NapCatOneBot11Adapter,
actions: ActionMap
) {
const config = {
name,
messagePostFormat: 'array',
reportSelfMessage: true,
enable: true,
debug: true,
};
super(name, config, core, obContext, actions);
this.pluginPath = this.core.context.pathWrapper.pluginPath;
this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json');
this.loader = new PluginLoader(this.pluginPath, this.configPath, this.logger);
}
// ==================== 插件状态配置 ====================
public getPluginConfig (): PluginStatusConfig {
return this.loader.loadPluginStatusConfig();
}
private savePluginConfig (config: PluginStatusConfig): void {
this.loader.savePluginStatusConfig(config);
}
// ==================== 插件扫描与加载 ====================
/**
* 扫描并加载所有插件
*/
private async scanAndLoadPlugins (): Promise<void> {
// 扫描所有插件目录
const entries = await this.loader.scanPlugins();
// 清空现有注册表
this.plugins.clear();
// 注册所有插件条目
for (const entry of entries) {
this.plugins.set(entry.id, entry);
}
this.logger.log(`[PluginManager] Scanned ${this.plugins.size} plugins`);
// 加载启用的插件
for (const entry of this.plugins.values()) {
if (entry.enable && entry.runtime.status !== 'error') {
await this.loadPlugin(entry);
}
}
const loadedCount = this.getLoadedPlugins().length;
this.logger.log(`[PluginManager] Loaded ${loadedCount} plugins`);
}
/**
* 加载单个插件
*/
private async loadPlugin (entry: PluginEntry): Promise<boolean> {
if (entry.loaded) {
return true;
}
if (entry.runtime.status === 'error') {
return false;
}
// 加载模块
const module = await this.loader.loadPluginModule(entry);
if (!module) {
return false;
}
// 创建上下文
const context = this.createPluginContext(entry);
// 初始化插件
try {
await module.plugin_init(context);
entry.loaded = true;
entry.runtime = {
status: 'loaded',
module,
context,
};
this.logger.log(`[PluginManager] Initialized plugin: ${entry.id}${entry.version ? ` v${entry.version}` : ''}`);
return true;
} catch (error: any) {
entry.loaded = false;
entry.runtime = {
status: 'error',
error: error.message || 'Initialization failed',
};
this.logger.logError(`[PluginManager] Error initializing plugin ${entry.id}:`, error);
return false;
}
}
/**
* 卸载单个插件
*/
private async unloadPlugin (entry: PluginEntry): Promise<void> {
if (!entry.loaded || entry.runtime.status !== 'loaded') {
return;
}
const { module, context } = entry.runtime;
// 调用清理方法
if (module && context && typeof module.plugin_cleanup === 'function') {
try {
await module.plugin_cleanup(context);
this.logger.log(`[PluginManager] Cleaned up plugin: ${entry.id}`);
} catch (error) {
this.logger.logError(`[PluginManager] Error cleaning up plugin ${entry.id}:`, error);
}
}
// 重置状态
entry.loaded = false;
entry.runtime = {
status: 'unloaded',
};
this.logger.log(`[PluginManager] Unloaded plugin: ${entry.id}`);
}
/**
* 创建插件上下文
*/
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
const dataPath = path.join(entry.pluginPath, 'data');
const configPath = path.join(dataPath, 'config.json');
// 创建插件专用日志器
const pluginPrefix = `[Plugin: ${entry.id}]`;
const coreLogger = this.logger;
const pluginLogger: PluginLogger = {
log: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
debug: (...args: any[]) => coreLogger.logDebug(pluginPrefix, ...args),
info: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
warn: (...args: any[]) => coreLogger.logWarn(pluginPrefix, ...args),
error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args),
};
// 创建或获取插件路由注册器
let router = this.pluginRouters.get(entry.id);
if (!router) {
router = new PluginRouterRegistryImpl(entry.id, entry.pluginPath);
this.pluginRouters.set(entry.id, router);
}
return {
core: this.core,
oneBot: this.obContext,
actions: this.actions,
pluginName: entry.id,
pluginPath: entry.pluginPath,
dataPath,
configPath,
NapCatConfig,
adapterName: this.name,
pluginManager: this,
logger: pluginLogger,
router,
};
}
// ==================== 公共 API ====================
/**
* 获取插件目录路径
*/
public getPluginPath (): string {
return this.pluginPath;
}
/**
* 获取所有插件条目
*/
public getAllPlugins (): PluginEntry[] {
return Array.from(this.plugins.values());
}
/**
* 获取已加载的插件列表
*/
public getLoadedPlugins (): PluginEntry[] {
return Array.from(this.plugins.values()).filter(p => p.loaded);
}
/**
* 通过 ID 获取插件信息
*/
public getPluginInfo (pluginId: string): PluginEntry | undefined {
return this.plugins.get(pluginId);
}
/**
* 设置插件状态(启用/禁用)
*/
public async setPluginStatus (pluginId: string, enable: boolean): Promise<void> {
const config = this.getPluginConfig();
config[pluginId] = enable;
this.savePluginConfig(config);
const entry = this.plugins.get(pluginId);
if (entry) {
entry.enable = enable;
if (enable && !entry.loaded) {
// 启用插件
await this.loadPlugin(entry);
} else if (!enable && entry.loaded) {
// 禁用插件
await this.unloadPlugin(entry);
}
}
}
/**
* 通过 ID 加载插件
*/
public async loadPluginById (pluginId: string): Promise<boolean> {
let entry = this.plugins.get(pluginId);
if (!entry) {
// 尝试查找并扫描
const dirname = this.loader.findPluginDirById(pluginId);
if (!dirname) {
this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found in filesystem`);
return false;
}
const newEntry = this.loader.rescanPlugin(dirname);
if (!newEntry) {
return false;
}
this.plugins.set(newEntry.id, newEntry);
entry = newEntry;
}
return await this.loadPlugin(entry);
}
/**
* 卸载插件(仅从内存卸载)
*/
public async unregisterPlugin (pluginId: string): Promise<void> {
const entry = this.plugins.get(pluginId);
if (entry) {
await this.unloadPlugin(entry);
}
}
/**
* 卸载并删除插件
*/
public async uninstallPlugin (pluginId: string, cleanData: boolean = false): Promise<void> {
const entry = this.plugins.get(pluginId);
if (!entry) {
throw new Error(`Plugin ${pluginId} not found`);
}
const pluginPath = entry.pluginPath;
const dataPath = path.join(pluginPath, 'data');
// 先卸载插件
await this.unloadPlugin(entry);
// 从注册表移除
this.plugins.delete(pluginId);
// 删除插件目录
if (fs.existsSync(pluginPath)) {
fs.rmSync(pluginPath, { recursive: true, force: true });
}
// 清理数据
if (cleanData && fs.existsSync(dataPath)) {
fs.rmSync(dataPath, { recursive: true, force: true });
}
}
/**
* 重载指定插件
*/
public async reloadPlugin (pluginId: string): Promise<boolean> {
const entry = this.plugins.get(pluginId);
if (!entry) {
this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found`);
return false;
}
try {
// 卸载插件
await this.unloadPlugin(entry);
// 重新扫描
const newEntry = this.loader.rescanPlugin(entry.fileId);
if (!newEntry) {
return false;
}
// 更新注册表
this.plugins.set(newEntry.id, newEntry);
// 重新加载
if (newEntry.enable) {
await this.loadPlugin(newEntry);
}
this.logger.log(`[PluginManager] Plugin ${pluginId} reloaded successfully`);
return true;
} catch (error) {
this.logger.logError(`[PluginManager] Error reloading plugin ${pluginId}:`, error);
return false;
}
}
/**
* 加载目录插件(用于新安装的插件)
*/
public async loadDirectoryPlugin (dirname: string): Promise<void> {
const entry = this.loader.rescanPlugin(dirname);
if (!entry) {
return;
}
// 检查是否已存在
if (this.plugins.has(entry.id)) {
this.logger.logWarn(`[PluginManager] Plugin ${entry.id} already exists`);
return;
}
this.plugins.set(entry.id, entry);
if (entry.enable && entry.runtime.status !== 'error') {
await this.loadPlugin(entry);
}
}
/**
* 获取插件数据目录路径
*/
public getPluginDataPath (pluginId: string): string {
const entry = this.plugins.get(pluginId);
if (!entry) {
throw new Error(`Plugin ${pluginId} not found`);
}
return path.join(entry.pluginPath, 'data');
}
/**
* 获取插件配置文件路径
*/
public getPluginConfigPath (pluginId: string): string {
return path.join(this.getPluginDataPath(pluginId), 'config.json');
}
/**
* 获取插件路由注册器
*/
public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined {
return this.pluginRouters.get(pluginId);
}
/**
* 获取所有插件路由注册器
*/
public getAllPluginRouters (): Map<string, PluginRouterRegistryImpl> {
return this.pluginRouters;
}
// ==================== 事件处理 ====================
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {
if (!this.isEnable) {
return;
}
try {
await Promise.allSettled(
this.getLoadedPlugins().map((entry) =>
this.callPluginEventHandler(entry, event)
)
);
} catch (error) {
this.logger.logError('[PluginManager] Error handling event:', error);
}
}
/**
* 调用插件的事件处理方法
*/
private async callPluginEventHandler (
entry: PluginEntry,
event: OB11EmitEventContent
): Promise<void> {
if (entry.runtime.status !== 'loaded' || !entry.runtime.module || !entry.runtime.context) {
return;
}
const { module, context } = entry.runtime;
try {
// 优先使用 plugin_onevent 方法
if (typeof module.plugin_onevent === 'function') {
await module.plugin_onevent(context, event);
}
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
if (
(event as any).message_type &&
typeof module.plugin_onmessage === 'function'
) {
await module.plugin_onmessage(context, event as OB11Message);
}
} catch (error) {
this.logger.logError(`[PluginManager] Error calling plugin ${entry.id} event handler:`, error);
}
}
// ==================== 生命周期 ====================
async open (): Promise<void> {
if (this.isEnable) {
return;
}
this.logger.log('[PluginManager] Opening plugin manager...');
this.isEnable = true;
// 扫描并加载所有插件
await this.scanAndLoadPlugins();
this.logger.log(`[PluginManager] Plugin manager opened with ${this.getLoadedPlugins().length} plugins loaded`);
}
async close (): Promise<void> {
if (!this.isEnable) {
return;
}
this.logger.log('[PluginManager] Closing plugin manager...');
this.isEnable = false;
// 卸载所有已加载的插件
for (const entry of this.plugins.values()) {
if (entry.loaded) {
await this.unloadPlugin(entry);
}
}
this.logger.log('[PluginManager] Plugin manager closed');
}
async reload (): Promise<OB11NetworkReloadType> {
this.logger.log('[PluginManager] Reloading plugin manager...');
// 先关闭然后重新打开
await this.close();
await this.open();
this.logger.log('[PluginManager] Plugin manager reloaded');
return OB11NetworkReloadType.Normal;
}
}

View File

@@ -0,0 +1,221 @@
import { Router, static as expressStatic, Request, Response, NextFunction } from 'express';
import path from 'path';
import {
PluginRouterRegistry,
PluginRequestHandler,
PluginApiRouteDefinition,
PluginPageDefinition,
PluginHttpRequest,
PluginHttpResponse,
HttpMethod,
} from './types';
/**
* 包装 Express Request 为 PluginHttpRequest
*/
function wrapRequest (req: Request): PluginHttpRequest {
return {
path: req.path,
method: req.method,
query: req.query as Record<string, string | string[] | undefined>,
body: req.body,
headers: req.headers as Record<string, string | string[] | undefined>,
params: req.params,
raw: req,
};
}
/**
* 包装 Express Response 为 PluginHttpResponse
*/
function wrapResponse (res: Response): PluginHttpResponse {
const wrapped: PluginHttpResponse = {
status (code: number) {
res.status(code);
return wrapped;
},
json (data: unknown) {
res.json(data);
},
send (data: string | Buffer) {
res.send(data);
},
setHeader (name: string, value: string) {
res.setHeader(name, value);
return wrapped;
},
sendFile (filePath: string) {
res.sendFile(filePath);
},
redirect (url: string) {
res.redirect(url);
},
raw: res,
};
return wrapped;
}
/**
* 插件路由注册器实现
* 为每个插件创建独立的路由注册器,收集路由定义
*/
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
private apiRoutes: PluginApiRouteDefinition[] = [];
private pageDefinitions: PluginPageDefinition[] = [];
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
constructor (
private readonly pluginId: string,
private readonly pluginPath: string
) { }
// ==================== API 路由注册 ====================
api (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
this.apiRoutes.push({ method, path: routePath, handler });
}
get (routePath: string, handler: PluginRequestHandler): void {
this.api('get', routePath, handler);
}
post (routePath: string, handler: PluginRequestHandler): void {
this.api('post', routePath, handler);
}
put (routePath: string, handler: PluginRequestHandler): void {
this.api('put', routePath, handler);
}
delete (routePath: string, handler: PluginRequestHandler): void {
this.api('delete', routePath, handler);
}
// ==================== 页面注册 ====================
page (pageDef: PluginPageDefinition): void {
this.pageDefinitions.push(pageDef);
}
pages (pageDefs: PluginPageDefinition[]): void {
this.pageDefinitions.push(...pageDefs);
}
// ==================== 静态资源 ====================
static (urlPath: string, localPath: string): void {
// 如果是相对路径,则相对于插件目录
const absolutePath = path.isAbsolute(localPath)
? localPath
: path.join(this.pluginPath, localPath);
this.staticRoutes.push({ urlPath, localPath: absolutePath });
}
// ==================== 构建路由 ====================
/**
* 构建 Express Router用于 API 路由)
*/
buildApiRouter (): Router {
const router = Router();
// 注册静态文件路由
for (const { urlPath, localPath } of this.staticRoutes) {
router.use(urlPath, expressStatic(localPath));
}
// 注册 API 路由
for (const route of this.apiRoutes) {
const handler = this.wrapHandler(route.handler);
switch (route.method) {
case 'get':
router.get(route.path, handler);
break;
case 'post':
router.post(route.path, handler);
break;
case 'put':
router.put(route.path, handler);
break;
case 'delete':
router.delete(route.path, handler);
break;
case 'patch':
router.patch(route.path, handler);
break;
case 'all':
router.all(route.path, handler);
break;
}
}
return router;
}
/**
* 包装处理器,添加错误处理和请求/响应包装
*/
private wrapHandler (handler: PluginRequestHandler): (req: Request, res: Response, next: NextFunction) => void {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const wrappedReq = wrapRequest(req);
const wrappedRes = wrapResponse(res);
await handler(wrappedReq, wrappedRes, next);
} catch (error: any) {
console.error(`[Plugin: ${this.pluginId}] Route error:`, error);
if (!res.headersSent) {
res.status(500).json({
code: -1,
message: `Plugin error: ${error.message || 'Unknown error'}`,
});
}
}
};
}
// ==================== 查询方法 ====================
/**
* 检查是否有注册的 API 路由
*/
hasApiRoutes (): boolean {
return this.apiRoutes.length > 0 || this.staticRoutes.length > 0;
}
/**
* 检查是否有注册的页面
*/
hasPages (): boolean {
return this.pageDefinitions.length > 0;
}
/**
* 获取所有注册的页面定义
*/
getPages (): PluginPageDefinition[] {
return [...this.pageDefinitions];
}
/**
* 获取插件 ID
*/
getPluginId (): string {
return this.pluginId;
}
/**
* 获取插件路径
*/
getPluginPath (): string {
return this.pluginPath;
}
/**
* 清空路由(用于插件卸载)
*/
clear (): void {
this.apiRoutes = [];
this.pageDefinitions = [];
this.staticRoutes = [];
}
}

View File

@@ -0,0 +1,347 @@
import { NapCatCore } from 'napcat-core';
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
import { ActionMap } from '@/napcat-onebot/action';
import { OB11EmitEventContent } from '@/napcat-onebot/network/index';
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
// ==================== 插件包信息 ====================
export interface PluginPackageJson {
name?: string;
plugin?: string;
version?: string;
main?: string;
description?: string;
author?: string;
}
// ==================== 插件配置 Schema ====================
export interface PluginConfigItem {
key: string;
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
label: string;
description?: string;
default?: unknown;
options?: { label: string; value: string | number; }[];
placeholder?: string;
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
reactive?: boolean;
/** 是否隐藏此字段 */
hidden?: boolean;
}
export type PluginConfigSchema = PluginConfigItem[];
// ==================== NapCatConfig 静态接口 ====================
/** NapCatConfig 类的静态方法接口(用于 typeof NapCatConfig */
export interface INapCatConfigStatic {
text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem;
number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem;
boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem;
select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem;
multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem;
html (content: string): PluginConfigItem;
plainText (content: string): PluginConfigItem;
combine (...items: PluginConfigItem[]): PluginConfigSchema;
}
/** NapCatConfig 类型(包含静态方法) */
export type NapCatConfigClass = INapCatConfigStatic;
// ==================== 插件路由相关类型(包装层,不直接依赖 express ====================
/** HTTP 请求对象(包装类型) */
export interface PluginHttpRequest {
/** 请求路径 */
path: string;
/** 请求方法 */
method: string;
/** 查询参数 */
query: Record<string, string | string[] | undefined>;
/** 请求体 */
body: unknown;
/** 请求头 */
headers: Record<string, string | string[] | undefined>;
/** 路由参数 */
params: Record<string, string>;
/** 原始请求对象(用于高级用法) */
raw: unknown;
}
/** HTTP 响应对象(包装类型) */
export interface PluginHttpResponse {
/** 设置状态码 */
status (code: number): PluginHttpResponse;
/** 发送 JSON 响应 */
json (data: unknown): void;
/** 发送文本响应 */
send (data: string | Buffer): void;
/** 设置响应头 */
setHeader (name: string, value: string): PluginHttpResponse;
/** 发送文件 */
sendFile (filePath: string): void;
/** 重定向 */
redirect (url: string): void;
/** 原始响应对象(用于高级用法) */
raw: unknown;
}
/** 下一步函数类型 */
export type PluginNextFunction = (err?: unknown) => void;
/** 插件请求处理器类型 */
export type PluginRequestHandler = (
req: PluginHttpRequest,
res: PluginHttpResponse,
next: PluginNextFunction
) => void | Promise<void>;
/** HTTP 方法类型 */
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'all';
/** 插件 API 路由定义 */
export interface PluginApiRouteDefinition {
/** HTTP 方法 */
method: HttpMethod;
/** 路由路径(相对于插件路由前缀) */
path: string;
/** 请求处理器 */
handler: PluginRequestHandler;
}
/** 插件页面定义 */
export interface PluginPageDefinition {
/** 页面路径(用于路由,如 'settings' */
path: string;
/** 页面标题(显示在 Tab 上) */
title: string;
/** 页面图标(可选,支持 emoji 或图标名) */
icon?: string;
/** 页面 HTML 文件路径(相对于插件目录) */
htmlFile: string;
/** 页面描述 */
description?: string;
}
/** 插件路由注册器 */
export interface PluginRouterRegistry {
// ==================== API 路由注册 ====================
/**
* 注册单个 API 路由
* @param method HTTP 方法
* @param path 路由路径
* @param handler 请求处理器
*/
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
/** 注册 GET API */
get (path: string, handler: PluginRequestHandler): void;
/** 注册 POST API */
post (path: string, handler: PluginRequestHandler): void;
/** 注册 PUT API */
put (path: string, handler: PluginRequestHandler): void;
/** 注册 DELETE API */
delete (path: string, handler: PluginRequestHandler): void;
// ==================== 页面注册 ====================
/**
* 注册插件页面
* @param page 页面定义
*/
page (page: PluginPageDefinition): void;
/**
* 注册多个插件页面
* @param pages 页面定义数组
*/
pages (pages: PluginPageDefinition[]): void;
// ==================== 静态资源 ====================
/**
* 提供静态文件服务
* @param urlPath URL 路径
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
*/
static (urlPath: string, localPath: string): void;
}
// ==================== 插件管理器接口 ====================
/** 插件管理器公共接口 */
export interface IPluginManager {
readonly config: NetworkAdapterConfig;
getPluginPath (): string;
getPluginConfig (): PluginStatusConfig;
getAllPlugins (): PluginEntry[];
getLoadedPlugins (): PluginEntry[];
getPluginInfo (pluginId: string): PluginEntry | undefined;
setPluginStatus (pluginId: string, enable: boolean): Promise<void>;
loadPluginById (pluginId: string): Promise<boolean>;
unregisterPlugin (pluginId: string): Promise<void>;
uninstallPlugin (pluginId: string, cleanData?: boolean): Promise<void>;
reloadPlugin (pluginId: string): Promise<boolean>;
loadDirectoryPlugin (dirname: string): Promise<void>;
getPluginDataPath (pluginId: string): string;
getPluginConfigPath (pluginId: string): string;
}
// ==================== 插件配置 UI 控制器 ====================
/** 插件配置 UI 控制器 - 用于动态控制配置界面 */
export interface PluginConfigUIController {
/** 更新整个 schema */
updateSchema: (schema: PluginConfigSchema) => void;
/** 更新单个字段 */
updateField: (key: string, field: Partial<PluginConfigItem>) => void;
/** 移除字段 */
removeField: (key: string) => void;
/** 添加字段 */
addField: (field: PluginConfigItem, afterKey?: string) => void;
/** 显示字段 */
showField: (key: string) => void;
/** 隐藏字段 */
hideField: (key: string) => void;
/** 获取当前配置值 */
getCurrentConfig: () => Record<string, unknown>;
}
// ==================== 插件日志接口 ====================
/**
* 插件日志接口 - 简化的日志 API
*/
export interface PluginLogger {
/** 普通日志 */
log (...args: unknown[]): void;
/** 调试日志 */
debug (...args: unknown[]): void;
/** 信息日志 */
info (...args: unknown[]): void;
/** 警告日志 */
warn (...args: unknown[]): void;
/** 错误日志 */
error (...args: unknown[]): void;
}
// ==================== 插件上下文 ====================
export interface NapCatPluginContext {
core: NapCatCore;
oneBot: NapCatOneBot11Adapter;
actions: ActionMap;
pluginName: string;
pluginPath: string;
configPath: string;
dataPath: string;
/** NapCatConfig 配置构建器 */
NapCatConfig: NapCatConfigClass;
adapterName: string;
/** 插件管理器实例 */
pluginManager: IPluginManager;
/** 插件日志器 - 自动添加插件名称前缀 */
logger: PluginLogger;
/**
* WebUI 路由注册器
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
*/
router: PluginRouterRegistry;
}
// ==================== 插件模块接口 ====================
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent, C = unknown> {
plugin_init: (ctx: NapCatPluginContext) => void | Promise<void>;
plugin_onmessage?: (
ctx: NapCatPluginContext,
event: OB11Message,
) => void | Promise<void>;
plugin_onevent?: (
ctx: NapCatPluginContext,
event: T,
) => void | Promise<void>;
plugin_cleanup?: (
ctx: NapCatPluginContext
) => void | Promise<void>;
plugin_config_schema?: PluginConfigSchema;
plugin_config_ui?: PluginConfigSchema;
plugin_get_config?: (ctx: NapCatPluginContext) => C | Promise<C>;
plugin_set_config?: (ctx: NapCatPluginContext, config: C) => void | Promise<void>;
/**
* 配置界面控制器 - 当配置界面打开时调用
* 返回清理函数,在界面关闭时调用
*/
plugin_config_controller?: (
ctx: NapCatPluginContext,
ui: PluginConfigUIController,
initialConfig: Record<string, unknown>
) => void | (() => void) | Promise<void | (() => void)>;
/**
* 响应式字段变化回调 - 当标记为 reactive 的字段值变化时调用
*/
plugin_on_config_change?: (
ctx: NapCatPluginContext,
ui: PluginConfigUIController,
key: string,
value: unknown,
currentConfig: Record<string, unknown>
) => void | Promise<void>;
}
// ==================== 插件运行时状态 ====================
export type PluginRuntimeStatus = 'loaded' | 'error' | 'unloaded';
export interface PluginRuntime {
/** 运行时状态 */
status: PluginRuntimeStatus;
/** 错误信息(当 status 为 'error' 时) */
error?: string;
/** 插件模块(当 status 为 'loaded' 时) */
module?: PluginModule;
/** 插件上下文(当 status 为 'loaded' 时) */
context?: NapCatPluginContext;
}
// ==================== 插件条目(统一管理所有插件) ====================
export interface PluginEntry {
// ===== 基础信息 =====
/** 插件 ID包名或目录名 */
id: string;
/** 文件系统目录名 */
fileId: string;
/** 显示名称 */
name?: string;
/** 版本号 */
version?: string;
/** 描述 */
description?: string;
/** 作者 */
author?: string;
/** 插件目录路径 */
pluginPath: string;
/** 入口文件路径 */
entryPath?: string;
/** package.json 内容 */
packageJson?: PluginPackageJson;
// ===== 状态 =====
/** 是否启用(用户配置) */
enable: boolean;
/** 运行时是否已加载 */
loaded: boolean;
// ===== 运行时 =====
/** 运行时信息 */
runtime: PluginRuntime;
}
// ==================== 插件状态配置(持久化) ====================
export interface PluginStatusConfig {
[key: string]: boolean; // key: pluginId, value: enabled
}

View File

@@ -74,7 +74,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
let isClosedByError = false;
this.connection = new WebSocket(this.config.url, {
maxPayload: 1024 * 1024 * 1024,
maxPayload: 50 * 1024 * 1024, // 50 MB
handshakeTimeout: 2000,
perMessageDeflate: false,
headers: {

View File

@@ -32,7 +32,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.wsServer = new WebSocketServer({
port: this.config.port,
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
maxPayload: 1024 * 1024 * 1024,
maxPayload: 50 * 1024 * 1024, // 50 MB
});
this.createServer(this.wsServer);
}
@@ -237,7 +237,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.wsServer = new WebSocketServer({
port: newConfig.port,
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
maxPayload: 1024 * 1024 * 1024,
maxPayload: 50 * 1024 * 1024, // 50 MB
});
this.createServer(this.wsServer);
if (newConfig.enable) {

View File

@@ -1,45 +1,236 @@
import type { ActionMap } from 'napcat-types/napcat-onebot/action/index';
import { EventType } from 'napcat-types/napcat-onebot/event/index';
import type { PluginModule } from 'napcat-types/napcat-onebot/network/plugin-manger';
import type { PluginModule, PluginLogger, PluginConfigSchema, PluginConfigUIController } from 'napcat-types/napcat-onebot/network/plugin-manger';
import type { OB11Message, OB11PostSendMsg } from 'napcat-types/napcat-onebot/types/index';
import fs from 'fs';
import path from 'path';
import { NetworkAdapterConfig } from 'napcat-types/napcat-onebot/config/config';
let actions: ActionMap | undefined = undefined;
let startTime: number = Date.now();
let logger: PluginLogger | null = null;
/**
* 插件初始化
*/
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
console.log('[Plugin: builtin] NapCat 内置插件已初始化');
actions = _actions;
interface BuiltinPluginConfig {
prefix: string;
enableReply: boolean;
description: string;
theme?: string;
features?: string[];
apiUrl?: string;
apiEndpoints?: string[];
[key: string]: unknown;
}
let currentConfig: BuiltinPluginConfig = {
prefix: '#napcat',
enableReply: true,
description: '这是一个内置插件的配置示例'
};
export let plugin_config_ui: PluginConfigSchema = [];
const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
logger = ctx.logger;
logger.info('NapCat 内置插件已初始化');
plugin_config_ui = ctx.NapCatConfig.combine(
ctx.NapCatConfig.html('<div style="padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;"><h3>👋 Welcome to NapCat Builtin Plugin</h3><p>This is a demonstration of the plugin configuration interface with reactive fields.</p></div>'),
ctx.NapCatConfig.text('prefix', 'Command Prefix', '#napcat', 'The prefix to trigger the version info command'),
ctx.NapCatConfig.boolean('enableReply', 'Enable Reply', true, 'Switch to enable or disable the reply functionality'),
// 代表监听 apiUrl 字段的变化
ctx.NapCatConfig.text('apiUrl', 'API URL', '', 'Enter an API URL to load available endpoints', true),
ctx.NapCatConfig.select('theme', 'Theme Selection', [
{ label: 'Light Mode', value: 'light' },
{ label: 'Dark Mode', value: 'dark' },
{ label: 'Auto', value: 'auto' }
], 'light', 'Select a theme for the response (Demo purpose only)'),
ctx.NapCatConfig.multiSelect('features', 'Enabled Features', [
{ label: 'Version Info', value: 'version' },
{ label: 'Status Report', value: 'status' },
{ label: 'Debug Log', value: 'debug' }
], ['version'], 'Select features to enable'),
ctx.NapCatConfig.text('description', 'Description', '这是一个内置插件的配置示例', 'A multi-line text area for notes')
);
// Try to load config
try {
// Use ctx.configPath
if (fs.existsSync(ctx.configPath)) {
const savedConfig = JSON.parse(fs.readFileSync(ctx.configPath, 'utf-8'));
Object.assign(currentConfig, savedConfig);
}
} catch (e) {
logger?.warn('Failed to load config', e);
}
// ==================== 注册 WebUI 路由示例 ====================
// 注册静态资源目录webui 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问)
ctx.router.static('/static', 'webui');
// 注册 API 路由
ctx.router.get('/status', (_req, res) => {
const uptime = Date.now() - startTime;
res.json({
code: 0,
data: {
pluginName: ctx.pluginName,
uptime,
uptimeFormatted: formatUptime(uptime),
config: currentConfig,
platform: process.platform,
arch: process.arch
}
});
});
ctx.router.get('/config', (_req, res) => {
res.json({
code: 0,
data: currentConfig
});
});
ctx.router.post('/config', (req, res) => {
try {
const newConfig = req.body as Partial<BuiltinPluginConfig>;
Object.assign(currentConfig, newConfig);
// 保存配置
const configDir = path.dirname(ctx.configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(ctx.configPath, JSON.stringify(currentConfig, null, 2), 'utf-8');
res.json({ code: 0, message: 'Config saved successfully' });
} catch (e: any) {
res.status(500).json({ code: -1, message: e.message });
}
});
// 注册扩展页面
ctx.router.page({
path: 'dashboard',
title: '插件仪表盘',
icon: '📊',
htmlFile: 'webui/dashboard.html',
description: '查看内置插件的运行状态和配置'
});
logger.info('WebUI 路由已注册: /api/Plugin/ext/' + ctx.pluginName);
};
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
return currentConfig;
};
export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config) => {
currentConfig = config as BuiltinPluginConfig;
if (ctx && ctx.configPath) {
try {
const configPath = ctx.configPath;
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (e) {
logger?.error('Failed to save config', e);
throw e;
}
}
};
/**
* 消息处理
* 当收到包含 #napcat 的消息时,回复版本信息
* 响应式配置控制器 - 当插件配置界面打开时调用
* 用于初始化动态 UI 控制
*/
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, _actions, instance) => {
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith('#napcat')) {
export const plugin_config_controller: PluginModule['plugin_config_controller'] = async (_ctx, ui, initialConfig) => {
logger?.info('配置控制器已初始化', initialConfig);
// 如果初始配置中有 apiUrl立即加载端点
if (initialConfig['apiUrl']) {
await loadEndpointsForUrl(ui, initialConfig['apiUrl'] as string);
}
// 返回清理函数
return () => {
logger?.info('配置控制器已清理');
};
};
/**
* 响应式字段变化处理 - 当标记为 reactive 的字段值变化时调用
*/
export const plugin_on_config_change: PluginModule['plugin_on_config_change'] = async (_ctx, ui, key, value, _currentConfig: Partial<BuiltinPluginConfig>) => {
logger?.info(`配置字段变化: ${key} = ${value}`);
if (key === 'apiUrl') {
await loadEndpointsForUrl(ui, value as string);
}
};
/**
* 根据 API URL 动态加载端点列表
*/
async function loadEndpointsForUrl (ui: PluginConfigUIController, apiUrl: string) {
if (!apiUrl) {
// URL 为空时,移除端点选择字段
ui.removeField('apiEndpoints');
return;
}
// 模拟从 API 获取端点列表(实际使用时可以 fetch 真实 API
const mockEndpoints = [
{ label: `${apiUrl}/users`, value: '/users' },
{ label: `${apiUrl}/posts`, value: '/posts' },
{ label: `${apiUrl}/comments`, value: '/comments' },
{ label: `${apiUrl}/albums`, value: '/albums' },
];
// 动态添加或更新端点选择字段
const currentSchema = ui.getCurrentConfig();
if ('apiEndpoints' in currentSchema) {
// 更新现有字段的选项
ui.updateField('apiEndpoints', {
options: mockEndpoints,
description: `${apiUrl} 加载的端点`
});
} else {
// 添加新字段
ui.addField({
key: 'apiEndpoints',
type: 'multi-select',
label: 'API Endpoints',
description: `${apiUrl} 加载的端点`,
options: mockEndpoints,
default: []
}, 'apiUrl');
}
}
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (_ctx, event) => {
if (currentConfig.enableReply === false) {
return;
}
const prefix = currentConfig.prefix || '#napcat';
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith(prefix)) {
return;
}
try {
const versionInfo = await getVersionInfo(adapter, instance.config);
const versionInfo = await getVersionInfo(_ctx.actions, _ctx.adapterName, _ctx.pluginManager.config);
if (!versionInfo) return;
const message = formatVersionMessage(versionInfo);
await sendMessage(event, message, adapter, instance.config);
await sendMessage(_ctx.actions, event, message, _ctx.adapterName, _ctx.pluginManager.config);
console.log('[Plugin: builtin] 已回复版本信息');
logger?.info('已回复版本信息');
} catch (error) {
console.error('[Plugin: builtin] 处理消息时发生错误:', error);
logger?.error('处理消息时发生错误:', error);
}
};
/**
* 获取版本信息(完美的类型推导,无需 as 断言)
*/
async function getVersionInfo (adapter: string, config: any) {
async function getVersionInfo (actions: ActionMap, adapter: string, config: NetworkAdapterConfig) {
if (!actions) return null;
try {
@@ -50,14 +241,11 @@ async function getVersionInfo (adapter: string, config: any) {
protocolVersion: data.protocol_version,
};
} catch (error) {
console.error('[Plugin: builtin] 获取版本信息失败:', error);
logger?.error('获取版本信息失败:', error);
return null;
}
}
/**
* 格式化运行时间
*/
function formatUptime (ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
@@ -75,20 +263,12 @@ function formatUptime (ms: number): string {
}
}
/**
* 格式化版本信息消息
*/
function formatVersionMessage (info: { appName: string; appVersion: string; protocolVersion: string; }) {
const uptime = Date.now() - startTime;
return `NapCat 信息\n版本: ${info.appVersion}\n平台: ${process.platform}${process.arch === 'x64' ? ' (64-bit)' : ''}\n运行时间: ${formatUptime(uptime)}`;
}
/**
* 发送消息(完美的类型推导)
*/
async function sendMessage (event: OB11Message, message: string, adapter: string, config: any) {
if (!actions) return;
async function sendMessage (actions: ActionMap, event: OB11Message, message: string, adapter: string, config: NetworkAdapterConfig) {
const params: OB11PostSendMsg = {
message,
message_type: event.message_type,
@@ -99,7 +279,7 @@ async function sendMessage (event: OB11Message, message: string, adapter: string
try {
await actions.call('send_msg', params, adapter, config);
} catch (error) {
console.error('[Plugin: builtin] 发送消息失败:', error);
logger?.error('发送消息失败:', error);
}
}

View File

@@ -1,12 +1,13 @@
{
"name": "napcat-plugin-builtin",
"plugin": "内置插件",
"version": "1.0.0",
"type": "module",
"main": "index.mjs",
"description": "NapCat 内置插件",
"author": "NapNeko",
"dependencies": {
"napcat-types": "0.0.3"
"napcat-types": "0.0.14"
},
"devDependencies": {
"@types/node": "^22.0.1"

View File

@@ -14,8 +14,10 @@ function copyToShellPlugin () {
writeBundle () {
try {
const sourceDir = resolve(__dirname, 'dist');
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/builtin');
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/napcat-plugin-builtin');
const packageJsonSource = resolve(__dirname, 'package.json');
const webuiSourceDir = resolve(__dirname, 'webui');
const webuiTargetDir = resolve(targetDir, 'webui');
// 确保目标目录存在
if (!fs.existsSync(targetDir)) {
@@ -44,6 +46,12 @@ function copyToShellPlugin () {
copiedCount++;
}
// 拷贝 webui 目录
if (fs.existsSync(webuiSourceDir)) {
copyDirRecursive(webuiSourceDir, webuiTargetDir);
console.log(`[copy-to-shell] Copied webui directory to ${webuiTargetDir}`);
}
console.log(`[copy-to-shell] Successfully copied ${copiedCount} file(s) to ${targetDir}`);
} catch (error) {
console.error('[copy-to-shell] Failed to copy files:', error);
@@ -53,6 +61,26 @@ function copyToShellPlugin () {
};
}
// 递归复制目录
function copyDirRecursive (src: string, dest: string) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = resolve(src, entry.name);
const destPath = resolve(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
export default defineConfig({
resolve: {
conditions: ['node', 'default'],

View File

@@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>内置插件仪表盘</title>
<style>
:root {
--bg-primary: rgba(255, 255, 255, 0.4);
--bg-secondary: rgba(255, 255, 255, 0.6);
--bg-card: rgba(255, 255, 255, 0.5);
--bg-item: rgba(0, 0, 0, 0.03);
--text-primary: #1a1a1a;
--text-secondary: #666;
--text-muted: #999;
--border-color: rgba(0, 0, 0, 0.06);
--accent-color: #52525b;
--accent-light: rgba(82, 82, 91, 0.1);
--success-color: #17c964;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: rgba(0, 0, 0, 0.2);
--bg-secondary: rgba(0, 0, 0, 0.3);
--bg-card: rgba(255, 255, 255, 0.05);
--bg-item: rgba(255, 255, 255, 0.05);
--text-primary: #f5f5f5;
--text-secondary: #a1a1a1;
--text-muted: #666;
--border-color: rgba(255, 255, 255, 0.1);
--accent-light: rgba(82, 82, 91, 0.25);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: transparent;
min-height: 100vh;
padding: 16px;
color: var(--text-primary);
}
.container {
max-width: 900px;
margin: 0 auto;
}
.card {
background: var(--bg-card);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 14px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border-color);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.card-header .icon {
font-size: 20px;
}
.card-header h2 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.status-item {
background: var(--bg-item);
border-radius: 12px;
padding: 16px;
text-align: center;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.status-item:hover {
background: var(--accent-light);
border-color: var(--accent-color);
}
.status-item .label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.status-item .value {
font-size: 18px;
font-weight: 600;
color: var(--accent-color);
word-break: break-all;
}
.status-item .value.success {
color: var(--success-color);
}
.config-list {
list-style: none;
}
.config-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
margin-bottom: 6px;
background: var(--bg-item);
border: 1px solid var(--border-color);
}
.config-list li:last-child {
margin-bottom: 0;
}
.config-list .key {
font-weight: 500;
font-size: 13px;
color: var(--text-secondary);
}
.config-list .value {
color: var(--accent-color);
font-family: 'Monaco', 'Consolas', monospace;
font-size: 12px;
background: var(--accent-light);
padding: 4px 10px;
border-radius: 6px;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
transform: scale(1.02);
}
.actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.loading {
text-align: center;
padding: 30px;
color: var(--text-muted);
font-size: 14px;
}
.loading::after {
content: '';
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--accent-color);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error {
background: rgba(243, 18, 96, 0.1);
color: #f31260;
padding: 12px 16px;
border-radius: 10px;
font-size: 13px;
border: 1px solid rgba(243, 18, 96, 0.2);
}
.footer {
text-align: center;
color: var(--text-muted);
font-size: 11px;
margin-top: 16px;
padding: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h2>NapCat 内置插件仪表盘</h2>
</div>
<div id="content">
<div class="loading">加载中</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>当前配置</h2>
</div>
<div id="config-content">
<div class="loading">加载中</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>静态资源测试</h2>
</div>
<div id="static-content">
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
测试插件静态资源服务是否正常工作
</p>
<div class="actions" style="margin-top: 0;">
<button class="btn btn-primary" onclick="testStaticResource()">
获取 test.txt
</button>
</div>
<div id="static-result" style="margin-top: 12px;"></div>
</div>
</div>
<div class="footer">
<p>NapCat Builtin Plugin - WebUI 扩展页面演示</p>
</div>
</div>
<script>
// 从 URL 参数获取 webui_token
const urlParams = new URLSearchParams(window.location.search);
const webuiToken = urlParams.get('webui_token') || '';
// 插件自行管理 API 调用
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
// 封装 fetch自动携带认证
async function authFetch (url, options = {}) {
const headers = options.headers || {};
if (webuiToken) {
headers['Authorization'] = `Bearer ${webuiToken}`;
}
return fetch(url, { ...options, headers });
}
async function fetchStatus () {
try {
const response = await authFetch(`${apiBase}/status`);
const result = await response.json();
if (result.code === 0) {
renderStatus(result.data);
} else {
showError('获取状态失败: ' + result.message);
}
} catch (error) {
showError('请求失败: ' + error.message);
}
}
async function fetchConfig () {
try {
const response = await authFetch(`${apiBase}/config`);
const result = await response.json();
if (result.code === 0) {
renderConfig(result.data);
} else {
showError('获取配置失败: ' + result.message);
}
} catch (error) {
showError('请求失败: ' + error.message);
}
}
function renderStatus (data) {
const content = document.getElementById('content');
content.innerHTML = `
<div class="status-grid">
<div class="status-item">
<div class="label">插件名称</div>
<div class="value">${data.pluginName}</div>
</div>
<div class="status-item">
<div class="label">运行时间</div>
<div class="value success">${data.uptimeFormatted}</div>
</div>
<div class="status-item">
<div class="label">运行平台</div>
<div class="value">${data.platform}</div>
</div>
<div class="status-item">
<div class="label">系统架构</div>
<div class="value">${data.arch}</div>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="refresh()">
刷新状态
</button>
</div>
`;
}
function renderConfig (config) {
const content = document.getElementById('config-content');
const items = Object.entries(config)
.map(([key, value]) => `
<li>
<span class="key">${key}</span>
<span class="value">${JSON.stringify(value)}</span>
</li>
`)
.join('');
content.innerHTML = `
<ul class="config-list">
${items || '<li><span class="key">暂无配置</span></li>'}
</ul>
`;
}
function showError (message) {
const content = document.getElementById('content');
content.innerHTML = `<div class="error">${message}</div>`;
}
function refresh () {
document.getElementById('content').innerHTML = '<div class="loading">加载中</div>';
fetchStatus();
fetchConfig();
}
// 初始化
refresh();
// 每 30 秒自动刷新
setInterval(refresh, 30000);
// 测试静态资源
async function testStaticResource () {
const resultDiv = document.getElementById('static-result');
resultDiv.innerHTML = '<div class="loading">加载中</div>';
try {
const response = await authFetch(`${apiBase}/static/test.txt`);
if (response.ok) {
const text = await response.text();
resultDiv.innerHTML = `
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
<div style="font-size: 11px; color: var(--success-color); margin-bottom: 8px;">静态资源访问成功</div>
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${text}</pre>
</div>
`;
} else {
resultDiv.innerHTML = `<div class="error">请求失败: ${response.status} ${response.statusText}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
Hello from NapCat Builtin Plugin!
这是一个静态资源测试文件。
如果你能看到这段文字,说明插件的静态资源服务正常工作。
时间戳: 2026-01-30

View File

@@ -1,3 +0,0 @@
# Example Plugin
## Install
安装只需要将dist产物 `index.mjs` 目录放入 napcat根目录下`plugins/example`,如果没有这个目录请创建。

View File

@@ -1,22 +0,0 @@
import type { createActionMap } from 'napcat-types/dist/napcat-onebot/action/index.js';
import { EventType } from 'napcat-types/dist/napcat-onebot/event/index.js';
import type { PluginModule } from 'napcat-types/dist/napcat-onebot/network/plugin-manger';
/**
* 导入 napcat 包时候不使用 @/napcat...,直接使用 napcat...
* 因为 @/napcat... 会导致打包时包含整个 napcat 包,而不是只包含需要的部分
*/
// action 作为参数传递时请用这个
let actionMap: ReturnType<typeof createActionMap> | undefined = undefined;
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
console.log('[Plugin: example] 插件已初始化');
actionMap = _actions;
};
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
}
};
export { plugin_init, plugin_onmessage, actionMap };

View File

@@ -1,16 +0,0 @@
{
"name": "napcat-plugin",
"version": "1.0.0",
"type": "module",
"main": "index.mjs",
"description": "一个高级的 NapCat 插件示例",
"dependencies": {
"napcat-types": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"scripts": {
"build": "vite build"
}
}

View File

@@ -1,30 +0,0 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module';
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
export default defineConfig({
resolve: {
conditions: ['node', 'default'],
alias: {
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@': resolve(__dirname, '../'),
},
},
build: {
sourcemap: false,
target: 'esnext',
minify: false,
lib: {
entry: 'index.ts',
formats: ['es'],
fileName: () => 'index.mjs',
},
rollupOptions: {
external: [...nodeModules],
},
},
plugins: [nodeResolve()],
});

View File

@@ -0,0 +1,51 @@
import { NapCatCore } from 'napcat-core';
import { NapCatProtocolResponse } from '@/napcat-protocol/types';
// 前向声明类型,避免循环依赖
import type { NapCatProtocolAdapter } from '@/napcat-protocol/index';
// Action 基类
export abstract class BaseAction<PayloadType = unknown, ReturnType = unknown> {
abstract actionName: string;
protected core: NapCatCore;
protected adapter: NapCatProtocolAdapter;
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
this.adapter = adapter;
this.core = core;
}
protected abstract _handle (payload: PayloadType): Promise<ReturnType>;
async handle (payload: PayloadType): Promise<NapCatProtocolResponse<ReturnType>> {
try {
const result = await this._handle(payload);
return {
status: 'ok',
retcode: 0,
data: result,
};
} catch (e) {
return {
status: 'failed',
retcode: -1,
data: null,
message: e instanceof Error ? e.message : String(e),
};
}
}
}
// Action 映射类型
export type ActionMap = Map<string, BaseAction<unknown, unknown>>;
// 创建 Action 映射
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function createActionMap (_adapter: NapCatProtocolAdapter, _core: NapCatCore): ActionMap {
const actionMap = new Map<string, BaseAction<unknown, unknown>>();
// 这里可以注册各种 Action
// 例如: actionMap.set('send_msg', new SendMsgAction(adapter, core));
return actionMap;
}

View File

@@ -0,0 +1,49 @@
import { NapCatCore } from 'napcat-core';
import { NapCatProtocolAdapter } from '@/napcat-protocol/index';
// NapCat Protocol API 基类
export abstract class NapCatProtocolApiBase {
protected adapter: NapCatProtocolAdapter;
protected core: NapCatCore;
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
this.adapter = adapter;
this.core = core;
}
}
// 消息 API
export class NapCatProtocolMsgApi extends NapCatProtocolApiBase {
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
super(adapter, core);
}
// 消息相关 API 方法可以在这里实现
}
// 用户 API
export class NapCatProtocolUserApi extends NapCatProtocolApiBase {
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
super(adapter, core);
}
// 用户相关 API 方法可以在这里实现
}
// 群组 API
export class NapCatProtocolGroupApi extends NapCatProtocolApiBase {
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
super(adapter, core);
}
// 群组相关 API 方法可以在这里实现
}
// 好友 API
export class NapCatProtocolFriendApi extends NapCatProtocolApiBase {
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
super(adapter, core);
}
// 好友相关 API 方法可以在这里实现
}

View File

@@ -0,0 +1,66 @@
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
// WebSocket 服务端配置
const WebsocketServerConfigSchema = Type.Object({
name: Type.String({ default: 'napcat-ws-server' }),
enable: Type.Boolean({ default: false }),
host: Type.String({ default: '127.0.0.1' }),
port: Type.Number({ default: 6700 }),
token: Type.String({ default: '' }),
heartInterval: Type.Number({ default: 30000 }),
debug: Type.Boolean({ default: false }),
});
// WebSocket 客户端配置
const WebsocketClientConfigSchema = Type.Object({
name: Type.String({ default: 'napcat-ws-client' }),
enable: Type.Boolean({ default: false }),
url: Type.String({ default: 'ws://localhost:6701' }),
token: Type.String({ default: '' }),
reconnectInterval: Type.Number({ default: 5000 }),
heartInterval: Type.Number({ default: 30000 }),
debug: Type.Boolean({ default: false }),
});
// HTTP 服务端配置
const HttpServerConfigSchema = Type.Object({
name: Type.String({ default: 'napcat-http-server' }),
enable: Type.Boolean({ default: false }),
host: Type.String({ default: '127.0.0.1' }),
port: Type.Number({ default: 6702 }),
token: Type.String({ default: '' }),
enableCors: Type.Boolean({ default: true }),
debug: Type.Boolean({ default: false }),
});
// 网络配置
const NetworkConfigSchema = Type.Object({
httpServers: Type.Array(HttpServerConfigSchema, { default: [] }),
websocketServers: Type.Array(WebsocketServerConfigSchema, { default: [] }),
websocketClients: Type.Array(WebsocketClientConfigSchema, { default: [] }),
}, { default: {} });
// NapCat Protocol 主配置 - 默认关闭
export const NapCatProtocolConfigSchema = Type.Object({
enable: Type.Boolean({ default: false }), // 默认关闭
network: NetworkConfigSchema,
});
export type NapCatProtocolConfig = Static<typeof NapCatProtocolConfigSchema>;
export type HttpServerConfig = Static<typeof HttpServerConfigSchema>;
export type WebsocketServerConfig = Static<typeof WebsocketServerConfigSchema>;
export type WebsocketClientConfig = Static<typeof WebsocketClientConfigSchema>;
export type NetworkAdapterConfig = HttpServerConfig | WebsocketServerConfig | WebsocketClientConfig;
export type NetworkConfigKey = keyof NapCatProtocolConfig['network'];
export function loadConfig (config: Partial<NapCatProtocolConfig>): NapCatProtocolConfig {
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
const validate = ajv.compile(NapCatProtocolConfigSchema);
const valid = validate(config);
if (!valid) {
throw new Error(ajv.errorsText(validate.errors));
}
return config as NapCatProtocolConfig;
}

View File

@@ -0,0 +1,11 @@
import { ConfigBase } from 'napcat-core';
import { NapCatCore } from 'napcat-core';
import { NapCatProtocolConfig, NapCatProtocolConfigSchema } from './config';
export class NapCatProtocolConfigLoader extends ConfigBase<NapCatProtocolConfig> {
constructor (core: NapCatCore, configPath: string) {
super('napcat_protocol', core, configPath, NapCatProtocolConfigSchema);
}
}
export * from './config';

View File

@@ -0,0 +1,66 @@
import { NapCatCore } from 'napcat-core';
// NapCat Protocol 事件基类
export abstract class NapCatProtocolEvent {
protected core: NapCatCore;
public time: number;
public self_id: number;
public post_type: string;
constructor (core: NapCatCore) {
this.core = core;
this.time = Math.floor(Date.now() / 1000);
this.self_id = parseInt(core.selfInfo.uin);
this.post_type = 'event';
}
abstract toJSON (): Record<string, unknown>;
}
// 消息事件基类
export abstract class NapCatProtocolMessageEvent extends NapCatProtocolEvent {
public message_type: 'private' | 'group';
public message_id: number;
public user_id: number;
constructor (core: NapCatCore, messageType: 'private' | 'group', messageId: number, userId: number) {
super(core);
this.post_type = 'message';
this.message_type = messageType;
this.message_id = messageId;
this.user_id = userId;
}
}
// 通知事件基类
export abstract class NapCatProtocolNoticeEvent extends NapCatProtocolEvent {
public notice_type: string;
constructor (core: NapCatCore, noticeType: string) {
super(core);
this.post_type = 'notice';
this.notice_type = noticeType;
}
}
// 请求事件基类
export abstract class NapCatProtocolRequestEvent extends NapCatProtocolEvent {
public request_type: string;
constructor (core: NapCatCore, requestType: string) {
super(core);
this.post_type = 'request';
this.request_type = requestType;
}
}
// 元事件基类
export abstract class NapCatProtocolMetaEvent extends NapCatProtocolEvent {
public meta_event_type: string;
constructor (core: NapCatCore, metaEventType: string) {
super(core);
this.post_type = 'meta_event';
this.meta_event_type = metaEventType;
}
}

View File

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

View File

@@ -0,0 +1,104 @@
import {
InstanceContext,
NapCatCore,
} from 'napcat-core';
import { NapCatProtocolConfigLoader, NapCatProtocolConfig } from '@/napcat-protocol/config';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import {
NapCatProtocolNetworkManager,
} from '@/napcat-protocol/network';
import {
NapCatProtocolMsgApi,
NapCatProtocolUserApi,
NapCatProtocolGroupApi,
NapCatProtocolFriendApi,
} from '@/napcat-protocol/api';
import { ActionMap, createActionMap } from '@/napcat-protocol/action';
interface ApiListType {
MsgApi: NapCatProtocolMsgApi;
UserApi: NapCatProtocolUserApi;
GroupApi: NapCatProtocolGroupApi;
FriendApi: NapCatProtocolFriendApi;
}
// NapCat Protocol 适配器 - NapCat 私有 Bot 协议实现
export class NapCatProtocolAdapter {
readonly core: NapCatCore;
readonly context: InstanceContext;
configLoader: NapCatProtocolConfigLoader;
public apis: ApiListType;
networkManager: NapCatProtocolNetworkManager;
actions: ActionMap;
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.configLoader = new NapCatProtocolConfigLoader(core, pathWrapper.configPath);
this.apis = {
MsgApi: new NapCatProtocolMsgApi(this, core),
UserApi: new NapCatProtocolUserApi(this, core),
GroupApi: new NapCatProtocolGroupApi(this, core),
FriendApi: new NapCatProtocolFriendApi(this, core),
} as const;
this.actions = createActionMap(this, core);
this.networkManager = new NapCatProtocolNetworkManager();
}
// 检查协议是否启用
isEnabled (): boolean {
return this.configLoader.configData.enable;
}
async createProtocolLog (config: NapCatProtocolConfig) {
let log = '[NapCat Protocol] 配置加载\n';
log += `协议状态: ${config.enable ? '已启用' : '已禁用'}\n`;
if (config.enable) {
for (const key of config.network.httpServers) {
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
}
for (const key of config.network.websocketServers) {
log += `WebSocket服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
}
for (const key of config.network.websocketClients) {
log += `WebSocket客户端: ${key.url}, : ${key.enable ? '已启动' : '未启动'}\n`;
}
}
return log;
}
async initProtocol () {
const config = this.configLoader.configData;
// 如果协议未启用,直接返回
if (!config.enable) {
this.context.logger.log('[NapCat Protocol] 协议未启用,跳过初始化');
return;
}
const selfInfo = this.core.selfInfo;
const serviceInfo = await this.createProtocolLog(config);
this.context.logger.log(`[Notice] ${serviceInfo}`);
// 注册网络适配器
// 这里可以根据配置注册不同的网络适配器
// 例如: WebSocket Server, WebSocket Client, HTTP Server 等
await this.networkManager.openAllAdapters();
this.context.logger.log(`[NapCat Protocol] 初始化完成Bot: ${selfInfo.uin}`);
}
async close () {
await this.networkManager.closeAllAdapters();
this.context.logger.log('[NapCat Protocol] 已关闭所有网络适配器');
}
}
export * from './types/index';
export * from './api/index';
export * from './event/index';
export * from './config/index';
export * from './network/index';

View File

@@ -0,0 +1,37 @@
import { NetworkAdapterConfig } from '@/napcat-protocol/config/config';
import { LogWrapper } from 'napcat-core/helper/log';
import { NapCatCore } from 'napcat-core';
import { NapCatProtocolAdapter } from '@/napcat-protocol/index';
import { ActionMap } from '@/napcat-protocol/action';
import { NapCatProtocolEmitEventContent, NapCatProtocolNetworkReloadType } from '@/napcat-protocol/network/index';
export abstract class INapCatProtocolNetworkAdapter<CT extends NetworkAdapterConfig> {
name: string;
isEnable: boolean = false;
config: CT;
readonly logger: LogWrapper;
readonly core: NapCatCore;
readonly protocolContext: NapCatProtocolAdapter;
readonly actions: ActionMap;
constructor (name: string, config: CT, core: NapCatCore, protocolContext: NapCatProtocolAdapter, actions: ActionMap) {
this.name = name;
this.config = structuredClone(config);
this.core = core;
this.protocolContext = protocolContext;
this.actions = actions;
this.logger = core.context.logger;
}
abstract onEvent<T extends NapCatProtocolEmitEventContent> (event: T): Promise<void>;
abstract open (): void | Promise<void>;
abstract close (): void | Promise<void>;
abstract reload (config: unknown): NapCatProtocolNetworkReloadType | Promise<NapCatProtocolNetworkReloadType>;
get isActive (): boolean {
return this.isEnable;
}
}

View File

@@ -0,0 +1,112 @@
import { NapCatProtocolEvent } from '@/napcat-protocol/event/NapCatProtocolEvent';
import { NapCatProtocolMessage } from '@/napcat-protocol/types';
import { NetworkAdapterConfig } from '@/napcat-protocol/config/config';
import { INapCatProtocolNetworkAdapter } from '@/napcat-protocol/network/adapter';
export type NapCatProtocolEmitEventContent = NapCatProtocolEvent | NapCatProtocolMessage;
export enum NapCatProtocolNetworkReloadType {
Normal = 0,
ConfigChange = 1,
NetWorkReload = 2,
NetWorkClose = 3,
NetWorkOpen = 4,
}
export class NapCatProtocolNetworkManager {
adapters: Map<string, INapCatProtocolNetworkAdapter<NetworkAdapterConfig>> = new Map();
async openAllAdapters () {
return Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.open()));
}
async emitEvent (event: NapCatProtocolEmitEventContent) {
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
if (adapter.isActive) {
return await adapter.onEvent(event);
}
}));
}
async emitEvents (events: NapCatProtocolEmitEventContent[]) {
return Promise.all(events.map(event => this.emitEvent(event)));
}
async emitEventByName (names: string[], event: NapCatProtocolEmitEventContent) {
return Promise.all(names.map(async name => {
const adapter = this.adapters.get(name);
if (adapter && adapter.isActive) {
return await adapter.onEvent(event);
}
}));
}
async emitEventByNames (map: Map<string, NapCatProtocolEmitEventContent>) {
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
const adapter = this.adapters.get(name);
if (adapter && adapter.isActive) {
return await adapter.onEvent(event);
}
}));
}
registerAdapter<CT extends NetworkAdapterConfig> (adapter: INapCatProtocolNetworkAdapter<CT>) {
this.adapters.set(adapter.name, adapter);
}
async registerAdapterAndOpen<CT extends NetworkAdapterConfig> (adapter: INapCatProtocolNetworkAdapter<CT>) {
this.registerAdapter(adapter);
await adapter.open();
}
async closeSomeAdapters<CT extends NetworkAdapterConfig> (adaptersToClose: INapCatProtocolNetworkAdapter<CT>[]) {
for (const adapter of adaptersToClose) {
this.adapters.delete(adapter.name);
await adapter.close();
}
}
async closeSomeAdapterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: INapCatProtocolNetworkAdapter<CT>[]) {
for (const adapter of adaptersToClose) {
this.adapters.delete(adapter.name);
if (adapter.isEnable) {
await adapter.close();
}
}
}
findSomeAdapter (name: string) {
return this.adapters.get(name);
}
async closeAdapterByPredicate (closeFilter: (adapter: INapCatProtocolNetworkAdapter<NetworkAdapterConfig>) => boolean) {
const adaptersToClose = Array.from(this.adapters.values()).filter(closeFilter);
await this.closeSomeAdapters(adaptersToClose);
}
async closeAllAdapters () {
await Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.close()));
this.adapters.clear();
}
async reloadAdapter<T> (name: string, config: T) {
const adapter = this.adapters.get(name);
if (adapter) {
await adapter.reload(config);
}
}
async reloadSomeAdapters<T> (configMap: Map<string, T>) {
await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.reloadAdapter(name, config)));
}
hasActiveAdapters (): boolean {
return Array.from(this.adapters.values()).some(adapter => adapter.isActive);
}
async getAllConfig () {
return Array.from(this.adapters.values()).map(adapter => adapter.config);
}
}
export * from './adapter';

View File

@@ -0,0 +1,36 @@
{
"name": "napcat-protocol",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./index.ts"
},
"./*": {
"import": "./*"
}
},
"dependencies": {
"ajv": "^8.13.0",
"@sinclair/typebox": "^0.34.38",
"cors": "^2.8.5",
"express": "^5.0.0",
"ws": "^8.18.3",
"json5": "^2.2.3",
"napcat-core": "workspace:*",
"napcat-common": "workspace:*"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

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

View File

@@ -0,0 +1,70 @@
// NapCat Protocol 消息类型定义
export interface NapCatProtocolMessage {
post_type: 'message' | 'notice' | 'request' | 'meta_event';
time: number;
self_id: number;
message_type?: 'private' | 'group';
sub_type?: string;
message_id?: number;
user_id?: number;
group_id?: number;
message?: NapCatProtocolMessageSegment[] | string;
raw_message?: string;
sender?: NapCatProtocolSender;
}
export interface NapCatProtocolMessageSegment {
type: string;
data: Record<string, unknown>;
}
export interface NapCatProtocolSender {
user_id: number;
nickname: string;
card?: string;
sex?: 'male' | 'female' | 'unknown';
age?: number;
area?: string;
level?: string;
role?: 'owner' | 'admin' | 'member';
title?: string;
}
// API 请求类型
export interface NapCatProtocolRequest {
action: string;
params?: Record<string, unknown>;
echo?: string | number;
}
// API 响应类型
export interface NapCatProtocolResponse<T = unknown> {
status: 'ok' | 'failed';
retcode: number;
data: T | null;
message?: string;
echo?: string | number;
}
// 心跳事件
export interface NapCatProtocolHeartbeat {
post_type: 'meta_event';
meta_event_type: 'heartbeat';
time: number;
self_id: number;
status: {
online: boolean;
good: boolean;
};
interval: number;
}
// 生命周期事件
export interface NapCatProtocolLifecycle {
post_type: 'meta_event';
meta_event_type: 'lifecycle';
time: number;
self_id: number;
sub_type: 'connect' | 'enable' | 'disable';
}

View File

@@ -105,7 +105,8 @@ export function generateOpenAPI () {
retcode: 0,
data: schemas.returnExample || {},
message: '',
wording: ''
wording: '',
stream: 'normal-action'
}
}
};
@@ -119,7 +120,8 @@ export function generateOpenAPI () {
retcode: error.code,
data: null,
message: error.description,
wording: error.description
wording: error.description,
stream: 'normal-action'
}
};
});
@@ -132,7 +134,8 @@ export function generateOpenAPI () {
retcode: 1400,
data: null,
message: '请求参数错误或业务逻辑执行失败',
wording: '请求参数错误或业务逻辑执行失败'
wording: '请求参数错误或业务逻辑执行失败',
stream: 'normal-action'
}
};
}
@@ -171,7 +174,8 @@ export function generateOpenAPI () {
retcode: { type: 'number', description: '返回码' },
data: { ...cleanReturn, description: '数据' },
message: { type: 'string', description: '消息' },
wording: { type: 'string', description: '提示' }
wording: { type: 'string', description: '提示' },
stream: { type: 'string', description: '流式响应', enum: ['stream-action', 'normal-action'] }
},
required: ['status', 'retcode', 'data']
},

View File

@@ -15,12 +15,11 @@ export default defineConfig({
resolve: {
conditions: ['node', 'default'],
alias: {
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@/napcat-common': resolve(__dirname, '../napcat-common'),
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
'@/napcat-common': resolve(__dirname, '../napcat-common'),
'@/napcat-schema': resolve(__dirname, './src'),
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
'@/image-size': resolve(__dirname, '../image-size'),
},
},
plugins: [

View File

@@ -22,7 +22,7 @@ import fs from 'fs';
import os from 'os';
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
import qrcode from 'napcat-qrcode/lib/main';
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
import { NapCatAdapterManager } from 'napcat-adapter';
import { InitWebUi } from 'napcat-webui-backend/index';
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
import { napCatVersion } from 'napcat-common/src/version';
@@ -475,10 +475,14 @@ export class NapCatShell {
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));
// 使用 NapCatAdapterManager 统一管理协议适配器
const adapterManager = new NapCatAdapterManager(this.core, this.context, this.context.pathWrapper);
await adapterManager.initAdapters()
.catch(e => this.context.logger.logError('初始化协议适配器失败', e));
// 注册 OneBot 适配器到 WebUiDataRuntime供调试功能使用
const oneBotAdapter = adapterManager.getOneBotAdapter();
if (oneBotAdapter) {
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
}
}
}

View File

@@ -38,6 +38,11 @@ let isElectron = false;
let isRestarting = false;
let isShuttingDown = false;
// 进程崩溃保护:记录最近的异常退出时间戳
const recentCrashTimestamps: number[] = [];
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
/**
* 获取进程类型名称(用于日志)
*/
@@ -217,7 +222,23 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
}
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
if (!isRestarting && !isShuttingDown) {
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出正在尝试重新拉起...`);
const now = Date.now();
// 清理超出时间窗口的崩溃记录
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
recentCrashTimestamps.shift();
}
// 记录本次崩溃
recentCrashTimestamps.push(now);
// 检查是否超过崩溃阈值
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
process.exit(1);
}
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
startWorker(true).catch(e => {
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
});

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-adapter": "workspace:*",
"napcat-webui-backend": "workspace:*",
"napcat-qrcode": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1",
"napcat-vite": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -47,6 +47,7 @@ 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-protocol': resolve(__dirname, '../napcat-protocol'),
},
},
build: {

View File

@@ -1,4 +1,3 @@
/// <reference path="./external-shims.d.ts" />
// 聚合导出核心库的所有内容(包括枚举、类和类型)
export * from '../napcat-core/index';

View File

@@ -14,13 +14,6 @@
},
"dependencies": {
"@types/node": "^22.10.7",
"@types/express": "^4.17.21",
"@types/ws": "^8.5.12",
"@types/cors": "^2.8.17",
"@types/multer": "^1.4.12",
"@types/winston": "^2.4.4",
"@types/yaml": "^1.9.7",
"@types/ip": "^1.1.3",
"@sinclair/typebox": "^0.34.38"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "napcat-types",
"version": "0.0.4",
"version": "0.0.14",
"private": false,
"type": "module",
"types": "./napcat-types/index.d.ts",
@@ -9,13 +9,6 @@
],
"dependencies": {
"@types/node": "^22.10.7",
"@types/express": "^4.17.21",
"@types/ws": "^8.5.12",
"@types/cors": "^2.8.17",
"@types/multer": "^1.4.12",
"@types/winston": "^2.4.4",
"@types/yaml": "^1.9.7",
"@types/ip": "^1.1.3",
"@sinclair/typebox": "^0.34.38"
},
"publishConfig": {

View File

@@ -1,4 +1,4 @@
// 复制 cp README.md dist/ && cp package.public.json dist/package.json && cp external-shims.d.ts dist/
// 复制 cp README.md dist/ && cp package.public.json dist/package.json
import { copyFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -10,8 +10,4 @@ await copyFile(
await copyFile(
join(__dirname, 'README.md'),
join(__dirname, 'dist', 'README.md')
);
await copyFile(
join(__dirname, 'external-shims.d.ts'),
join(__dirname, 'dist', 'external-shims.d.ts')
);

View File

@@ -5,6 +5,111 @@ import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('../', import.meta.url));
const distDir = join(__dirname, 'dist');
// 允许保留的包(白名单)
const ALLOWED_PACKAGES = [
'@sinclair/typebox',
'node:', // node: 前缀的内置模块
];
// 外部包类型到 any 的映射
const EXTERNAL_TYPE_REPLACEMENTS = {
// winston
'winston.Logger': 'any',
'winston.transport': 'any',
// express
'express.Express': 'any',
'express.Application': 'any',
'express.Router': 'any',
'Express': 'any',
'Request': 'any',
'Response': 'any',
'NextFunction': 'any',
// ws
'WebSocket': 'any',
'WebSocketServer': 'any',
'RawData': 'any',
// ajv
'Ajv': 'any',
'AnySchema': 'any',
'ValidateFunction': 'any',
'ValidateFunction<T>': 'any',
// inversify
'Container': 'any',
// async-mutex
'Mutex': 'any',
'Semaphore': 'any',
// napcat-protobuf
'NapProtoDecodeStructType': 'any',
'NapProtoEncodeStructType': 'any',
'NapProtoDecodeStructType<T>': 'any',
'NapProtoEncodeStructType<T>': 'any',
};
function isAllowedImport (importPath) {
return ALLOWED_PACKAGES.some(pkg => importPath.startsWith(pkg));
}
function removeExternalImports (content) {
const lines = content.split('\n');
const resultLines = [];
for (const line of lines) {
// 匹配 import 语句
const importMatch = line.match(/^import\s+.*\s+from\s+['"]([^'"]+)['"]/);
if (importMatch) {
const importPath = importMatch[1];
// 如果是相对路径或白名单包,保留
if (importPath.startsWith('.') || importPath.startsWith('/') || isAllowedImport(importPath)) {
resultLines.push(line);
}
// 否则移除该 import
continue;
}
resultLines.push(line);
}
return resultLines.join('\n');
}
function replaceExternalTypes (content) {
let result = content;
// 替换带泛型的类型(先处理复杂的)
result = result.replace(/NapProtoDecodeStructType<[^>]+>/g, 'any');
result = result.replace(/NapProtoEncodeStructType<[^>]+>/g, 'any');
result = result.replace(/ValidateFunction<[^>]+>/g, 'any');
// 替换 winston.Logger 等带命名空间的类型
result = result.replace(/winston\.Logger/g, 'any');
result = result.replace(/winston\.transport/g, 'any');
result = result.replace(/express\.Express/g, 'any');
result = result.replace(/express\.Application/g, 'any');
result = result.replace(/express\.Router/g, 'any');
// 替换独立的类型名(需要小心不要替换变量名)
// 使用类型上下文的模式匹配
const typeContextPatterns = [
// : Type
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
// <Type>
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
// Type[]
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
// extends Type
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
// implements Type
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
];
for (const pattern of typeContextPatterns) {
result = result.replace(pattern, (match, typeName) => {
return match.replace(typeName, 'any');
});
}
return result;
}
async function traverseDirectory (dir) {
const entries = await readdir(dir, { withFileTypes: true });
@@ -23,7 +128,13 @@ async function processFile (filePath) {
// Read file content
let content = await readFile(filePath, 'utf-8');
// Replace "export declare enum" with "export enum"
// 1. 移除外部包的 import
content = removeExternalImports(content);
// 2. 替换外部类型为 any
content = replaceExternalTypes(content);
// 3. Replace "export declare enum" with "export enum"
content = content.replace(/export declare enum/g, 'export enum');
// Write back the modified content
@@ -33,7 +144,7 @@ async function processFile (filePath) {
const newPath = filePath.replace(/\.d\.ts$/, '.ts');
await rename(filePath, newPath);
console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`);
//console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`);
}
console.log('Starting post-build processing...');

View File

@@ -39,9 +39,6 @@
"../napcat-onebot/**/*.ts",
"../napcat-common/**/*.ts"
],
"files": [
"./external-shims.d.ts"
],
"exclude": [
"node_modules",
"dist"

View File

@@ -0,0 +1,230 @@
import { RequestHandler } from 'express';
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
import {
GITHUB_FILE_MIRRORS,
GITHUB_RAW_MIRRORS,
buildMirrorUrl,
getMirrorConfig,
setCustomMirror,
clearMirrorCache
} from 'napcat-common/src/mirror';
import https from 'https';
import http from 'http';
export interface MirrorTestResult {
mirror: string;
latency: number;
success: boolean;
error?: string;
}
/**
* 测试单个镜像的延迟
*/
async function testMirrorLatency (mirror: string, testUrl: string, timeout: number = 5000): Promise<MirrorTestResult> {
const url = mirror ? buildMirrorUrl(testUrl, mirror) : testUrl;
const start = Date.now();
return new Promise<MirrorTestResult>((resolve) => {
try {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http;
const req = client.request({
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'HEAD',
timeout,
headers: {
'User-Agent': 'NapCat-Mirror-Test',
},
}, (res) => {
const statusCode = res.statusCode || 0;
const isValid = statusCode >= 200 && statusCode < 400;
resolve({
mirror: mirror || 'https://github.com',
latency: Date.now() - start,
success: isValid,
});
});
req.on('error', (e) => {
resolve({
mirror: mirror || 'https://github.com',
latency: Date.now() - start,
success: false,
error: e.message,
});
});
req.on('timeout', () => {
req.destroy();
resolve({
mirror: mirror || 'https://github.com',
latency: timeout,
success: false,
error: 'Timeout',
});
});
req.end();
} catch (e: any) {
resolve({
mirror: mirror || 'https://github.com',
latency: Date.now() - start,
success: false,
error: e.message,
});
}
});
}
/**
* 获取所有可用的镜像列表
*/
export const GetMirrorListHandler: RequestHandler = async (_req, res) => {
try {
const config = getMirrorConfig();
return sendSuccess(res, {
fileMirrors: GITHUB_FILE_MIRRORS.filter(m => m),
rawMirrors: GITHUB_RAW_MIRRORS,
customMirror: config.customMirror,
timeout: config.timeout,
});
} catch (e: any) {
return sendError(res, e.message);
}
};
/**
* 设置自定义镜像
*/
export const SetCustomMirrorHandler: RequestHandler = async (req, res) => {
try {
const { mirror } = req.body;
setCustomMirror(mirror || '');
clearMirrorCache();
return sendSuccess(res, { message: 'Mirror set successfully' });
} catch (e: any) {
return sendError(res, e.message);
}
};
/**
* SSE 实时测速所有镜像
*/
export const TestMirrorsSSEHandler: RequestHandler = async (req, res) => {
const { type = 'file' } = req.query;
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendProgress = (data: any) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
try {
// 选择镜像列表
let mirrors: string[];
let testUrl: string;
if (type === 'raw') {
mirrors = GITHUB_RAW_MIRRORS;
testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md';
} else {
mirrors = GITHUB_FILE_MIRRORS.filter(m => m);
testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
}
// 添加原始 URL 测试
if (!mirrors.includes('')) {
mirrors = ['', ...mirrors];
}
sendProgress({
type: 'start',
total: mirrors.length,
message: `开始测试 ${mirrors.length} 个镜像源...`,
});
const results: MirrorTestResult[] = [];
const timeout = 5000;
// 逐个测试并实时推送结果
for (let i = 0; i < mirrors.length; i++) {
const mirror = mirrors[i] ?? '';
const displayName = mirror || 'https://github.com (原始)';
sendProgress({
type: 'testing',
index: i,
total: mirrors.length,
mirror: displayName,
message: `正在测试: ${displayName}`,
});
const result = await testMirrorLatency(mirror, testUrl, timeout);
results.push(result);
sendProgress({
type: 'result',
index: i,
total: mirrors.length,
result: {
...result,
mirror: result.mirror || 'https://github.com (原始)',
},
});
}
// 按延迟排序
const sortedResults = results
.filter(r => r.success)
.sort((a, b) => a.latency - b.latency);
const failedResults = results.filter(r => !r.success);
sendProgress({
type: 'complete',
results: sortedResults,
failed: failedResults,
fastest: sortedResults[0] || null,
message: `测试完成!${sortedResults.length} 个可用,${failedResults.length} 个失败`,
});
res.end();
} catch (e: any) {
sendProgress({
type: 'error',
error: e.message,
});
res.end();
}
};
/**
* 快速测试单个镜像
*/
export const TestSingleMirrorHandler: RequestHandler = async (req, res) => {
try {
const { mirror, type = 'file' } = req.body;
let testUrl: string;
if (type === 'raw') {
testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md';
} else {
testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
}
const result = await testMirrorLatency(mirror || '', testUrl, 5000);
return sendSuccess(res, result);
} catch (e: any) {
return sendError(res, e.message);
}
};

View File

@@ -3,8 +3,10 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import path from 'path';
import fs from 'fs';
import compressing from 'compressing';
// Helper to get the plugin manager adapter
const getPluginManager = (): OB11PluginMangerAdapter | null => {
@@ -13,170 +15,145 @@ const getPluginManager = (): OB11PluginMangerAdapter | null => {
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
};
// Helper to get OneBot context
const getOneBotContext = (): NapCatOneBot11Adapter | null => {
return WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
};
/**
* 手动注册插件管理器到 NetworkManager
*/
export const RegisterPluginManagerHandler: RequestHandler = async (_req, res) => {
const ob11 = getOneBotContext();
if (!ob11) {
return sendError(res, 'OneBot context not found');
}
// 检查是否已经注册
const existingManager = ob11.networkManager.findSomeAdapter('plugin_manager');
if (existingManager) {
return sendError(res, '插件管理器已经注册');
}
try {
// 确保插件目录存在
const pluginPath = webUiPathWrapper.pluginPath;
if (!fs.existsSync(pluginPath)) {
fs.mkdirSync(pluginPath, { recursive: true });
}
// 创建并注册插件管理器
const pluginManager = new OB11PluginMangerAdapter(
'plugin_manager',
ob11.core,
ob11,
ob11.actions
);
await ob11.networkManager.registerAdapterAndOpen(pluginManager);
return sendSuccess(res, { message: '插件管理器注册成功' });
} catch (e: any) {
return sendError(res, '注册插件管理器失败: ' + e.message);
}
};
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
// 返回成功但带特殊标记
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true });
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true, extensionPages: [] });
}
// 辅助函数:根据文件名/路径生成唯一ID作为配置键
const getPluginId = (fsName: string, isFile: boolean): string => {
if (isFile) {
return path.parse(fsName).name;
}
return fsName;
};
const loadedPlugins = pluginManager.getAllPlugins();
const AllPlugins: Array<{
name: string;
id: string;
version: string;
description: string;
author: string;
status: string;
hasConfig: boolean;
hasPages: boolean;
}> = new Array();
const loadedPlugins = pluginManager.getLoadedPlugins();
const loadedPluginMap = new Map<string, any>(); // Map ID -> Loaded Info
// 收集所有插件的扩展页面
const extensionPages: Array<{
pluginId: string;
pluginName: string;
path: string;
title: string;
icon?: string;
description?: string;
}> = [];
// 1. 整理已加载的插件
for (const p of loadedPlugins) {
// 计算 ID需要回溯到加载时的入口信息
// 对于已加载的插件,我们通过判断 pluginPath 是否等于根 pluginPath 来判断它是单文件还是目录
const isFilePlugin = p.pluginPath === pluginManager.getPluginPath();
const fsName = isFilePlugin ? path.basename(p.entryPath) : path.basename(p.pluginPath);
const id = getPluginId(fsName, isFilePlugin);
// 根据插件状态确定 status
let status: string;
if (!p.enable) {
status = 'disabled';
} else if (p.loaded) {
status = 'active';
} else {
status = 'stopped'; // 启用但未加载(可能加载失败)
}
loadedPluginMap.set(id, {
name: p.packageJson?.name || p.name, // 优先使用 package.json 的 name
id: id,
// 检查插件是否有注册页面
const pluginRouter = pluginManager.getPluginRouter(p.id);
const hasPages = pluginRouter?.hasPages() ?? false;
AllPlugins.push({
name: p.packageJson?.plugin || p.name || '', // 优先显示 package.json 的 plugin 字段
id: p.id, // 包名,用于 API 操作
version: p.version || '0.0.0',
description: p.packageJson?.description || '',
author: p.packageJson?.author || '',
status: 'active',
filename: fsName, // 真实文件/目录名
loadedName: p.name // 运行时注册的名称,用于重载/卸载
status,
hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui),
hasPages
});
}
const pluginPath = pluginManager.getPluginPath();
const pluginConfig = pluginManager.getPluginConfig();
const allPlugins: any[] = [];
// 2. 扫描文件系统,合并状态
if (fs.existsSync(pluginPath)) {
const items = fs.readdirSync(pluginPath, { withFileTypes: true });
for (const item of items) {
let id = '';
if (item.isFile()) {
if (!['.js', '.mjs'].includes(path.extname(item.name))) continue;
id = getPluginId(item.name, true);
} else if (item.isDirectory()) {
id = getPluginId(item.name, false);
} else {
continue;
}
const isActiveConfig = pluginConfig[id] !== false; // 默认为 true
if (loadedPluginMap.has(id)) {
// 已加载,使用加载的信息
const loadedInfo = loadedPluginMap.get(id);
allPlugins.push(loadedInfo);
} else {
// 未加载 (可能是被禁用,或者加载失败,或者新增未运行)
let version = '0.0.0';
let description = '';
let author = '';
// 默认显示名称为 ID (文件名/目录名)
let name = id;
try {
// 尝试读取 package.json 获取信息
if (item.isDirectory()) {
const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
version = pkg.version || version;
description = pkg.description || description;
author = pkg.author || author;
// 如果 package.json 有 name优先使用
name = pkg.name || name;
}
}
} catch (e) { }
allPlugins.push({
name: name,
id: id,
version,
description,
author,
// 如果配置是 false则为 disabled否则是 stopped (应启动但未启动)
status: isActiveConfig ? 'stopped' : 'disabled',
filename: item.name
// 收集插件的扩展页面
if (hasPages && pluginRouter) {
const pages = pluginRouter.getPages();
for (const page of pages) {
extensionPages.push({
pluginId: p.id,
pluginName: p.packageJson?.plugin || p.name || p.id,
path: page.path,
title: page.title,
icon: page.icon,
description: page.description
});
}
}
}
return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false });
};
export const ReloadPluginHandler: RequestHandler = async (req, res) => {
const { name } = req.body;
// Note: we should probably accept ID or Name. But ReloadPlugin uses valid loaded name.
// Let's stick to name for now, but be aware of ambiguity.
if (!name) return sendError(res, 'Plugin Name is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, '插件管理器未加载,请检查 plugins 目录是否存在');
}
const success = await pluginManager.reloadPlugin(name);
if (success) {
return sendSuccess(res, { message: 'Reloaded successfully' });
} else {
return sendError(res, 'Failed to reload plugin');
}
return sendSuccess(res, { plugins: AllPlugins, pluginManagerNotFound: false, extensionPages });
};
export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
const { enable, filename } = req.body;
// We Use filename / id to control config
// Front-end should pass the 'filename' or 'id' as the key identifier
const { enable, id } = req.body;
if (!filename) return sendError(res, 'Plugin Filename/ID is required');
if (!id) return sendError(res, 'Plugin id is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// Calculate ID from filename (remove ext if file)
// Or just use the logic consistent with loadPlugins
let id = filename;
// If it has extension .js/.mjs, remove it to get the ID used in config
if (filename.endsWith('.js') || filename.endsWith('.mjs')) {
id = path.parse(filename).name;
}
try {
pluginManager.setPluginStatus(id, enable);
// 设置插件状态(需要 await因为内部会加载/卸载插件)
await pluginManager.setPluginStatus(id, enable);
// If enabling, trigger load
// 如果启用,检查插件是否加载成功
if (enable) {
const pluginPath = pluginManager.getPluginPath();
const fullPath = path.join(pluginPath, filename);
if (fs.statSync(fullPath).isDirectory()) {
await pluginManager.loadDirectoryPlugin(filename);
} else {
await pluginManager.loadFilePlugin(filename);
const plugin = pluginManager.getPluginInfo(id);
if (!plugin || !plugin.loaded) {
return sendError(res, 'Plugin load failed: ' + id);
}
} else {
// Disabling is handled inside setPluginStatus usually if implemented,
// OR we can explicitly unload here using the loaded name.
// The Manager's setPluginStatus implementation (if added) might logic this out.
// But our current Manager implementation just saves config.
// Wait, I updated Manager to try to unload.
// Let's rely on Manager's setPluginStatus or do it here?
// I implemented a basic unload loop in Manager.setPluginStatus.
}
return sendSuccess(res, { message: 'Status updated successfully' });
@@ -186,41 +163,434 @@ export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
};
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
const { name, filename } = req.body;
// If it's loaded, we use name. If it's disabled, we might use filename.
const { id, cleanData } = req.body;
if (!id) return sendError(res, 'Plugin id is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// Check if loaded
const plugin = pluginManager.getPluginInfo(name);
let fsPath = '';
if (plugin) {
// Active plugin
await pluginManager.unregisterPlugin(name);
if (plugin.pluginPath === pluginManager.getPluginPath()) {
fsPath = plugin.entryPath;
} else {
fsPath = plugin.pluginPath;
}
} else {
// Disabled or not loaded
if (filename) {
fsPath = path.join(pluginManager.getPluginPath(), filename);
} else {
return sendError(res, 'Plugin not found, provide filename if disabled');
}
}
try {
if (fs.existsSync(fsPath)) {
fs.rmSync(fsPath, { recursive: true, force: true });
}
await pluginManager.uninstallPlugin(id, cleanData);
return sendSuccess(res, { message: 'Uninstalled successfully' });
} catch (e: any) {
return sendError(res, 'Failed to uninstall: ' + e.message);
}
};
export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
const id = req.query['id'] as string;
if (!id) return sendError(res, 'Plugin id is required');
const pluginManager = getPluginManager();
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) return sendError(res, 'Plugin not loaded');
// 获取配置值
let config: unknown = {};
if (plugin.runtime.module?.plugin_get_config && plugin.runtime.context) {
try {
config = await plugin.runtime.module?.plugin_get_config(plugin.runtime.context);
} catch (e) { }
} else {
// Default behavior: read from default config path
try {
const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id);
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch (e) { }
}
// 获取静态 schema
const schema = plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || [];
// 检查是否支持动态控制
const supportReactive = !!(plugin.runtime.module?.plugin_config_controller || plugin.runtime.module?.plugin_on_config_change);
return sendSuccess(res, { schema, config, supportReactive });
};
/** 活跃的 SSE 连接 */
const activeConfigSessions = new Map<string, {
res: any;
cleanup?: () => void;
currentConfig: Record<string, any>;
}>();
/**
* 插件配置 SSE 连接 - 用于动态更新配置界面
*/
export const PluginConfigSSEHandler: RequestHandler = (req, res): void => {
const id = req.query['id'] as string;
const initialConfigStr = req.query['config'] as string;
if (!id) {
res.status(400).json({ error: 'Plugin id is required' });
return;
}
const pluginManager = getPluginManager();
if (!pluginManager) {
res.status(400).json({ error: 'Plugin Manager not found' });
return;
}
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) {
res.status(400).json({ error: 'Plugin not loaded' });
return;
}
// 设置 SSE 头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// 生成会话 ID
const sessionId = `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
// 解析初始配置
let currentConfig: Record<string, any> = {};
if (initialConfigStr) {
try {
currentConfig = JSON.parse(initialConfigStr);
} catch (e) { }
}
// 发送 SSE 消息的辅助函数
const sendSSE = (event: string, data: any) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// 创建 UI 控制器
const uiController = {
updateSchema: (schema: any[]) => {
sendSSE('schema', { type: 'full', schema });
},
updateField: (key: string, field: any) => {
sendSSE('schema', { type: 'updateField', key, field });
},
removeField: (key: string) => {
sendSSE('schema', { type: 'removeField', key });
},
addField: (field: any, afterKey?: string) => {
sendSSE('schema', { type: 'addField', field, afterKey });
},
showField: (key: string) => {
sendSSE('schema', { type: 'showField', key });
},
hideField: (key: string) => {
sendSSE('schema', { type: 'hideField', key });
},
getCurrentConfig: () => currentConfig
};
// 存储会话
activeConfigSessions.set(sessionId, { res, currentConfig });
// 发送连接成功消息
sendSSE('connected', { sessionId });
// 调用插件的控制器初始化(异步处理)
(async () => {
let cleanup: (() => void) | undefined;
if (plugin.runtime.module?.plugin_config_controller && plugin.runtime.context) {
try {
const result = await plugin.runtime.module.plugin_config_controller(
plugin.runtime.context,
uiController,
currentConfig
);
if (typeof result === 'function') {
cleanup = result;
}
} catch (e: any) {
sendSSE('error', { message: e.message });
}
}
// 更新会话的 cleanup
const session = activeConfigSessions.get(sessionId);
if (session) {
session.cleanup = cleanup;
}
})();
// 心跳保持连接
const heartbeat = setInterval(() => {
sendSSE('ping', { time: Date.now() });
}, 30000);
// 连接关闭时清理
req.on('close', () => {
clearInterval(heartbeat);
const session = activeConfigSessions.get(sessionId);
if (session?.cleanup) {
try {
session.cleanup();
} catch (e) { }
}
activeConfigSessions.delete(sessionId);
});
};
/**
* 插件配置字段变化通知
*/
export const PluginConfigChangeHandler: RequestHandler = async (req, res) => {
const { id, sessionId, key, value, currentConfig } = req.body;
if (!id || !sessionId || !key) {
return sendError(res, 'Missing required parameters');
}
const pluginManager = getPluginManager();
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) return sendError(res, 'Plugin not loaded');
// 获取会话
const session = activeConfigSessions.get(sessionId);
if (!session) {
return sendError(res, 'Session not found');
}
// 更新会话中的当前配置
session.currentConfig = currentConfig || {};
// 如果插件有响应式处理器,调用它
if (plugin.runtime.module?.plugin_on_config_change) {
const uiController = {
updateSchema: (schema: any[]) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'full', schema })}\n\n`);
},
updateField: (fieldKey: string, field: any) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'updateField', key: fieldKey, field })}\n\n`);
},
removeField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'removeField', key: fieldKey })}\n\n`);
},
addField: (field: any, afterKey?: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'addField', field, afterKey })}\n\n`);
},
showField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'showField', key: fieldKey })}\n\n`);
},
hideField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'hideField', key: fieldKey })}\n\n`);
},
getCurrentConfig: () => session.currentConfig
};
try {
if (plugin.runtime.context) {
await plugin.runtime.module.plugin_on_config_change(
plugin.runtime.context,
uiController,
key,
value,
currentConfig || {}
);
}
} catch (e: any) {
session.res.write(`event: error\n`);
session.res.write(`data: ${JSON.stringify({ message: e.message })}\n\n`);
}
}
return sendSuccess(res, { message: 'Change processed' });
};
export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
const { id, config } = req.body;
if (!id || !config) return sendError(res, 'Plugin id and config required');
const pluginManager = getPluginManager();
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) return sendError(res, 'Plugin not loaded');
if (plugin.runtime.module?.plugin_set_config && plugin.runtime.context) {
try {
await plugin.runtime.module.plugin_set_config(plugin.runtime.context, config);
return sendSuccess(res, { message: 'Config updated' });
} catch (e: any) {
return sendError(res, 'Error updating config: ' + e.message);
}
} else if (plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || plugin.runtime.module?.plugin_config_controller) {
// Default behavior: write to default config path
try {
const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id);
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
// Auto-Reload plugin to apply changes
await pluginManager.reloadPlugin(id);
return sendSuccess(res, { message: 'Config saved and plugin reloaded' });
} catch (e: any) {
return sendError(res, 'Error saving config: ' + e.message);
}
} else {
return sendError(res, 'Plugin does not support config update');
}
};
/**
* 导入本地插件包(支持 .zip 文件)
*/
export const ImportLocalPluginHandler: RequestHandler = async (req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// multer 会将文件信息放在 req.file 中
const file = req.file;
if (!file) {
return sendError(res, 'No file uploaded');
}
const PLUGINS_DIR = webUiPathWrapper.pluginPath;
// 确保插件目录存在
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
const tempZipPath = file.path;
try {
// 创建临时解压目录
const tempExtractDir = path.join(PLUGINS_DIR, `_temp_extract_${Date.now()}`);
fs.mkdirSync(tempExtractDir, { recursive: true });
// 解压到临时目录
await compressing.zip.uncompress(tempZipPath, tempExtractDir);
// 检查解压后的内容
const extractedItems = fs.readdirSync(tempExtractDir);
let pluginSourceDir: string;
let pluginId: string;
// 判断解压结构:可能是直接的插件文件,或者包含一个子目录
const hasPackageJson = extractedItems.includes('package.json');
const hasIndexFile = extractedItems.some(item =>
['index.js', 'index.mjs', 'main.js', 'main.mjs'].includes(item)
);
if (hasPackageJson || hasIndexFile) {
// 直接是插件文件
pluginSourceDir = tempExtractDir;
// 尝试从 package.json 获取插件 ID
const packageJsonPath = path.join(tempExtractDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
pluginId = pkg.name || path.basename(file.originalname, '.zip');
} catch {
pluginId = path.basename(file.originalname, '.zip');
}
} else {
pluginId = path.basename(file.originalname, '.zip');
}
} else if (extractedItems.length === 1 && fs.statSync(path.join(tempExtractDir, extractedItems[0]!)).isDirectory()) {
// 包含一个子目录
const subDir = extractedItems[0]!;
pluginSourceDir = path.join(tempExtractDir, subDir);
// 尝试从子目录的 package.json 获取插件 ID
const packageJsonPath = path.join(pluginSourceDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
pluginId = pkg.name || subDir;
} catch {
pluginId = subDir;
}
} else {
pluginId = subDir;
}
} else {
// 清理临时文件
fs.rmSync(tempExtractDir, { recursive: true, force: true });
fs.unlinkSync(tempZipPath);
return sendError(res, 'Invalid plugin package structure');
}
// 目标插件目录
const targetPluginDir = path.join(PLUGINS_DIR, pluginId);
// 如果目标目录已存在,先删除
if (fs.existsSync(targetPluginDir)) {
// 先卸载已存在的插件
const existingPlugin = pluginManager.getPluginInfo(pluginId);
if (existingPlugin && existingPlugin.loaded) {
await pluginManager.unregisterPlugin(pluginId);
}
fs.rmSync(targetPluginDir, { recursive: true, force: true });
}
// 移动插件文件到目标目录
if (pluginSourceDir === tempExtractDir) {
// 直接重命名临时目录
fs.renameSync(tempExtractDir, targetPluginDir);
} else {
// 移动子目录内容
fs.renameSync(pluginSourceDir, targetPluginDir);
// 清理临时目录
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
// 删除上传的临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
// 加载插件
const loaded = await pluginManager.loadPluginById(pluginId);
if (loaded) {
return sendSuccess(res, {
message: 'Plugin imported and loaded successfully',
pluginId,
installPath: targetPluginDir,
});
} else {
return sendSuccess(res, {
message: 'Plugin imported but failed to load (check plugin structure)',
pluginId,
installPath: targetPluginDir,
});
}
} catch (e: any) {
// 清理临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
return sendError(res, 'Failed to import plugin: ' + e.message);
}
};

View File

@@ -8,6 +8,16 @@ import { createWriteStream } from 'fs';
import compressing from 'compressing';
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
// Helper to get the plugin manager adapter
const getPluginManager = (): OB11PluginMangerAdapter | null => {
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
if (!ob11) return null;
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
};
// 插件商店源配置
const PLUGIN_STORE_SOURCES = [
@@ -242,6 +252,15 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
// 删除临时文件
fs.unlinkSync(tempZipPath);
// 如果 pluginManager 存在,立即注册插件
const pluginManager = getPluginManager();
if (pluginManager) {
// 检查是否已注册,避免重复注册
if (!pluginManager.getPluginInfo(id)) {
await pluginManager.loadPluginById(id);
}
}
return sendSuccess(res, {
message: 'Plugin installed successfully',
plugin: plugin,
@@ -315,6 +334,16 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
sendProgress('解压完成,正在清理...', 90);
fs.unlinkSync(tempZipPath);
// 如果 pluginManager 存在,立即注册插件
const pluginManager = getPluginManager();
if (pluginManager) {
// 检查是否已注册,避免重复注册
if (!pluginManager.getPluginInfo(id)) {
sendProgress('正在注册插件...', 95);
await pluginManager.loadPluginById(id);
}
}
sendProgress('安装成功!', 100);
res.write(`data: ${JSON.stringify({
success: true,

View File

@@ -1,7 +1,9 @@
import { RequestHandler } from 'express';
import { WebUiConfig } from '@/napcat-webui-backend/index';
import { WebUiConfig, webUiPathWrapper } from '@/napcat-webui-backend/index';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
import { existsSync, promises as fsProm } from 'node:fs';
import { join } from 'node:path';
// 获取WebUI基础配置
export const GetWebUIConfigHandler: RequestHandler = async (_, res) => {
@@ -158,3 +160,86 @@ export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
return sendError(res, `更新WebUI配置失败: ${msg}`);
}
};
// 获取SSL证书状态
export const GetSSLStatusHandler: RequestHandler = async (_, res) => {
try {
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
const certExists = existsSync(certPath);
const keyExists = existsSync(keyPath);
let certContent = '';
let keyContent = '';
if (certExists) {
certContent = await fsProm.readFile(certPath, 'utf-8');
}
if (keyExists) {
keyContent = await fsProm.readFile(keyPath, 'utf-8');
}
return sendSuccess(res, {
enabled: certExists && keyExists,
certExists,
keyExists,
certContent,
keyContent,
});
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `获取SSL状态失败: ${msg}`);
}
};
// 保存SSL证书通过文本内容
export const UploadSSLCertHandler: RequestHandler = async (req, res) => {
try {
const { cert, key } = req.body;
if (isEmpty(cert) || isEmpty(key)) {
return sendError(res, 'cert和key内容不能为空');
}
// 简单验证证书格式
if (!cert.includes('-----BEGIN CERTIFICATE-----') || !cert.includes('-----END CERTIFICATE-----')) {
return sendError(res, 'cert格式不正确应为PEM格式的证书');
}
if (!key.includes('-----BEGIN') || !key.includes('KEY-----')) {
return sendError(res, 'key格式不正确应为PEM格式的私钥');
}
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
await fsProm.writeFile(certPath, cert, 'utf-8');
await fsProm.writeFile(keyPath, key, 'utf-8');
return sendSuccess(res, { message: 'SSL证书保存成功重启后生效' });
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `保存SSL证书失败: ${msg}`);
}
};
// 删除SSL证书
export const DeleteSSLCertHandler: RequestHandler = async (_, res) => {
try {
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
if (existsSync(certPath)) {
await fsProm.unlink(certPath);
}
if (existsSync(keyPath)) {
await fsProm.unlink(keyPath);
}
return sendSuccess(res, { message: 'SSL证书已删除重启后生效' });
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `删除SSL证书失败: ${msg}`);
}
};

View File

@@ -12,7 +12,7 @@ import { getRandomToken } from '../utils/url';
// 限制尝试端口的次数,避免死循环
// 定义配置的类型
const WebUiConfigSchema = Type.Object({
host: Type.String({ default: '0.0.0.0' }),
host: Type.String({ default: '::' }),
port: Type.Number({ default: 6099 }),
token: Type.String({ default: getRandomToken(12) }),
loginRate: Type.Number({ default: 10 }),

View File

@@ -16,10 +16,7 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
req.url === '/auth/passkey/verify-authentication') {
return next();
}
// 判断是否有Authorization头
let hash: string | undefined;
if (req.headers?.authorization) {
// 切割参数以获取token
const authorization = req.headers.authorization.split(' ');
@@ -28,8 +25,14 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
return sendError(res, 'Unauthorized');
}
// 获取token
const hash = authorization[1];
if (!hash) return sendError(res, 'Unauthorized');
hash = authorization[1];
} else if (req.query['webui_token'] && typeof req.query['webui_token'] === 'string') {
// 支持通过query参数传递token
hash = req.query['webui_token'];
}
// 判断是否有Authorization头
if (hash) {
//if (!hash) return sendError(res, 'Unauthorized');
// 解析token
let Credential: WebUiCredentialJson;
try {

View File

@@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
musicSignUrl: Type.String({ default: '' }),
enableLocalFile2Url: Type.Boolean({ default: false }),
parseMultMsg: Type.Boolean({ default: false }),
imageDownloadProxy: Type.String({ default: '' }),
});
export type OneBotConfig = Static<typeof OneBotConfigSchema>;

View File

@@ -0,0 +1,23 @@
import { Router } from 'express';
import {
GetMirrorListHandler,
SetCustomMirrorHandler,
TestMirrorsSSEHandler,
TestSingleMirrorHandler
} from '@/napcat-webui-backend/src/api/Mirror';
const router: Router = Router();
// 获取镜像列表
router.get('/List', GetMirrorListHandler);
// 设置自定义镜像
router.post('/SetCustom', SetCustomMirrorHandler);
// SSE 实时测速
router.get('/Test/SSE', TestMirrorsSSEHandler);
// 测试单个镜像
router.post('/Test', TestSingleMirrorHandler);
export { router as MirrorRouter };

View File

@@ -1,13 +1,73 @@
import { Router } from 'express';
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import os from 'os';
import {
GetPluginListHandler,
SetPluginStatusHandler,
UninstallPluginHandler,
GetPluginConfigHandler,
SetPluginConfigHandler,
RegisterPluginManagerHandler,
PluginConfigSSEHandler,
PluginConfigChangeHandler,
ImportLocalPluginHandler
} from '@/napcat-webui-backend/src/api/Plugin';
import {
GetPluginStoreListHandler,
GetPluginStoreDetailHandler,
InstallPluginFromStoreHandler,
InstallPluginFromStoreSSEHandler
} from '@/napcat-webui-backend/src/api/PluginStore';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
// 配置 multer 用于文件上传
const uploadDir = path.join(os.tmpdir(), 'napcat-plugin-uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, uploadDir);
},
filename: (_req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + file.originalname);
}
});
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB 限制
},
fileFilter: (_req, file, cb) => {
// 只允许 .zip 文件
if (file.mimetype === 'application/zip' ||
file.mimetype === 'application/x-zip-compressed' ||
file.originalname.endsWith('.zip')) {
cb(null, true);
} else {
cb(new Error('Only .zip files are allowed'));
}
}
});
const router: Router = Router();
router.get('/List', GetPluginListHandler);
router.post('/Reload', ReloadPluginHandler);
router.post('/SetStatus', SetPluginStatusHandler);
router.post('/Uninstall', UninstallPluginHandler);
router.get('/Config', GetPluginConfigHandler);
router.post('/Config', SetPluginConfigHandler);
router.get('/Config/SSE', PluginConfigSSEHandler);
router.post('/Config/Change', PluginConfigChangeHandler);
router.post('/RegisterManager', RegisterPluginManagerHandler);
router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler);
// 插件商店相关路由
router.get('/Store/List', GetPluginStoreListHandler);
@@ -15,4 +75,92 @@ router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
router.post('/Store/Install', InstallPluginFromStoreHandler);
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
// 插件扩展路由 - 动态挂载插件注册的 API 路由
router.use('/ext/:pluginId', (req, res, next): void => {
const { pluginId } = req.params;
if (!pluginId) {
res.status(400).json({ code: -1, message: 'Plugin ID is required' });
return;
}
// 获取插件管理器
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
if (!ob11) {
res.status(503).json({ code: -1, message: 'OneBot context not available' });
return;
}
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
if (!pluginManager) {
res.status(503).json({ code: -1, message: 'Plugin manager not available' });
return;
}
// 获取插件路由
const routerRegistry = pluginManager.getPluginRouter(pluginId);
if (!routerRegistry || !routerRegistry.hasApiRoutes()) {
res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered API routes` });
return;
}
// 构建并执行插件路由
const pluginRouter = routerRegistry.buildApiRouter();
pluginRouter(req, res, next);
});
// 插件页面路由 - 服务插件注册的 HTML 页面
router.get('/page/:pluginId/:pagePath', (req, res): void => {
const { pluginId, pagePath } = req.params;
if (!pluginId) {
res.status(400).json({ code: -1, message: 'Plugin ID is required' });
return;
}
// 获取插件管理器
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
if (!ob11) {
res.status(503).json({ code: -1, message: 'OneBot context not available' });
return;
}
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
if (!pluginManager) {
res.status(503).json({ code: -1, message: 'Plugin manager not available' });
return;
}
// 获取插件路由
const routerRegistry = pluginManager.getPluginRouter(pluginId);
if (!routerRegistry || !routerRegistry.hasPages()) {
res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` });
return;
}
// 查找匹配的页面
const pages = routerRegistry.getPages();
const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath);
if (!page) {
res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` });
return;
}
// 获取插件路径
const pluginPath = routerRegistry.getPluginPath();
if (!pluginPath) {
res.status(500).json({ code: -1, message: 'Plugin path not available' });
return;
}
// 构建 HTML 文件路径并发送
const htmlFilePath = path.join(pluginPath, page.htmlFile);
if (!fs.existsSync(htmlFilePath)) {
res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` });
return;
}
res.sendFile(htmlFilePath);
});
export { router as PluginRouter };

View File

@@ -5,6 +5,9 @@ import {
UpdateDisableWebUIHandler,
UpdateWebUIConfigHandler,
GetClientIPHandler,
GetSSLStatusHandler,
UploadSSLCertHandler,
DeleteSSLCertHandler,
} from '@/napcat-webui-backend/src/api/WebUIConfig';
const router: Router = Router();
@@ -24,4 +27,13 @@ router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler);
// 获取当前客户端IP
router.get('/GetClientIP', GetClientIPHandler);
// 获取SSL证书状态
router.get('/GetSSLStatus', GetSSLStatusHandler);
// 上传SSL证书
router.post('/UploadSSLCert', UploadSSLCertHandler);
// 删除SSL证书
router.post('/DeleteSSLCert', DeleteSSLCertHandler);
export { router as WebUIConfigRouter };

View File

@@ -18,6 +18,7 @@ import { UpdateNapCatRouter } from './UpdateNapCat';
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
import { ProcessRouter } from './Process';
import { PluginRouter } from './Plugin';
import { MirrorRouter } from './Mirror';
const router: Router = Router();
@@ -50,5 +51,7 @@ router.use('/Debug', DebugRouter);
router.use('/Process', ProcessRouter);
// router:插件管理相关路由
router.use('/Plugin', PluginRouter);
// router:镜像管理相关路由
router.use('/Mirror', MirrorRouter);
export { router as ALLRouter };

View File

@@ -27,6 +27,7 @@ const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store'));
const ExtensionPage = lazy(() => import('@/pages/dashboard/extension'));
function App () {
return (
@@ -80,6 +81,7 @@ function AppRoutes () {
<Route path='terminal' element={<TerminalPage />} />
<Route path='plugins' element={<PluginPage />} />
<Route path='plugin_store' element={<PluginStorePage />} />
<Route path='extension' element={<ExtensionPage />} />
<Route path='about' element={<AboutPage />} />
</Route>
<Route path='/qq_login' element={<QQLoginPage />} />

View File

@@ -3,26 +3,28 @@ import { Switch } from '@heroui/switch';
import { Chip } from '@heroui/chip';
import { useState } from 'react';
import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md';
import { MdDeleteForever, MdSettings } from 'react-icons/md';
import DisplayCardContainer from './container';
import { PluginItem } from '@/controllers/plugin_manager';
export interface PluginDisplayCardProps {
data: PluginItem;
onReload: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onUninstall: () => Promise<void>;
onConfig?: () => void;
hasConfig?: boolean;
}
const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
data,
onReload,
onToggleStatus,
onUninstall,
onConfig,
hasConfig = false,
}) => {
const { name, version, author, description, status } = data;
const isEnabled = status !== 'disabled';
const isEnabled = status === 'active';
const [processing, setProcessing] = useState(false);
const handleToggle = () => {
@@ -30,11 +32,6 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
onToggleStatus().finally(() => setProcessing(false));
};
const handleReload = () => {
setProcessing(true);
onReload().finally(() => setProcessing(false));
};
const handleUninstall = () => {
setProcessing(true);
onUninstall().finally(() => setProcessing(false));
@@ -44,32 +41,34 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
<DisplayCardContainer
className='w-full max-w-[420px]'
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-primary/20 hover:text-primary transition-colors'
startContent={<MdPublishedWithChanges size={16} />}
onPress={handleReload}
isDisabled={!isEnabled || processing}
>
</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={handleUninstall}
isDisabled={processing}
>
</Button>
<div className='flex flex-col gap-2 w-full'>
<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-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />}
onPress={handleUninstall}
isDisabled={processing}
>
</Button>
</div>
{hasConfig && (
<Button
fullWidth
radius='full'
size='sm'
variant='flat'
className='bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-secondary/20 hover:text-secondary transition-colors'
startContent={<MdSettings size={16} />}
onPress={onConfig}
>
</Button>
)}
</div>
}
enableSwitch={

View File

@@ -1,19 +1,24 @@
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { useState } from 'react';
import { IoMdDownload } from 'react-icons/io';
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
import DisplayCardContainer from './container';
import { PluginStoreItem } from '@/types/plugin-store';
export type InstallStatus = 'not-installed' | 'installed' | 'update-available';
export interface PluginStoreCardProps {
data: PluginStoreItem;
onInstall: () => Promise<void>;
installStatus?: InstallStatus;
installedVersion?: string;
}
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
data,
onInstall,
installStatus = 'not-installed',
}) => {
const { name, version, author, description, tags, id } = data;
const [processing, setProcessing] = useState(false);
@@ -23,19 +28,65 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
onInstall().finally(() => setProcessing(false));
};
// 根据安装状态返回按钮配置
const getButtonConfig = () => {
switch (installStatus) {
case 'installed':
return {
text: '重新安装',
icon: <IoMdRefresh size={16} />,
color: 'default' as const,
};
case 'update-available':
return {
text: '更新',
icon: <IoMdDownload size={16} />,
color: 'success' as const,
};
default:
return {
text: '安装',
icon: <IoMdDownload size={16} />,
color: 'primary' as const,
};
}
};
const buttonConfig = getButtonConfig();
return (
<DisplayCardContainer
className='w-full max-w-[420px]'
title={name}
tag={
<Chip
className="ml-auto"
color="primary"
size="sm"
variant="flat"
>
v{version}
</Chip>
<div className="ml-auto flex items-center gap-1">
{installStatus === 'installed' && (
<Chip
color="success"
size="sm"
variant="flat"
startContent={<IoMdCheckmarkCircle size={14} />}
>
</Chip>
)}
{installStatus === 'update-available' && (
<Chip
color="warning"
size="sm"
variant="flat"
>
</Chip>
)}
<Chip
color="primary"
size="sm"
variant="flat"
>
v{version}
</Chip>
</div>
}
enableSwitch={undefined}
action={
@@ -43,13 +94,13 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
fullWidth
radius='full'
size='sm'
color='primary'
startContent={<IoMdDownload size={16} />}
color={buttonConfig.color}
startContent={buttonConfig.icon}
onPress={handleInstall}
isLoading={processing}
isDisabled={processing}
>
{buttonConfig.text}
</Button>
}
>

View File

@@ -0,0 +1,283 @@
import { useState, useEffect } from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { Tooltip } from '@heroui/tooltip';
import { IoMdFlash, IoMdCheckmark, IoMdClose } from 'react-icons/io';
import clsx from 'clsx';
import MirrorManager, { MirrorTestResult } from '@/controllers/mirror_manager';
interface MirrorSelectorModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (mirror: string | undefined) => void;
currentMirror?: string;
type?: 'file' | 'raw';
}
export default function MirrorSelectorModal ({
isOpen,
onClose,
onSelect,
currentMirror,
type = 'file',
}: MirrorSelectorModalProps) {
const [mirrors, setMirrors] = useState<string[]>([]);
const [selectedMirror, setSelectedMirror] = useState<string>(currentMirror || 'auto');
const [testResults, setTestResults] = useState<Map<string, MirrorTestResult>>(new Map());
const [isTesting, setIsTesting] = useState(false);
const [testProgress, setTestProgress] = useState(0);
const [testMessage, setTestMessage] = useState('');
const [fastestMirror, setFastestMirror] = useState<string | null>(null);
// 加载镜像列表
useEffect(() => {
if (isOpen) {
loadMirrors();
}
}, [isOpen]);
const loadMirrors = async () => {
try {
const data = await MirrorManager.getMirrorList();
const mirrorList = type === 'raw' ? data.rawMirrors : data.fileMirrors;
setMirrors(mirrorList);
if (data.customMirror) {
setSelectedMirror(data.customMirror);
}
} catch (e) {
console.error('Failed to load mirrors:', e);
}
};
const startSpeedTest = () => {
setIsTesting(true);
setTestProgress(0);
setTestResults(new Map());
setFastestMirror(null);
setTestMessage('准备测速...');
MirrorManager.testMirrorsSSE(type, {
onStart: (data) => {
setTestMessage(data.message);
},
onTesting: (data) => {
setTestProgress((data.index / data.total) * 100);
setTestMessage(data.message);
},
onResult: (data) => {
setTestResults((prev) => {
const newMap = new Map(prev);
newMap.set(data.result.mirror, data.result);
return newMap;
});
setTestProgress(((data.index + 1) / data.total) * 100);
},
onComplete: (data) => {
setIsTesting(false);
setTestProgress(100);
setTestMessage(data.message);
if (data.fastest) {
setFastestMirror(data.fastest.mirror);
}
},
onError: (error) => {
setIsTesting(false);
setTestMessage(`测速失败: ${error}`);
},
});
};
const handleConfirm = () => {
const mirror = selectedMirror === 'auto' ? undefined : selectedMirror;
onSelect(mirror);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
backdrop: 'z-[200]',
wrapper: 'z-[200]',
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<div className="flex items-center justify-between pr-8">
<span></span>
<Button
size="sm"
color="primary"
variant="flat"
startContent={!isTesting && <IoMdFlash />}
onPress={startSpeedTest}
isLoading={isTesting}
>
{isTesting ? '测速中...' : '一键测速'}
</Button>
</div>
{isTesting && (
<div className="mt-2">
<div className="w-full bg-default-200 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${testProgress}%` }}
/>
</div>
<p className="text-xs text-default-500 mt-1">{testMessage}</p>
</div>
)}
</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
{/* 自动选择选项 */}
<MirrorOption
value="auto"
label="自动选择"
description="系统自动选择最快的镜像源"
isSelected={selectedMirror === 'auto'}
onSelect={() => setSelectedMirror('auto')}
badge={<Chip size="sm" color="primary" variant="flat"></Chip>}
/>
{/* 原始 GitHub */}
<MirrorOption
value="https://github.com"
label="GitHub 原始"
description="直连 GitHub可能较慢"
isSelected={selectedMirror === 'https://github.com'}
onSelect={() => setSelectedMirror('https://github.com')}
testResult={testResults.get('https://github.com (原始)')}
isFastest={fastestMirror === 'https://github.com (原始)'}
/>
{/* 镜像列表 */}
{mirrors.map((mirror) => {
if (!mirror) return null;
const result = testResults.get(mirror);
const isFastest = fastestMirror === mirror;
let hostname = mirror;
try {
hostname = new URL(mirror).hostname;
} catch { }
return (
<MirrorOption
key={mirror}
value={mirror}
label={hostname}
description={mirror}
isSelected={selectedMirror === mirror}
onSelect={() => setSelectedMirror(mirror)}
testResult={result}
isFastest={isFastest}
/>
);
})}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
</Button>
<Button color="primary" onPress={handleConfirm}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
// 镜像选项组件
interface MirrorOptionProps {
value: string;
label: string;
description: string;
isSelected: boolean;
onSelect: () => void;
testResult?: MirrorTestResult;
isFastest?: boolean;
badge?: React.ReactNode;
}
function MirrorOption ({
label,
description,
isSelected,
onSelect,
testResult,
isFastest,
badge,
}: MirrorOptionProps) {
return (
<div
className={clsx(
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all',
'bg-content1 hover:bg-content2 border-2',
isSelected ? 'border-primary' : 'border-transparent',
isFastest && 'ring-2 ring-success'
)}
onClick={onSelect}
>
<div className="flex-1 min-w-0">
<p className="font-medium">{label}</p>
<p className="text-xs text-default-500 truncate">{description}</p>
</div>
<div className="flex items-center gap-2 ml-2">
{badge}
{isFastest && !badge && (
<Chip size="sm" color="success" variant="flat"></Chip>
)}
{testResult && <MirrorStatus result={testResult} />}
</div>
</div>
);
}
// 镜像状态显示组件
function MirrorStatus ({ result }: { result: MirrorTestResult; }) {
const formatLatency = (latency: number) => {
if (latency >= 5000) return '>5s';
if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`;
return `${latency}ms`;
};
if (!result.success) {
return (
<Tooltip content={result.error || '连接失败'}>
<Chip
size="sm"
color="danger"
variant="flat"
startContent={<IoMdClose size={14} />}
>
</Chip>
</Tooltip>
);
}
const getColor = (): 'success' | 'warning' | 'danger' => {
if (result.latency < 300) return 'success';
if (result.latency < 1000) return 'warning';
return 'danger';
};
return (
<Chip
size="sm"
color={getColor()}
variant="flat"
startContent={<IoMdCheckmark size={14} />}
>
{formatLatency(result.latency)}
</Chip>
);
}

View File

@@ -180,7 +180,7 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
export default GenericForm;
export function random_token (length: number) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?';
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));

View File

@@ -7,7 +7,7 @@ import { Tab, Tabs } from '@heroui/tabs';
import { Chip } from '@heroui/chip';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
import { TbCode, TbMessageCode } from 'react-icons/tb';
@@ -59,16 +59,30 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
const [responseHeight, setResponseHeight] = useState(240);
const [storedHeight, setStoredHeight] = useLocalStorage('napcat_debug_response_height', 240);
const parsedRequest = parseTypeBox(data?.payload);
// 使用 useMemo 缓存解析结果,避免每次渲染都重新解析
const parsedRequest = useMemo(() => {
try {
return parseTypeBox(data?.payload);
} catch (e) {
console.error('Error parsing request schema:', e);
return [];
}
}, [data?.payload]);
// 将返回值的 data 结构包装进 BaseResponseSchema 进行展示
// 使用解构属性的方式重新构建对象,确保 parseTypeBox 能够识别为 object 类型
const wrappedResponseSchema = Type.Object({
...BaseResponseSchema.properties,
data: data?.response || Type.Any({ description: '数据' })
});
const parsedResponse = parseTypeBox(wrappedResponseSchema);
const parsedResponse = useMemo(() => {
try {
const wrappedResponseSchema = Type.Object({
...BaseResponseSchema.properties,
data: data?.response || Type.Any({ description: '数据' })
});
return parseTypeBox(wrappedResponseSchema);
} catch (e) {
console.error('Error parsing response schema:', e);
return [];
}
}, [data?.response]);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
@@ -166,7 +180,12 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
if (data?.payloadExample) {
setRequestBody(JSON.stringify(data.payloadExample, null, 2));
} else {
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
try {
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
} catch (e) {
console.error('Error generating default:', e);
setRequestBody('{}');
}
}
setResponseContent('');
setResponseStatus(null);
@@ -320,7 +339,14 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
)}
</ChatInputModal>
<Tooltip content="生成示例" closeDelay={0}>
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={() => setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2))}>
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={() => {
try {
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
} catch (e) {
console.error('Error generating default:', e);
toast.error('生成示例失败');
}
}}>
<TbCode size={16} />
</Button>
</Tooltip>

View File

@@ -127,6 +127,20 @@ const SchemaContainer: React.FC<{
};
const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
// 处理循环引用和截断的情况,直接显示提示而不继续递归
if (schema.isCircularRef || schema.isTruncated) {
return (
<div className='mb-2 flex items-center gap-1 pl-5'>
{schema.name && (
<span className='text-default-400'>{schema.name}</span>
)}
<Chip size='sm' color='default' variant='flat'>
{schema.description || '...'}
</Chip>
</div>
);
}
if (schema.type === 'object') {
return (
<SchemaContainer schema={schema}>

View File

@@ -8,18 +8,22 @@ import { Switch } from '@heroui/switch';
import { Pagination } from '@heroui/pagination';
import { Tabs, Tab } from '@heroui/tabs';
import { Input } from '@heroui/input';
import { Button } from '@heroui/button';
import { useLocalStorage, useDebounce } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
import { IoLogoChrome, IoLogoOctocat, IoSearch } from 'react-icons/io5';
import { IoMdFlash, IoMdCheckmark, IoMdSettings } from 'react-icons/io';
import { RiMacFill } from 'react-icons/ri';
import { useState, useCallback } from 'react';
import key from '@/const/key';
import WebUIManager from '@/controllers/webui_manager';
import MirrorManager from '@/controllers/mirror_manager';
import useDialog from '@/hooks/use-dialog';
import Modal from '@/components/modal';
import MirrorSelectorModal from '@/components/mirror_selector_modal';
import { hasNewVersion, compareVersion } from '@/utils/version';
@@ -304,17 +308,54 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
// 镜像相关状态
const [selectedMirror, setSelectedMirror] = useState<string | undefined>(undefined);
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
cacheKey: 'napcat-mirrors',
staleTime: 60 * 60 * 1000,
});
const mirrors = mirrorsData?.mirrors || [];
const [mirrorLatency, setMirrorLatency] = useState<number | null>(null);
const [mirrorTesting, setMirrorTesting] = useState(false);
const [mirrorModalOpen, setMirrorModalOpen] = useState(false);
const pageSize = 15;
// 测试当前镜像速度
const testCurrentMirror = async () => {
setMirrorTesting(true);
try {
const result = await MirrorManager.testSingleMirror(selectedMirror || '', 'file');
if (result.success) {
setMirrorLatency(result.latency);
} else {
setMirrorLatency(null);
}
} catch (e) {
setMirrorLatency(null);
} finally {
setMirrorTesting(false);
}
};
const formatLatency = (latency: number) => {
if (latency >= 5000) return '>5s';
if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`;
return `${latency}ms`;
};
const getLatencyColor = (latency: number | null): 'success' | 'warning' | 'danger' | 'default' => {
if (latency === null) return 'default';
if (latency < 300) return 'success';
if (latency < 1000) return 'warning';
return 'danger';
};
const getMirrorDisplayName = () => {
if (!selectedMirror) return '自动选择';
try {
return new URL(selectedMirror).hostname;
} catch {
return selectedMirror;
}
};
// 获取所有可用版本(带分页、过滤和搜索)
// 懒加载:根据 activeTab 只获取对应类型的版本
const pageSize = 15;
const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest(
() => WebUIManager.getAllReleases({
page: currentPage,
@@ -502,46 +543,78 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
<Tab key='action' title='临时版本 (Action)' />
</Tabs>
<div className="flex gap-2">
{/* 搜索框 */}
<Input
placeholder='搜索版本号...'
size='sm'
value={searchQuery}
onValueChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
setSelectedVersion(null);
}}
startContent={<IoSearch className='text-default-400' />}
isClearable
onClear={() => setSearchQuery('')}
classNames={{
inputWrapper: 'h-9',
base: 'flex-1'
}}
/>
{/* 下载镜像状态卡片 */}
<Card className="bg-default-100/50 shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getMirrorDisplayName()}</span>
{mirrorLatency !== null && (
<Chip
size="sm"
color={getLatencyColor(mirrorLatency)}
variant="flat"
startContent={<IoMdCheckmark size={12} />}
>
{formatLatency(mirrorLatency)}
</Chip>
)}
{mirrorLatency === null && !mirrorTesting && (
<Chip size="sm" color="default" variant="flat">
</Chip>
)}
{mirrorTesting && (
<Chip size="sm" color="primary" variant="flat">
...
</Chip>
)}
</div>
<div className="flex items-center gap-1">
<Tooltip content="测速">
<Button
isIconOnly
size="sm"
variant="light"
onPress={testCurrentMirror}
isLoading={mirrorTesting}
>
<IoMdFlash size={16} />
</Button>
</Tooltip>
<Tooltip content="切换镜像">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setMirrorModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</div>
</CardBody>
</Card>
{/* 镜像选择 */}
<Select
placeholder="自动选择 (默认)"
selectedKeys={selectedMirror ? [selectedMirror] : ['default']}
onSelectionChange={(keys) => {
const m = Array.from(keys)[0] as string;
setSelectedMirror(m === 'default' ? undefined : m);
}}
size="sm"
className="w-48"
classNames={{ trigger: 'h-9 min-h-9' }}
aria-label="选择镜像源"
>
{['default', ...mirrors].map(m => (
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
{m === 'default' ? '自动选择 (默认)' : m}
</SelectItem>
))}
</Select>
</div>
{/* 搜索框 */}
<Input
placeholder='搜索版本号...'
size='sm'
value={searchQuery}
onValueChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
setSelectedVersion(null);
}}
startContent={<IoSearch className='text-default-400' />}
isClearable
onClear={() => setSearchQuery('')}
classNames={{
inputWrapper: 'h-9',
}}
/>
{/* 版本选择 */}
<div className='space-y-2'>
@@ -703,6 +776,18 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
{isSelectedDowngrade ? '确认降级更新' : '更新到此版本'}
</button>
</div>
{/* 镜像选择弹窗 */}
<MirrorSelectorModal
isOpen={mirrorModalOpen}
onClose={() => setMirrorModalOpen(false)}
currentMirror={selectedMirror}
onSelect={(mirror) => {
setSelectedMirror(mirror || undefined);
setMirrorLatency(null);
}}
type="file"
/>
</div>
);
};

View File

@@ -10,6 +10,7 @@ import {
LuZap,
LuPackage,
LuStore,
LuPuzzle,
} from 'react-icons/lu';
export type SiteConfig = typeof siteConfig;
@@ -36,11 +37,6 @@ export const siteConfig = {
icon: <LuSignal className='w-5 h-5' />,
href: '/network',
},
{
label: '其他配置',
icon: <LuSettings className='w-5 h-5' />,
href: '/config',
},
{
label: '猫猫日志',
icon: <LuFileText className='w-5 h-5' />,
@@ -71,11 +67,21 @@ export const siteConfig = {
icon: <LuStore className='w-5 h-5' />,
href: '/plugin_store',
},
{
label: '扩展页面',
icon: <LuPuzzle className='w-5 h-5' />,
href: '/extension',
},
{
label: '系统终端',
icon: <LuTerminal className='w-5 h-5' />,
href: '/terminal',
},
{
label: '系统配置',
icon: <LuSettings className='w-5 h-5' />,
href: '/config',
},
{
label: '关于我们',
icon: <LuInfo className='w-5 h-5' />,

View File

@@ -0,0 +1,109 @@
import { EventSourcePolyfill } from 'event-source-polyfill';
import { serverRequest } from '@/utils/request';
import key from '@/const/key';
export interface MirrorTestResult {
mirror: string;
latency: number;
success: boolean;
error?: string;
}
export interface MirrorListResponse {
fileMirrors: string[];
rawMirrors: string[];
customMirror?: string;
timeout: number;
}
export default class MirrorManager {
/**
* 获取镜像列表
*/
public static async getMirrorList (): Promise<MirrorListResponse> {
const { data } = await serverRequest.get<ServerResponse<MirrorListResponse>>('/Mirror/List');
return data.data;
}
/**
* 设置自定义镜像
*/
public static async setCustomMirror (mirror: string): Promise<void> {
await serverRequest.post('/Mirror/SetCustom', { mirror });
}
/**
* 测试单个镜像
*/
public static async testSingleMirror (mirror: string, type: 'file' | 'raw' = 'file'): Promise<MirrorTestResult> {
const { data } = await serverRequest.post<ServerResponse<MirrorTestResult>>('/Mirror/Test', { mirror, type });
return data.data;
}
/**
* SSE 实时测速所有镜像
*/
public static testMirrorsSSE (
type: 'file' | 'raw' = 'file',
callbacks: {
onStart?: (data: { total: number; message: string; }) => void;
onTesting?: (data: { index: number; total: number; mirror: string; message: string; }) => void;
onResult?: (data: { index: number; total: number; result: MirrorTestResult; }) => void;
onComplete?: (data: { results: MirrorTestResult[]; failed: MirrorTestResult[]; fastest: MirrorTestResult | null; message: string; }) => void;
onError?: (error: string) => void;
}
): EventSourcePolyfill {
const token = localStorage.getItem(key.token);
if (!token) {
throw new Error('未登录');
}
const _token = JSON.parse(token);
const eventSource = new EventSourcePolyfill(
`/api/Mirror/Test/SSE?type=${type}`,
{
headers: {
Authorization: `Bearer ${_token}`,
Accept: 'text/event-stream',
},
withCredentials: true,
}
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'start':
callbacks.onStart?.(data);
break;
case 'testing':
callbacks.onTesting?.(data);
break;
case 'result':
callbacks.onResult?.(data);
break;
case 'complete':
callbacks.onComplete?.(data);
eventSource.close();
break;
case 'error':
callbacks.onError?.(data.error);
eventSource.close();
break;
}
} catch (e) {
console.error('Failed to parse SSE message:', e);
}
};
eventSource.onerror = (error) => {
console.error('SSE连接出错:', error);
callbacks.onError?.('连接中断');
eventSource.close();
};
return eventSource;
}
}

View File

@@ -1,59 +1,229 @@
import { serverRequest } from '@/utils/request';
import { PluginStoreList, PluginStoreItem } from '@/types/plugin-store';
/** 插件状态 */
export type PluginStatus = 'active' | 'disabled' | 'stopped';
/** 插件信息 */
export interface PluginItem {
/** 显示名称 (优先 package.json 的 plugin 字段) */
name: string;
/** 包名 (package name),用于 API 操作 */
id: string;
/** 版本号 */
version: string;
/** 描述 */
description: string;
/** 作者 */
author: string;
status: 'active' | 'disabled' | 'stopped';
filename?: string;
/** 状态: active-运行中, disabled-已禁用, stopped-已停止 */
status: PluginStatus;
/** 是否有配置项 */
hasConfig?: boolean;
/** 是否有扩展页面 */
hasPages?: boolean;
}
/** 扩展页面信息 */
export interface ExtensionPageItem {
/** 插件 ID */
pluginId: string;
/** 插件名称 */
pluginName: string;
/** 页面路径 */
path: string;
/** 页面标题 */
title: string;
/** 页面图标 */
icon?: string;
/** 页面描述 */
description?: string;
}
/** 插件列表响应 */
export interface PluginListResponse {
plugins: PluginItem[];
pluginManagerNotFound: boolean;
extensionPages: ExtensionPageItem[];
}
/** 插件配置项定义 */
export interface PluginConfigSchemaItem {
key: string;
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
label: string;
description?: string;
default?: any;
options?: { label: string; value: string | number; }[];
placeholder?: string;
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
reactive?: boolean;
/** 是否隐藏此字段 */
hidden?: boolean;
}
/** 插件配置响应 */
export interface PluginConfigResponse {
schema: PluginConfigSchemaItem[];
config: Record<string, unknown>;
/** 是否支持响应式更新 */
supportReactive?: boolean;
}
/** 服务端响应 */
export interface ServerResponse<T> {
code: number;
message: string;
data: T;
}
/**
* 插件管理器 API
*/
export default class PluginManager {
public static async getPluginList () {
/**
* 获取插件列表
*/
public static async getPluginList (): Promise<PluginListResponse> {
const { data } = await serverRequest.get<ServerResponse<PluginListResponse>>('/Plugin/List');
return data.data;
}
public static async reloadPlugin (name: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Reload', { name });
/**
* 手动注册插件管理器到 NetworkManager
*/
public static async registerPluginManager (): Promise<{ message: string; }> {
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>('/Plugin/RegisterManager');
return data.data;
}
public static async setPluginStatus (name: string, enable: boolean, filename?: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename });
/**
* 设置插件状态(启用/禁用)
* @param id 插件包名
* @param enable 是否启用
*/
public static async setPluginStatus (id: string, enable: boolean): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { id, enable });
}
public static async uninstallPlugin (name: string, filename?: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename });
/**
* 卸载插件
* @param id 插件包名
* @param cleanData 是否清理数据
*/
public static async uninstallPlugin (id: string, cleanData?: boolean): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { id, cleanData });
}
// 插件商店相关方法
public static async getPluginStoreList () {
/**
* 导入本地插件包
* @param file 插件 zip 文件
*/
public static async importLocalPlugin (file: File): Promise<{ message: string; pluginId: string; installPath: string; }> {
const formData = new FormData();
formData.append('plugin', file);
const { data } = await serverRequest.post<ServerResponse<{ message: string; pluginId: string; installPath: string; }>>(
'/Plugin/Import',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 60000, // 60秒超时
}
);
return data.data;
}
// ==================== 插件商店 ====================
/**
* 获取插件商店列表
*/
public static async getPluginStoreList (): Promise<PluginStoreList> {
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
return data.data;
}
public static async getPluginStoreDetail (id: string) {
/**
* 获取插件商店详情
* @param id 插件 ID
*/
public static async getPluginStoreDetail (id: string): Promise<PluginStoreItem> {
const { data } = await serverRequest.get<ServerResponse<PluginStoreItem>>(`/Plugin/Store/Detail/${id}`);
return data.data;
}
public static async installPluginFromStore (id: string, mirror?: string) {
// 插件安装可能需要较长时间(下载+解压设置5分钟超时
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id, mirror }, {
timeout: 300000, // 5分钟
/**
* 从商店安装插件
* @param id 插件 ID
* @param mirror 镜像源
*/
public static async installPluginFromStore (id: string, mirror?: string): Promise<void> {
await serverRequest.post<ServerResponse<void>>(
'/Plugin/Store/Install',
{ id, mirror },
{ timeout: 300000 } // 5分钟超时
);
}
// ==================== 插件配置 ====================
/**
* 获取插件配置
* @param id 插件包名
*/
public static async getPluginConfig (id: string): Promise<PluginConfigResponse> {
const { data } = await serverRequest.get<ServerResponse<PluginConfigResponse>>('/Plugin/Config', {
params: { id }
});
return data.data;
}
/**
* 设置插件配置
* @param id 插件包名
* @param config 配置内容
*/
public static async setPluginConfig (id: string, config: Record<string, unknown>): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { id, config });
}
/**
* 通知配置字段变化
* @param id 插件包名
* @param sessionId SSE 会话 ID
* @param key 变化的字段
* @param value 新值
* @param currentConfig 当前配置
*/
public static async notifyConfigChange (
id: string,
sessionId: string,
key: string,
value: unknown,
currentConfig: Record<string, unknown>
): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/Config/Change', {
id,
sessionId,
key,
value,
currentConfig
});
}
/**
* 获取配置 SSE URL
* @param id 插件包名
* @param config 初始配置
*/
public static getConfigSSEUrl (id: string, config?: Record<string, unknown>): string {
const params = new URLSearchParams({ id });
if (config) {
params.set('config', JSON.stringify(config));
}
return `/api/Plugin/Config/SSE?${params.toString()}`;
}
}

View File

@@ -281,6 +281,35 @@ export default class WebUIManager {
return data.data;
}
// 获取SSL证书状态
public static async getSSLStatus () {
const { data } = await serverRequest.get<ServerResponse<{
enabled: boolean;
certExists: boolean;
keyExists: boolean;
certContent: string;
keyContent: string;
}>>('/WebUIConfig/GetSSLStatus');
return data.data;
}
// 保存SSL证书
public static async saveSSLCert (cert: string, key: string) {
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>(
'/WebUIConfig/UploadSSLCert',
{ cert, key }
);
return data.data;
}
// 删除SSL证书
public static async deleteSSLCert () {
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>(
'/WebUIConfig/DeleteSSLCert'
);
return data.data;
}
// Passkey相关方法
public static async generatePasskeyRegistrationOptions () {
const { data } = await serverRequest.post<ServerResponse<any>>(

View File

@@ -10,6 +10,7 @@ import ChangePasswordCard from './change_password';
import LoginConfigCard from './login';
import OneBotConfigCard from './onebot';
import ServerConfigCard from './server';
import SSLConfigCard from './ssl';
import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui';
@@ -81,6 +82,11 @@ export default function ConfigPage () {
<ServerConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='SSL配置' key='ssl'>
<ConfigPageItem size='sm'>
<SSLConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='WebUI配置' key='webui'>
<ConfigPageItem>
<WebUIConfigCard />

View File

@@ -22,12 +22,14 @@ const OneBotConfigCard = () => {
musicSignUrl: '',
enableLocalFile2Url: false,
parseMultMsg: false,
imageDownloadProxy: '',
},
});
const reset = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl);
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url);
setOnebotValue('parseMultMsg', config.parseMultMsg);
setOnebotValue('imageDownloadProxy', config.imageDownloadProxy);
};
const onSubmit = handleOnebotSubmit(async (data) => {
@@ -104,6 +106,22 @@ const OneBotConfigCard = () => {
/>
)}
/>
<Controller
control={control}
name='imageDownloadProxy'
render={({ field }) => (
<Input
{...field}
label='图片下载代理'
placeholder='请输入代理地址,如 http://127.0.0.1:7890'
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}

View File

@@ -0,0 +1,161 @@
import { useRequest } from 'ahooks';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { Button } from '@heroui/button';
import { Textarea } from '@heroui/input';
import PageLoading from '@/components/page_loading';
import WebUIManager from '@/controllers/webui_manager';
const SSLConfigCard = () => {
const {
data: sslData,
loading: sslLoading,
refreshAsync: refreshSSL,
} = useRequest(WebUIManager.getSSLStatus);
const [sslCert, setSslCert] = useState('');
const [sslKey, setSslKey] = useState('');
const [sslSaving, setSslSaving] = useState(false);
useEffect(() => {
if (sslData) {
setSslCert(sslData.certContent || '');
setSslKey(sslData.keyContent || '');
}
}, [sslData]);
const handleSaveSSL = async () => {
if (!sslCert.trim() || !sslKey.trim()) {
toast.error('证书和私钥内容不能为空');
return;
}
setSslSaving(true);
try {
const result = await WebUIManager.saveSSLCert(sslCert, sslKey);
toast.success(result.message || 'SSL证书保存成功');
await refreshSSL();
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存SSL证书失败: ${msg}`);
} finally {
setSslSaving(false);
}
};
const handleDeleteSSL = async () => {
setSslSaving(true);
try {
const result = await WebUIManager.deleteSSLCert();
toast.success(result.message || 'SSL证书已删除');
setSslCert('');
setSslKey('');
await refreshSSL();
} catch (error) {
const msg = (error as Error).message;
toast.error(`删除SSL证书失败: ${msg}`);
} finally {
setSslSaving(false);
}
};
const handleRefresh = async () => {
try {
await refreshSSL();
toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新失败: ${msg}`);
}
};
if (sslLoading) return <PageLoading loading />;
return (
<>
<title>SSL配置 - NapCat WebUI</title>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2'>
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>SSL/HTTPS </div>
{sslData?.enabled && (
<span className='px-2 py-0.5 text-xs bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400 rounded-full whitespace-nowrap'>
</span>
)}
</div>
<p className='text-sm text-default-500 px-1'>
SSL证书后重启即可启用HTTPS(cert.pem)(key.pem)
</p>
<div className='p-3 bg-warning-50 dark:bg-warning-900/20 rounded-lg border border-warning-200 dark:border-warning-800'>
<p className='text-sm text-warning-700 dark:text-warning-400'>
<strong></strong>HTTP模式
</p>
</div>
</div>
<div className='flex flex-col gap-4'>
<Textarea
label='证书内容 (cert.pem)'
placeholder={'-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'}
value={sslCert}
onValueChange={setSslCert}
minRows={6}
maxRows={12}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400 font-mono text-sm',
}}
/>
<Textarea
label='私钥内容 (key.pem)'
placeholder={'-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'}
value={sslKey}
onValueChange={setSslKey}
minRows={6}
maxRows={12}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400 font-mono text-sm',
}}
/>
</div>
<div className='flex gap-2 justify-end'>
<Button
variant='flat'
isLoading={sslSaving || sslLoading}
onPress={handleRefresh}
size='sm'
>
</Button>
{sslData?.enabled && (
<Button
color='danger'
variant='flat'
isLoading={sslSaving || sslLoading}
onPress={handleDeleteSSL}
size='sm'
>
SSL证书
</Button>
)}
<Button
color='primary'
isLoading={sslSaving || sslLoading}
onPress={handleSaveSSL}
size='sm'
>
SSL证书
</Button>
</div>
</div>
</>
);
};
export default SSLConfigCard;

View File

@@ -112,9 +112,17 @@ export default function HttpDebug () {
const executeCommand = (commandId: string, mode: CommandPaletteExecuteMode) => {
const api = commandId as OneBotHttpApiPath;
const item = oneBotHttpApi[api];
const body = item?.payloadExample
? JSON.stringify(item.payloadExample, null, 2)
: (item?.payload ? JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2) : '{}');
let body = '{}';
if (item?.payloadExample) {
body = JSON.stringify(item.payloadExample, null, 2);
} else if (item?.payload) {
try {
body = JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2);
} catch (e) {
console.error('Error generating default:', e);
body = '{}';
}
}
handleSelectApi(api);
// 确保请求参数可见

View File

@@ -0,0 +1,164 @@
import { Tab, Tabs } from '@heroui/tabs';
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner';
import { useEffect, useState, useMemo } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
import { MdExtension } from 'react-icons/md';
import PageLoading from '@/components/page_loading';
import pluginManager from '@/controllers/plugin_manager';
interface ExtensionPage {
pluginId: string;
pluginName: string;
path: string;
title: string;
icon?: string;
description?: string;
}
export default function ExtensionPage () {
const [loading, setLoading] = useState(true);
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
const [selectedTab, setSelectedTab] = useState<string>('');
const [iframeLoading, setIframeLoading] = useState(false);
const fetchExtensionPages = async () => {
setLoading(true);
try {
const result = await pluginManager.getPluginList();
if (result.pluginManagerNotFound) {
setExtensionPages([]);
} else {
setExtensionPages(result.extensionPages || []);
// 默认选中第一个
if (result.extensionPages?.length > 0 && !selectedTab) {
setSelectedTab(`${result.extensionPages[0].pluginId}:${result.extensionPages[0].path}`);
}
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取扩展页面失败: ${msg}`);
} finally {
setLoading(false);
}
};
const refresh = async () => {
await fetchExtensionPages();
};
// 生成 tabs
const tabs = useMemo(() => {
return extensionPages.map(page => ({
key: `${page.pluginId}:${page.path}`,
title: page.title,
pluginId: page.pluginId,
pluginName: page.pluginName,
path: page.path,
icon: page.icon,
description: page.description,
}));
}, [extensionPages]);
// 获取当前选中页面的 iframe URL
const currentPageUrl = useMemo(() => {
if (!selectedTab) return '';
const [pluginId, ...pathParts] = selectedTab.split(':');
const path = pathParts.join(':').replace(/^\//, '');
// 获取认证 token
const token = localStorage.getItem('token') || '';
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${token}`;
}, [selectedTab]);
useEffect(() => {
fetchExtensionPages();
}, []);
useEffect(() => {
if (currentPageUrl) {
setIframeLoading(true);
}
}, [currentPageUrl]);
const handleIframeLoad = () => {
setIframeLoading(false);
};
return (
<>
<title> - NapCat WebUI</title>
<div className='p-2 md:p-4 relative h-full flex flex-col'>
<PageLoading loading={loading} />
<div className='flex mb-4 items-center gap-4'>
<div className='flex items-center gap-2 text-default-600'>
<MdExtension size={24} />
<span className='text-lg font-medium'></span>
</div>
<Button
isIconOnly
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
radius='full'
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
</div>
{extensionPages.length === 0 && !loading
? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
)
: (
<div className='flex-1 flex flex-col min-h-0'>
<Tabs
aria-label='Extension Pages'
className='max-w-full'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'flex-1 min-h-0 p-0',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className='flex items-center gap-2'>
{tab.icon && <span>{tab.icon}</span>}
<span>{tab.title}</span>
<span className='text-xs text-default-400'>({tab.pluginName})</span>
</div>
}
>
<div className='relative w-full h-[calc(100vh-220px)] bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title={tab.title}
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div>
</Tab>
))}
</Tabs>
</div>
)}
</div>
</>
);
}

View File

@@ -1,12 +1,15 @@
import { Button } from '@heroui/button';
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
import { FiUpload } from 'react-icons/fi';
import { useDisclosure } from '@heroui/modal';
import PageLoading from '@/components/page_loading';
import PluginDisplayCard from '@/components/display_card/plugin_card';
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
import useDialog from '@/hooks/use-dialog';
import PluginConfigModal from '@/pages/dashboard/plugin_config_modal';
export default function PluginPage () {
const [plugins, setPlugins] = useState<PluginItem[]>([]);
@@ -14,16 +17,21 @@ export default function PluginPage () {
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [currentPluginId, setCurrentPluginId] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
const loadPlugins = async () => {
setLoading(true);
setPluginManagerNotFound(false);
try {
const result = await PluginManager.getPluginList();
if (result.pluginManagerNotFound) {
const listResult = await PluginManager.getPluginList();
if (listResult.pluginManagerNotFound) {
setPluginManagerNotFound(true);
setPlugins([]);
} else {
setPlugins(result.plugins);
setPlugins(listResult.plugins);
}
} catch (e: any) {
toast.error(e.message);
@@ -36,23 +44,14 @@ export default function PluginPage () {
loadPlugins();
}, []);
const handleReload = async (name: string) => {
const loadingToast = toast.loading('重载中...');
try {
await PluginManager.reloadPlugin(name);
toast.success('重载成功', { id: loadingToast });
loadPlugins();
} catch (e: any) {
toast.error(e.message, { id: loadingToast });
}
};
const handleToggle = async (plugin: PluginItem) => {
const isEnable = plugin.status !== 'active';
const actionText = isEnable ? '启用' : '禁用';
const loadingToast = toast.loading(`${actionText}中...`);
try {
await PluginManager.setPluginStatus(plugin.name, isEnable, plugin.filename);
await PluginManager.setPluginStatus(plugin.id, isEnable);
toast.success(`${actionText}成功`, { id: loadingToast });
loadPlugins();
} catch (e: any) {
@@ -64,11 +63,31 @@ export default function PluginPage () {
return new Promise<void>((resolve, reject) => {
dialog.confirm({
title: '卸载插件',
content: `确定要卸载插件「${plugin.name}」吗? 此操作不可恢复。`,
content: (
<div className="flex flex-col gap-2">
<p>{plugin.name}? </p>
<p className="text-small text-default-500"></p>
</div>
),
// This 'dialog' utility might not support returning a value from UI interacting.
// We might need to implement a custom confirmation flow if we want a checkbox.
// Alternatively, use two buttons? "Uninstall & Clean", "Uninstall Only"?
// Standard dialog usually has Confirm/Cancel.
// Let's stick to a simpler "Uninstall" and then maybe a second prompt? Or just clean data?
// User requested: "Uninstall prompts whether to clean data".
// Let's use `window.confirm` for the second step or assume `dialog.confirm` is flexible enough?
// I will implement a two-step confirmation or try to modify the dialog hook if visible (not visible here).
// Let's use a standard `window.confirm` for the data cleanup question if the custom dialog doesn't support complex return.
// Better: Inside onConfirm, ask again?
onConfirm: async () => {
// Ask for data cleanup
// Since we are in an async callback, we can use another dialog or confirm.
// Native confirm is ugly but works reliably for logic:
const cleanData = window.confirm(`是否同时清理插件「${plugin.name}」的数据文件?\n点击“确定”清理数据点击“取消”仅卸载插件。`);
const loadingToast = toast.loading('卸载中...');
try {
await PluginManager.uninstallPlugin(plugin.name, plugin.filename);
await PluginManager.uninstallPlugin(plugin.id, cleanData);
toast.success('卸载成功', { id: loadingToast });
loadPlugins();
resolve();
@@ -84,11 +103,77 @@ export default function PluginPage () {
});
};
const handleConfig = (plugin: PluginItem) => {
setCurrentPluginId(plugin.id);
onOpen();
};
const handleImportClick = () => {
if (pluginManagerNotFound) {
dialog.confirm({
title: '插件管理器未加载',
content: (
<div className="space-y-2">
<p className="text-sm text-default-600">
</p>
<p className="text-sm text-default-600">
</p>
</div>
),
confirmText: '注册插件管理器',
cancelText: '取消',
onConfirm: async () => {
try {
await PluginManager.registerPluginManager();
toast.success('插件管理器注册成功');
setPluginManagerNotFound(false);
// 注册成功后打开文件选择器
fileInputRef.current?.click();
} catch (e: any) {
toast.error('注册失败: ' + e.message);
}
},
});
return;
}
fileInputRef.current?.click();
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 重置 input允许重复选择同一文件
e.target.value = '';
if (!file.name.endsWith('.zip')) {
toast.error('请选择 .zip 格式的插件包');
return;
}
const loadingToast = toast.loading('正在导入插件...');
try {
const result = await PluginManager.importLocalPlugin(file);
toast.success(result.message, { id: loadingToast });
loadPlugins();
} catch (err: any) {
toast.error(err.message || '导入失败', { id: loadingToast });
}
};
return (
<>
<title> - NapCat WebUI</title>
<div className='p-2 md:p-4 relative'>
<PageLoading loading={loading} />
<PluginConfigModal
isOpen={isOpen}
onOpenChange={onOpenChange}
pluginId={currentPluginId}
/>
<div className='flex mb-6 items-center gap-4'>
<h1 className="text-2xl font-bold"></h1>
<Button
@@ -99,6 +184,21 @@ export default function PluginPage () {
>
<IoMdRefresh size={24} />
</Button>
<Button
className="bg-primary-100/50 hover:bg-primary-200/50 text-primary-700 backdrop-blur-md"
radius='full'
startContent={<FiUpload size={18} />}
onPress={handleImportClick}
>
</Button>
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={handleFileChange}
/>
</div>
{pluginManagerNotFound ? (
@@ -117,11 +217,20 @@ export default function PluginPage () {
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4'>
{plugins.map(plugin => (
<PluginDisplayCard
key={plugin.name}
key={plugin.id}
data={plugin}
onReload={() => handleReload(plugin.name)}
onToggleStatus={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)}
onConfig={() => {
if (plugin.status !== 'active') {
toast.error('未启用插件,无法配置插件');
} else if (plugin.hasConfig) {
handleConfig(plugin);
} else {
toast.error('此插件没有配置哦');
}
}}
hasConfig={true}
/>
))}
</div>

View File

@@ -0,0 +1,389 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Select, SelectItem } from '@heroui/select';
import { Switch } from '@heroui/switch';
import { useEffect, useState, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { EventSourcePolyfill } from 'event-source-polyfill';
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
import key from '@/const/key';
interface Props {
isOpen: boolean;
onOpenChange: () => void;
/** 插件包名 (id) */
pluginId: string;
}
/** Schema 更新事件类型 */
interface SchemaUpdateEvent {
type: 'full' | 'updateField' | 'removeField' | 'addField' | 'showField' | 'hideField';
schema?: PluginConfigSchemaItem[];
key?: string;
field?: Partial<PluginConfigSchemaItem>;
afterKey?: string;
}
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) {
const [loading, setLoading] = useState(false);
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
const [config, setConfig] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const [supportReactive, setSupportReactive] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
// SSE 连接引用
const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
// 当前配置引用(用于 SSE 回调)
const configRef = useRef<Record<string, unknown>>({});
// 同步 config 到 ref
useEffect(() => {
configRef.current = config;
}, [config]);
/** 处理 schema 更新事件 */
const handleSchemaUpdate = useCallback((event: SchemaUpdateEvent) => {
switch (event.type) {
case 'full':
if (event.schema) {
setSchema(event.schema);
}
break;
case 'updateField':
if (event.key && event.field) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, ...event.field } : item
));
}
break;
case 'removeField':
if (event.key) {
setSchema(prev => prev.filter(item => item.key !== event.key));
}
break;
case 'addField':
if (event.field) {
setSchema(prev => {
const newField = event.field as PluginConfigSchemaItem;
// 检查字段是否已存在,如果存在则更新
const existingIndex = prev.findIndex(item => item.key === newField.key);
if (existingIndex !== -1) {
// 字段已存在,更新它
const newSchema = [...prev];
newSchema[existingIndex] = { ...newSchema[existingIndex], ...newField };
return newSchema;
}
// 字段不存在,添加新字段
if (event.afterKey) {
const index = prev.findIndex(item => item.key === event.afterKey);
if (index !== -1) {
const newSchema = [...prev];
newSchema.splice(index + 1, 0, newField);
return newSchema;
}
}
return [...prev, newField];
});
}
break;
case 'showField':
if (event.key) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, hidden: false } : item
));
}
break;
case 'hideField':
if (event.key) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, hidden: true } : item
));
}
break;
}
}, []);
/** 建立 SSE 连接 */
const connectSSE = useCallback((initialConfig: Record<string, unknown>) => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
const token = localStorage.getItem(key.token);
if (!token) {
console.warn('未登录,无法建立 SSE 连接');
return;
}
const _token = JSON.parse(token);
const url = PluginManager.getConfigSSEUrl(pluginId, initialConfig);
const es = new EventSourcePolyfill(url, {
headers: {
Authorization: `Bearer ${_token}`,
Accept: 'text/event-stream',
},
withCredentials: true,
});
eventSourceRef.current = es;
es.addEventListener('connected', (e) => {
const data = JSON.parse((e as MessageEvent).data);
setSessionId(data.sessionId);
setConnected(true);
});
es.addEventListener('schema', (e) => {
const data = JSON.parse((e as MessageEvent).data);
handleSchemaUpdate(data);
});
es.addEventListener('error', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data);
toast.error('插件错误: ' + data.message);
} catch {
// SSE 连接错误
setConnected(false);
}
});
es.onerror = () => {
setConnected(false);
};
}, [pluginId, handleSchemaUpdate]);
/** 关闭 SSE 连接 */
const disconnectSSE = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setSessionId(null);
setConnected(false);
}, []);
useEffect(() => {
if (isOpen && pluginId) {
loadConfig();
}
return () => {
disconnectSSE();
};
}, [isOpen, pluginId, disconnectSSE]);
/** 初始加载配置 */
const loadConfig = async () => {
setLoading(true);
setSchema([]);
setConfig({});
setSupportReactive(false);
disconnectSSE();
try {
const data = await PluginManager.getPluginConfig(pluginId);
setSchema(data.schema || []);
setConfig(data.config || {});
setSupportReactive(!!data.supportReactive);
// 如果支持响应式,建立 SSE 连接
if (data.supportReactive) {
connectSSE(data.config || {});
}
} catch (e: any) {
toast.error('加载配置失败: ' + e.message);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
await PluginManager.setPluginConfig(pluginId, config);
toast.success('Configuration saved');
onOpenChange();
} catch (e: any) {
toast.error('Save failed: ' + e.message);
} finally {
setSaving(false);
}
};
/** 更新配置 */
const updateConfig = useCallback((key: string, value: any) => {
setConfig((prev) => {
const newConfig = { ...prev, [key]: value };
// 如果是响应式字段且已连接 SSE通知后端
const field = schema.find(item => item.key === key);
if (field?.reactive && sessionId && connected) {
PluginManager.notifyConfigChange(pluginId, sessionId, key, value, newConfig)
.catch(e => console.error('通知配置变化失败:', e));
}
return newConfig;
});
}, [schema, sessionId, connected, pluginId]);
const renderField = (item: PluginConfigSchemaItem) => {
const value = config[item.key] ?? item.default;
switch (item.type) {
case 'string':
return (
<Input
key={item.key}
label={item.label}
placeholder={item.placeholder || item.description}
value={value || ''}
onValueChange={(val) => updateConfig(item.key, val)}
description={item.description}
className="mb-4"
/>
);
case 'number':
return (
<Input
key={item.key}
type="number"
label={item.label}
placeholder={item.placeholder || item.description}
value={String(value ?? 0)}
onValueChange={(val) => updateConfig(item.key, Number(val))}
description={item.description}
className="mb-4"
/>
);
case 'boolean':
return (
<div key={item.key} className="flex justify-between items-center mb-4 p-2 bg-default-100 rounded-lg">
<div className="flex flex-col">
<span className="text-small">{item.label}</span>
{item.description && <span className="text-tiny text-default-500">{item.description}</span>}
</div>
<Switch
isSelected={!!value}
onValueChange={(val) => updateConfig(item.key, val)}
/>
</div>
);
case 'select': {
const selectedValue = value !== undefined ? String(value) : undefined;
const options = item.options || [];
return (
<Select
key={item.key}
label={item.label}
placeholder={item.placeholder || 'Select an option'}
selectedKeys={selectedValue ? [selectedValue] : []}
onSelectionChange={(keys) => {
const val = Array.from(keys)[0];
const opt = options.find(o => String(o.value) === val);
updateConfig(item.key, opt ? opt.value : val);
}}
description={item.description}
className="mb-4"
>
{options.map((opt) => (
<SelectItem key={String(opt.value)} textValue={opt.label}>
{opt.label}
</SelectItem>
))}
</Select>
);
}
case 'multi-select': {
const selectedKeys = Array.isArray(value) ? value.map(String) : [];
const options = item.options || [];
return (
<Select
key={item.key}
label={item.label}
placeholder={item.placeholder || 'Select options'}
selectionMode="multiple"
selectedKeys={new Set(selectedKeys)}
onSelectionChange={(keys) => {
const selected = Array.from(keys).map(k => {
const opt = options.find(o => String(o.value) === k);
return opt ? opt.value : k;
});
updateConfig(item.key, selected);
}}
description={item.description}
className="mb-4"
>
{options.map((opt) => (
<SelectItem key={String(opt.value)} textValue={opt.label}>
{opt.label}
</SelectItem>
))}
</Select>
);
}
case 'html':
return (
<div key={item.key} className="mb-4">
{item.label && <h4 className="text-small font-bold mb-1">{item.label}</h4>}
<div dangerouslySetInnerHTML={{ __html: item.default || '' }} className="prose dark:prose-invert max-w-none" />
{item.description && <p className="text-tiny text-default-500 mt-1">{item.description}</p>}
</div>
);
case 'text':
return (
<div key={item.key} className="mb-4">
{item.label && <h4 className="text-small font-bold mb-1">{item.label}</h4>}
<div className="whitespace-pre-wrap text-default-700">{item.default || ''}</div>
{item.description && <p className="text-tiny text-default-500 mt-1">{item.description}</p>}
</div>
);
default:
return null;
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="2xl" scrollBehavior="inside">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<div className="flex items-center gap-2">
: {pluginId}
{supportReactive && (
<span className={`text-tiny px-2 py-0.5 rounded ${connected ? 'bg-success-100 text-success-600' : 'bg-warning-100 text-warning-600'}`}>
{connected ? '已连接' : '未连接'}
</span>
)}
</div>
</ModalHeader>
<ModalBody>
{loading ? (
<div className="flex justify-center p-8">Loading configuration...</div>
) : (
<div className="flex flex-col gap-2">
{schema.length === 0 ? (
<div className="text-center text-default-500">No configuration schema available.</div>
) : (
schema.filter(item => !item.hidden).map(renderField)
)}
</div>
)}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" onPress={handleSave} isLoading={saving}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View File

@@ -1,18 +1,18 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Select, SelectItem } from '@heroui/select';
import { Tab, Tabs } from '@heroui/tabs';
import { Card, CardBody } from '@heroui/card';
import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner';
import { useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch } from 'react-icons/io';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import clsx from 'clsx';
import { useRequest } from 'ahooks';
import { EventSourcePolyfill } from 'event-source-polyfill';
import PageLoading from '@/components/page_loading';
import PluginStoreCard from '@/components/display_card/plugin_store_card';
import PluginManager from '@/controllers/plugin_manager';
import WebUIManager from '@/controllers/webui_manager';
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
import MirrorSelectorModal from '@/components/mirror_selector_modal';
import { PluginStoreItem } from '@/types/plugin-store';
import useDialog from '@/hooks/use-dialog';
import key from '@/const/key';
@@ -35,23 +35,32 @@ const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
export default function PluginStorePage () {
const [plugins, setPlugins] = useState<PluginStoreItem[]>([]);
const [installedPlugins, setInstalledPlugins] = useState<PluginItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<string>('all');
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog();
// 获取镜像列表
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
cacheKey: 'napcat-mirrors',
staleTime: 60 * 60 * 1000,
});
const mirrors = mirrorsData?.mirrors || [];
// 商店列表源相关状态
const [storeSourceModalOpen, setStoreSourceModalOpen] = useState(false);
const [currentStoreSource, setCurrentStoreSource] = useState<string | undefined>(undefined);
// 下载镜像弹窗状态(安装时使用)
const [downloadMirrorModalOpen, setDownloadMirrorModalOpen] = useState(false);
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
const loadPlugins = async () => {
setLoading(true);
try {
const data = await PluginManager.getPluginStoreList();
setPlugins(data.plugins);
// 检查插件管理器是否已加载
const listResult = await PluginManager.getPluginList();
setPluginManagerNotFound(listResult.pluginManagerNotFound);
setInstalledPlugins(listResult.plugins || []);
} catch (e: any) {
toast.error(e.message);
} finally {
@@ -61,7 +70,7 @@ export default function PluginStorePage () {
useEffect(() => {
loadPlugins();
}, []);
}, [currentStoreSource]);
// 按标签分类和搜索
const categorizedPlugins = useMemo(() => {
@@ -90,6 +99,23 @@ export default function PluginStorePage () {
return categories;
}, [plugins, searchQuery]);
// 获取插件的安装状态和已安装版本
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
// 通过 id (包名) 或 name 匹配已安装的插件
const installed = installedPlugins.find(p => p.id === plugin.id);
if (!installed) {
return { status: 'not-installed' };
}
// 使用不等于判断:版本不同就显示更新
if (installed.version !== plugin.version) {
return { status: 'update-available', installedVersion: installed.version };
}
return { status: 'installed', installedVersion: installed.version };
};
const tabs = useMemo(() => {
return [
{ key: 'all', title: '全部', count: categorizedPlugins.all?.length || 0 },
@@ -101,60 +127,9 @@ export default function PluginStorePage () {
}, [categorizedPlugins]);
const handleInstall = async (plugin: PluginStoreItem) => {
// 检测是否是 GitHub 下载链接
const githubPattern = /^https:\/\/github\.com\//;
const isGitHubUrl = githubPattern.test(plugin.downloadUrl);
// 如果是 GitHub 链接,弹出镜像选择对话框
if (isGitHubUrl) {
let selectedMirror: string | undefined = undefined;
dialog.confirm({
title: '安装插件',
content: (
<div className="space-y-4">
<div>
<p className="text-sm mb-2">
: <span className="font-semibold">{plugin.name}</span>
</p>
<p className="text-sm mb-2">
: <span className="font-semibold">v{plugin.version}</span>
</p>
<p className="text-sm text-default-500 mb-4">
{plugin.description}
</p>
</div>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<Select
placeholder="自动选择 (默认)"
defaultSelectedKeys={['default']}
onSelectionChange={(keys) => {
const m = Array.from(keys)[0] as string;
selectedMirror = m === 'default' ? undefined : m;
}}
size="sm"
aria-label="选择镜像源"
>
{['default', ...mirrors].map(m => (
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
{m === 'default' ? '自动选择 (默认)' : m}
</SelectItem>
))}
</Select>
</div>
</div>
),
confirmText: '开始安装',
cancelText: '取消',
onConfirm: async () => {
await installPluginWithSSE(plugin.id, selectedMirror);
},
});
} else {
// 非 GitHub 链接,直接安装
await installPluginWithSSE(plugin.id);
}
// 弹窗选择下载镜像
setPendingInstallPlugin(plugin);
setDownloadMirrorModalOpen(true);
};
const installPluginWithSSE = async (pluginId: string, mirror?: string) => {
@@ -195,6 +170,35 @@ export default function PluginStorePage () {
} else if (data.success) {
toast.success('插件安装成功!', { id: loadingToast });
eventSource.close();
// 刷新插件列表
loadPlugins();
// 安装成功后检查插件管理器状态
if (pluginManagerNotFound) {
dialog.confirm({
title: '插件管理器未加载',
content: (
<div className="space-y-2">
<p className="text-sm text-default-600">
</p>
<p className="text-sm text-default-600">
</p>
</div>
),
confirmText: '注册插件管理器',
cancelText: '稍后再说',
onConfirm: async () => {
try {
await PluginManager.registerPluginManager();
toast.success('插件管理器注册成功');
setPluginManagerNotFound(false);
} catch (e: any) {
toast.error('注册失败: ' + e.message);
}
},
});
}
} else if (data.message) {
toast.loading(data.message, { id: loadingToast });
}
@@ -213,23 +217,55 @@ export default function PluginStorePage () {
}
};
const getStoreSourceDisplayName = () => {
if (!currentStoreSource) return '默认源';
try {
return new URL(currentStoreSource).hostname;
} catch {
return currentStoreSource;
}
};
return (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative">
<PageLoading loading={loading} />
{/* 头部 */}
<div className="flex mb-6 items-center gap-4">
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={loadPlugins}
>
<IoMdRefresh size={24} />
</Button>
<div className="flex mb-6 items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={loadPlugins}
isLoading={loading}
>
<IoMdRefresh size={24} />
</Button>
</div>
{/* 商店列表源卡片 */}
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
</div>
<Tooltip content="切换列表源">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</CardBody>
</Card>
</div>
{/* 搜索框 */}
@@ -244,35 +280,80 @@ export default function PluginStorePage () {
</div>
{/* 标签页 */}
<Tabs
aria-label="Plugin Store Categories"
className="max-w-full"
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={`${tab.title} (${tab.count})`}
>
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[tab.key]?.map((plugin) => (
<PluginStoreCard
key={plugin.id}
data={plugin}
onInstall={() => handleInstall(plugin)}
/>
))}
</div>
</Tab>
))}
</Tabs>
<div className="relative">
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
<Spinner size='lg' />
</div>
)}
<Tabs
aria-label="Plugin Store Categories"
className="max-w-full"
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={`${tab.title} (${tab.count})`}
>
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[tab.key]?.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
/>
);
})}
</div>
</Tab>
))}
</Tabs>
</div>
</div>
{/* 商店列表源选择弹窗 */}
<MirrorSelectorModal
isOpen={storeSourceModalOpen}
onClose={() => setStoreSourceModalOpen(false)}
onSelect={(mirror) => {
setCurrentStoreSource(mirror);
}}
currentMirror={currentStoreSource}
type="raw"
/>
{/* 下载镜像选择弹窗 */}
<MirrorSelectorModal
isOpen={downloadMirrorModalOpen}
onClose={() => {
setDownloadMirrorModalOpen(false);
setPendingInstallPlugin(null);
}}
onSelect={(mirror) => {
setSelectedDownloadMirror(mirror);
// 选择后立即开始安装
if (pendingInstallPlugin) {
setDownloadMirrorModalOpen(false);
installPluginWithSSE(pendingInstallPlugin.id, mirror);
setPendingInstallPlugin(null);
}
}}
currentMirror={selectedDownloadMirror}
type="file"
/>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More