Compare commits

..

66 Commits

Author SHA1 Message Date
手瓜一十雪
74b1da67d8 Add password login support to web UI and backend
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Implement password-based QQ login across the stack: add a PasswordLogin React component, integrate it into the QQ login page, and add a frontend controller method to call a new /QQLogin/PasswordLogin API. On the backend, add QQPasswordLoginHandler, router entry, and WebUiDataRuntime hooks (setPasswordLoginCall / requestPasswordLogin) plus a default handler. Register a password login callback in the shell (base.ts) that calls the kernel login service, handles common error cases and falls back to QR code when needed. Update types to include onPasswordLoginRequested and adjust NodeIKernelLoginService method signatures (including passwordLogin return type changed to Promise<QuickLoginResult>) and minor formatting fixes.
2026-02-02 19:48:31 +08:00
手瓜一十雪
78ac36f670 Add plugin no-auth API routes and WebUI handling
Introduce support for plugin API routes that do not require WebUI authentication. Updates include:

- napcat-onebot: Add apiNoAuth route storage and helpers (apiNoAuth, getNoAuth, postNoAuth, putNoAuth, deleteNoAuth), hasApiNoAuthRoutes, buildApiNoAuthRouter, and clear handling in PluginRouterRegistryImpl.
- napcat-onebot types: Extend PluginRouterRegistry interface with no-auth API methods and document that authenticated APIs remain separate.
- napcat-webui-backend: Mount a new unauthenticated plugin route handler at /plugin/:pluginId/api that looks up the plugin router and dispatches requests to the plugin's no-auth router, returning appropriate errors when context or routes are missing.
- napcat-plugin-builtin: Add example no-auth endpoints (public/info and health) and update logger messages to reflect both auth and no-auth API paths.
- Bump napcat-types version to 0.0.16 and update napcat-plugin-builtin dependency accordingly.

These changes enable plugins to expose public endpoints (e.g. health checks or public metadata) under /plugin/{pluginId}/api/ while keeping existing authenticated APIs under /api/Plugin/ext/{pluginId}/.
2026-02-02 19:13:01 +08:00
手瓜一十雪
74781fda0a Cache object properties to avoid extra RPC
Serialize non-function properties of server-side objects as cachedProps so simple property reads don't require additional RPCs. Added cachedProps to SerializedValue, have RpcServer.storeObjectRef serialize and attach cachedProps (skipping functions), updated serializer to deserialize cachedProps and pass them to proxyCreator, and updated client proxy creation to accept cachedProps and return cached top-level properties directly. Tests updated to expect direct property access for serialized/simple objects and arrays.
2026-02-02 18:59:23 +08:00
手瓜一十雪
3bead89d46 Support object references and deep proxying
Introduce remote object references (refId) and deep proxy support across client, server, serializer and types. Key changes:

- Add refId propagation in client proxies so child proxies inherit and include refId on RPC requests.
- Extend serializer to handle a new SerializedValueType.OBJECT_REF, add refResolver and pass refId to proxyCreator.
- Server: store object references in a Map with generated ref IDs, resolve paths with optional refId, serialize results to OBJECT_REF when shouldProxyResult returns true, and release cleans up references. Add defaultShouldProxyResult heuristic to decide which return values should remain proxied (class instances and objects with methods).
- Types: add refId fields and ObjectRef shape, expose shouldProxyResult option on RpcServerOptions, and include refId in ProxyMeta and serialized values.
- Tests updated across the suite to expect proxied return values (arrays/objects/class instances) and to await property access or method calls; add comprehensive tests for deep return value proxying, chained calls, callbacks, constructors on returned proxies, and lifecycle of remote object proxies.

These changes enable returning live/proxied remote objects (including class instances and objects with methods) from RPC calls, preserving remote behavior and allowing subsequent operations to target the same server-side object.
2026-02-02 18:43:37 +08:00
手瓜一十雪
a4527fd8ca Add napcat-rpc package with deep RPC
Introduce a new napcat-rpc package implementing a deep-proxy RPC system. Adds client (createDeepProxy, proxy helpers), server (RpcServer, createRpcServer), serializer (serialize/deserialize, callback registry), and transport layers (LocalTransport, MessageTransport, message server handler), plus an easy API (createRpcPair, mockRemote, createServer, createClient). Includes TypeScript types, tsconfig and package.json. Wire-up: add package alias in napcat-schema vite config and add napcat-rpc dependency to napcat-test along with comprehensive rpc tests.
2026-02-02 17:12:05 +08:00
手瓜一十雪
52b6627ebd Validate pluginId and use localStorage token
Return a 400 error when the /call-plugin/:pluginId route is requested without a pluginId to avoid calling getPluginExports with an undefined id (packages/napcat-plugin-builtin/index.ts).

Update the dashboard UI to read the auth token from localStorage (same-origin) instead of relying on a URL parameter; a comment about legacy webui_token in the URL was added while the implementation currently prefers localStorage.getItem('token') (packages/napcat-plugin-builtin/webui/dashboard.html).
2026-02-02 16:17:03 +08:00
手瓜一十雪
a5769b6a62 Expose plugin pages at /plugin/:id/page/:path
Add a public route to serve plugin extension pages without auth and update related pieces accordingly. Backend: register GET /plugin/:pluginId/page/:pagePath to locate the plugin router, validate page and HTML file existence, and send the file (returns appropriate 4xx/5xx errors). Frontend: switch iframe and new-window URLs to the new unauthenticated route (remove webui_token usage). Builtin plugin: clarify page registration comment and add a log line for the extension page URL. Minor formatting whitespace tweaks in plugin manager type annotations.
2026-02-02 15:40:18 +08:00
手瓜一十雪
d9297c1e10 Bump napcat-types & add plugin static/memory tests
Upgrade napcat-types to v0.0.15 and update the built-in plugin UI to test both filesystem and in-memory static resources. dashboard.html: clarify which plugin endpoints require auth, add buttons and a testMemoryResource() function that fetches an in-memory JSON resource, and add staticBase/memBase variables for non-auth static routes. napcat-webui-backend: return after 404 for missing memory files to stop further handling. (Lockfile updated accordingly.)
2026-02-02 15:35:26 +08:00
手瓜一十雪
94f07ab98b Support memory static files and plugin APIs
Introduce in-memory static file support and inter-plugin exports. Add MemoryStaticFile/MemoryFileGenerator types and expose staticOnMem in PluginRouterRegistry; router registry now tracks memory routes and exposes getters. Add getPluginExports to plugin manager adapters to allow plugins to call each other's exported modules. WebUI backend gains routes to serve /plugin/:pluginId/mem/* (memory files) and /plugin/:pluginId/files/* (plugin filesystem static) without auth. Update builtin plugin to demonstrate staticOnMem and inter-plugin call, and add frontend UI to open extension pages in a new window. Note: API router no longer mounts static filesystem routes — those are handled by webui-backend.
2026-02-02 15:01:26 +08:00
手瓜一十雪
01a6594707 Add image-size alias to napcat-schema vite config
Add a new Vite path alias '@/napcat-image-size' in packages/napcat-schema/vite.config.ts pointing to ../napcat-image-size. This enables cleaner imports of the napcat-image-size package from within napcat-schema source files.
2026-02-02 14:15:27 +08:00
手瓜一十雪
82a7154b92 fix: #1574 & Clear CJS cache and reload plugins on install
Add support for clearing CommonJS module cache when unloading plugins and reload plugins on install. PluginLoader now uses createRequire to access require.cache and exposes clearCache(pluginPath) to remove cached modules under the plugin directory; plugin manager calls this when unloading. Web UI backend install handlers now reload an existing plugin (with progress updates) instead of skipping registration, ensuring updated code/metadata take effect.
2026-02-02 14:14:06 +08:00
手瓜一十雪
9b385ac9c9 Ignore env file and db-shm in .gitignore
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Add packages/napcat-develop/config/.env to .gitignore to prevent committing environment configuration, and restore the guild1.db-shm entry (fix EOF/newline).
2026-02-02 13:22:29 +08:00
手瓜一十雪
e3d4cee416 Rename .env to .env.example
Rename the tracked dotenv file to .env.example (identical content) to avoid committing environment secrets and provide a template/example for project configuration.
2026-02-02 13:21:34 +08:00
手瓜一十雪
f6c79370cb Replace Type.Void() with Type.Object({})
Replace Type.Void() payload/return schemas with Type.Object({}) in several OneBot action extensions to represent empty object schemas and avoid void-type validation issues. Updated files: BotExit (payload & return), GetClientkey, GetFriendWithCategory, GetGroupAddRequest, GetRkey, GetRobotUinRange, GetUnidirectionalFriendList.
2026-02-02 13:17:06 +08:00
手瓜一十雪
39460e4acb Rename image-size alias to napcat-image-size
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Update Vite path alias from '@/image-size' to '@/napcat-image-size' in napcat-framework and napcat-shell vite.config.ts files to match the package directory ../napcat-image-size and ensure consistent import paths.
2026-02-01 17:46:40 +08:00
手瓜一十雪
f971c312b9 Add TIFF parser and buffer-based image size APIs
Introduce a TIFF parser and integrate it into the image detection/size parsing pipeline. Add buffer-based APIs (detectImageTypeFromBuffer, imageSizeFromBuffer, imageSizeFromBufferFallBack) and a helper to convert Buffer to a Readable stream; refactor parser registry into a type-to-parser map and a first-byte fast-path map for quicker detection. Harden WebP parsing with safer length checks. Add sample image resources and a comprehensive Vitest test suite (packages/napcat-test) with updated package dependency and resolve aliases. pnpm-lock updated to link the new package.
2026-02-01 17:42:58 +08:00
手瓜一十雪
2c3a304440 Split image parsers into separate files
Move PNG/JPEG/BMP/GIF/WEBP parser implementations out of index.ts into dedicated files under packages/napcat-image-size/src/parser. Export ImageParser and matchMagic from index.ts, import the new BmpParser, GifParser, JpegParser, PngParser and WebpParser classes, and update the parsers array to instantiate those classes. Keeps existing parsing logic and stream-based size detection while cleaning up index.ts for modularity.
2026-02-01 17:13:19 +08:00
手瓜一十雪
286b0e03f7 Add node types to package tsconfig; drop base typeRoots
Enable Node typings for packages/napcat-image-size by adding "compilerOptions.types": ["node"] to its tsconfig.json. Remove the restrictive "typeRoots" entry from tsconfig.base.json so type resolution can be controlled per-package and won't be forced globally.
2026-02-01 17:08:46 +08:00
手瓜一十雪
447f86e2b5 Use tabs, redesign theme settings UI
Replace the Accordion-based layout with a Tabs component and overhaul the Theme settings page UI. Rework the top header to a compact title/status area showing current theme, font and unsaved state; restyle action buttons and refresh icon. Convert font settings into a Card with improved FileInput flow (attempt delete before upload, better success/error toasts and page reload), and present theme previews and custom color editors as Cards per light/dark mode with updated ColorPicker handling. Update imports accordingly and apply various layout / class refinements.
2026-02-01 14:53:00 +08:00
手瓜一十雪
0592f1a99a Replace react-color with custom HSL ColorPicker
Remove the react-color dependency and add a custom ColorPicker implementation. The new component uses HSL parsing and hex<->HSL conversion, provides SatLightPanel and HueSlider subcomponents with mouse/touch drag support, and integrates @heroui/input for inline HEX/HSL editing. The ColorPicker onChange now emits an "hsl(...)" string; theme config parsing was updated to convert that string into the existing "h s% l%" format. Also update package.json to drop react-color.
2026-02-01 14:22:52 +08:00
手瓜一十雪
90e3936204 Support custom WebUI fonts and UI additions
Backend: add CheckWebUIFontExist API and route; set --font-family-mono CSS variable in InitWebUi for aacute/custom/default. Improve webui font uploader: force saved filename to CustomFont, robustly clean old webui/CustomFont files, and log failures.

Frontend: add FileManager.checkWebUIFontExists; update theme settings to show upload UI only when 'custom' is selected, display uploaded status, attempt delete-before-upload, reload after actions, and adjust Accordion props. ColorPicker: enable pointer events on PopoverContent to allow dragging. applyFont now sets --font-family-mono for all modes.
2026-02-01 14:00:27 +08:00
Qiao
1239f622d2 feat(webui): 插件卡片添加仓库主页跳转功能 (#1569)
* 为插件接口添加主页字段并优化展示组件

本次更新在 PluginPackageJson 接口及相关类型中新增了一个可选的 `homepage` 字段,允许插件指定其主页 URL。插件展示组件已更新,新增了一个指向主页的 GitHub 链接按钮,以提升用户对插件资源的访问便捷性。此外,PluginConfigModal 中新增了一个问题反馈按钮,该按钮直接链接到插件的主页,从而优化了用户支持与反馈机制。

* 优化标题区域样式,确保长标题正确截断显示省略号

* 移除插件相关接口中的可选主页字段,并优化展示组件以简化代码结构。更新了插件展示卡片的样式,确保更好的用户体验。

* 修改 PluginStoreCard 组件,新增 displayId 优化包名展示,并调整卡片样式以提升响应式表现。更新不同屏幕尺寸的最大宽度设置,确保包名截断显示且悬停可查看完整内容。

* Revert "修改 PluginStoreCard 组件,新增 displayId 优化包名展示,并调整卡片样式以提升响应式表现。更新不同屏幕尺寸的最大宽度设置,确保包名截断显示且悬停可查看完整内容。"

This reverts commit 0301421bc8.

* Revert "移除插件相关接口中的可选主页字段,并优化展示组件以简化代码结构。更新了插件展示卡片的样式,确保更好的用户体验。"

This reverts commit 1d22f19fa6.

* Revert "优化标题区域样式,确保长标题正确截断显示省略号"

This reverts commit 8a0912b5b9.

* Revert "为插件接口添加主页字段并优化展示组件"

This reverts commit 4e5dddde90.

* 再说丑我打死你

---------

Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
2026-02-01 13:47:18 +08:00
手瓜一十雪
d511e2bb3f Load .env, prefer WEBUI secret, add build script
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Load local .env in napcat-shell and prioritize NAPCAT_WEBUI_SECRET_KEY across the app. Changes include:
- package.json: add build:shell:config script to build shell and copy env for dev builds.
- packages/napcat-develop/config/.env and loadNapCat.cjs: update default WEBUI secret to `napcatqq` and set the same env in the loader.
- packages/napcat-shell/napcat.ts: read config/.env into process.env at startup, rework pathWrapper/logger initialization, and minor formatting cleanups.
- packages/napcat-webui-backend/index.ts: InitWebUi now prefers NAPCAT_WEBUI_SECRET_KEY from the environment (updates config and logs the change); only generates a random token if no env override and current token is default.
These changes make it easier to override the WebUI token for development and ensure the token is propagated when building the shell for dev workflows.
2026-02-01 11:24:25 +08:00
手瓜一十雪
ff93aa3dc7 Add dev config and copy-env build script
Add development config files and wiring to include them in the shell build. Creates packages/napcat-develop/config/.env and packages/napcat-develop/config/onebot11.json, adds a root script `build:shell:config` that builds napcat-shell and then runs napcat-develop's `copy-env` script. Update packages/napcat-develop/package.json to add the `copy-env` script (uses xcopy to copy config into the napcat-shell dist), tidy exports and metadata. This ensures dev configuration is packaged into napcat-shell during the build process.
2026-02-01 11:01:53 +08:00
手瓜一十雪
cc8891b6a1 fix: #1575 2026-02-01 10:33:58 +08:00
手瓜一十雪
7c65b1eaf1 Revert "增加个网络配置导出导入 (#1567)"
This reverts commit c0bcced5fb.
2026-02-01 10:22:15 +08:00
香草味的纳西妲喵
ebe3e9c63c feat(webui): 新增配置全量备份与恢复功能。 (#1571)
* feat(webui): 新增配置全量备份与恢复功能。

* chore: Remove dependencies "archiver"

* feat(webui): 增加上传文件大小限制配置并优化上传处理

* Use memory-based zip import/export and multer

Replace disk-based zip handling with in-memory streaming to avoid temp files: remove unzipper/@types(unzipper) deps from package.json; update BackupConfig to stream-export configs with compressing.zip.Stream and to import by extracting uploaded zip buffer via compressing.zip.UncompressStream into in-memory Buffers. Backup of existing config is kept in-memory instead of copying to tmp, and imported files are written with path normalization checks. Router changed to use multer.memoryStorage() for uploads (remove dynamic tmp/disk upload logic and uploadSizeLimit usage). Also remove uploadSizeLimit from config schema.

* Revert "chore: Remove dependencies "archiver""

This reverts commit 890736d3c7.

* Regenerate pnpm-lock.yaml (prune entries)

Regenerated pnpm-lock.yaml to reflect the current dependency resolution. This update prunes many removed/unused lock entries (notably archiver, unzipper and related @types, older/deprecated packages such as rimraf v2/fstream/bluebird, etc.) and removes platform 'libc' metadata from several platform-specific packages. There are no package.json changes; run `pnpm install` to sync your local node_modules with the updated lockfile.

---------

Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
2026-02-01 10:21:19 +08:00
冷曦
d33a872c42 修改合并消息上传资源日志 (#1573)
当上传资源有失败时为warn
全部成功则不输出日志
2026-02-01 09:53:40 +08:00
手瓜一十雪
9377dc3d52 Update version keys to 9.9.27-45627
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Rename release keys for build 45627 to 9.9.27-45627 across external metadata. Updated keys in packages/napcat-core/external/appid.json, napi2native.json, and packet.json (including x64 entries). No other payload values were modified.
2026-01-31 22:04:08 +08:00
手瓜一十雪
17322bb5a4 Add mappings for 9.9.26-45627 and 6.9.88-44725
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Add support for new client builds by updating external mappings. appid.json: add 9.9.26-45627 (appid 537340060) and 6.9.88-44725 (appid 537337594) with QUA strings. napi2native.json: add send/recv entries for 9.9.26-45627-x64, 6.9.88-44725-x64 and 6.9.88-44725-arm64. packet.json: add corresponding send/recv offsets for the same builds/architectures. These additions enable handling of the new versions in napcat-core.
2026-01-31 15:55:36 +08:00
冷曦
c0bcced5fb 增加个网络配置导出导入 (#1567)
* 增加个网络配置导出导入

重装容器时可以直接导出导入

* Remove unused import for useRef in network.tsx
2026-01-31 15:28:18 +08:00
手瓜一十雪
805c1d5ea2 Default plugins disabled; skip loading disabled
Change plugin loader to treat plugins as disabled by default (unless the id/dir is 'napcat-plugin-builtin') by using nullish coalescing when reading statusConfig. Add an early-return guard in the plugin manager/adapter that logs and skips loading when entry.enable is false. This prevents disabled plugins from being loaded automatically and provides a clear log message when skipped.
2026-01-31 15:26:56 +08:00
手瓜一十雪
b3399b07ad Silence update log; change update UI colors
Comment out the noisy '[NapCat Update] No pending updates found' log in UpdateNapCat.ts. Update frontend color choices: switch the plugin store action color from 'success' to 'default', and change the NewVersion chip and spinner from 'danger' to 'primary' in system_info.tsx. These tweaks reduce alarming red styling and quiet an unnecessary backend log.
2026-01-31 15:15:01 +08:00
手瓜一十雪
71f8504849 Refactor extension page layout and tab handling
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Improves the layout of the extension page by adjusting container heights and restructuring the header to better support responsive design. Moves the tab navigation to the header and displays only the selected extension page in the main content area, simplifying the rendering logic and improving user experience.
2026-01-30 19:41:27 +08:00
手瓜一十雪
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
155 changed files with 13205 additions and 1823 deletions

3
.gitignore vendored
View File

@@ -16,4 +16,5 @@ checkVersion.sh
bun.lockb
tests/run/
guild1.db-wal
guild1.db-shm
guild1.db-shm
packages/napcat-develop/config/.env

View File

@@ -6,6 +6,7 @@
"scripts": {
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
"build:shell:config": "pnpm --filter napcat-shell run build && pnpm --filter napcat-develop run copy-env",
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",

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

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

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

@@ -518,5 +518,13 @@
"9.9.26-44725": {
"appid": 537337569,
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
},
"9.9.27-45627": {
"appid": 537340060,
"qua": "V1_WIN_NQ_9.9.26_45627_GW_B"
},
"6.9.88-44725": {
"appid": 537337594,
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
}
}

View File

@@ -154,5 +154,17 @@
"9.9.26-44725-x64": {
"send": "0A18D0C",
"recv": "1D4BF0D"
},
"9.9.27-45627-x64": {
"send": "0A697CC",
"recv": "1E86AC1"
},
"6.9.88-44725-x64": {
"send": "2756EF6",
"recv": "0A36152"
},
"6.9.88-44725-arm64": {
"send": "2313C68",
"recv": "09693E4"
}
}

View File

@@ -662,5 +662,17 @@
"9.9.26-44725-x64": {
"send": "2CEBB20",
"recv": "2CEF0A0"
},
"9.9.27-45627-x64": {
"send": "2E59CC0",
"recv": "2E5D240"
},
"6.9.88-44725-x64": {
"send": "451FE90",
"recv": "4522A40"
},
"6.9.88-44725-arm64": {
"send": "3D79168",
"recv": "3D7BA78"
}
}

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);
}
@@ -94,12 +95,15 @@ export class PacketOperationContext {
.filter(Boolean)
);
const res = await Promise.allSettled(reqList);
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}`);
res.forEach((result, index) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
}
});
const failedCount = res.filter((r) => r.status === 'rejected').length;
if (failedCount > 0) {
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}`);
res.forEach((result, index) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
}
});
}
}
async UploadImage (img: PacketMsgPicElement) {
@@ -224,7 +228,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

@@ -21,7 +21,7 @@ export interface PasswordLoginRetType {
jumpWord: string;
tipsTitle: string;
tipsContent: string;
}
};
}
export interface PasswordLoginArgType {
@@ -55,37 +55,37 @@ export interface QuickLoginResult {
jumpUrl: string,
jumpWord: string,
tipsTitle: string,
tipsContent: string
tipsContent: string;
};
}
export interface NodeIKernelLoginService {
getMsfStatus: () => number;
setLoginMiscData(arg0: string, value: string): unknown;
setLoginMiscData (arg0: string, value: string): unknown;
getMachineGuid(): string;
getMachineGuid (): string;
get(): NodeIKernelLoginService;
get (): NodeIKernelLoginService;
connect(): boolean;
connect (): boolean;
addKernelLoginListener(listener: NodeIKernelLoginListener): number;
addKernelLoginListener (listener: NodeIKernelLoginListener): number;
removeKernelLoginListener(listener: number): void;
removeKernelLoginListener (listener: number): void;
initConfig(config: LoginInitConfig): void;
initConfig (config: LoginInitConfig): void;
getLoginMiscData(data: string): Promise<GeneralCallResult & { value: string }>;
getLoginMiscData (data: string): Promise<GeneralCallResult & { value: string; }>;
getLoginList(): Promise<{
getLoginList (): Promise<{
result: number, // 0是ok
LocalLoginInfoList: LoginListItem[]
LocalLoginInfoList: LoginListItem[];
}>;
quickLoginWithUin(uin: string): Promise<QuickLoginResult>;
quickLoginWithUin (uin: string): Promise<QuickLoginResult>;
passwordLogin(param: PasswordLoginArgType): Promise<unknown>;
passwordLogin (param: PasswordLoginArgType): Promise<QuickLoginResult>;
getQRCodePicture(): boolean;
getQRCodePicture (): boolean;
}

View File

@@ -0,0 +1,4 @@
NAPCAT_DISABLE_PIPE=1
NAPCAT_DISABLE_MULTI_PROCESS=1
NAPCAT_WEBUI_JWT_SECRET_KEY=napcat_dev_secret_key
NAPCAT_WEBUI_SECRET_KEY=napcatqq

View File

@@ -0,0 +1,39 @@
{
"network": {
"httpServers": [
{
"enable": true,
"name": "HTTP",
"host": "127.0.0.1",
"port": 3000,
"enableCors": true,
"enableWebsocket": false,
"messagePostFormat": "array",
"token": "",
"debug": false
}
],
"httpSseServers": [],
"httpClients": [],
"websocketServers": [
{
"enable": true,
"name": "WebSocket",
"host": "127.0.0.1",
"port": 3001,
"reportSelfMessage": false,
"enableForcePushEvent": true,
"messagePostFormat": "array",
"token": "",
"debug": false,
"heartInterval": 30000
}
],
"websocketClients": [],
"plugins": []
},
"musicSignUrl": "",
"enableLocalFile2Url": false,
"parseMultMsg": false,
"imageDownloadProxy": ""
}

View File

@@ -78,7 +78,7 @@ async function copyAll () {
process.env.NAPCAT_WORKDIR = TARGET_DIR;
// 开发环境使用固定密钥
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcat';
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcatqq';
console.log('Loading NapCat module...');
await import(pathToFileURL(NAPCAT_MJS_PATH).href);
}

View File

@@ -1,27 +1,28 @@
{
"name": "napcat-develop",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.js",
"scripts": {
"dev": "powershell ./nodeTest.ps1"
"name": "napcat-develop",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.js",
"scripts": {
"dev": "powershell ./nodeTest.ps1",
"copy-env": "xcopy config ..\\napcat-shell\\dist\\config /E /I /Y"
},
"exports": {
".": {
"require": "./index.js"
},
"exports": {
".": {
"require": "./index.js"
},
"./*": {
"require": "./*"
}
},
"dependencies": {
"fs-extra": "^11.3.2"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
"./*": {
"require": "./*"
}
},
"dependencies": {
"fs-extra": "^11.3.2"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

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

@@ -49,7 +49,9 @@ const FrameworkBaseConfig = () =>
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
'@/image-size': resolve(__dirname, '../image-size'),
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
},
},
build: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,5 +1,12 @@
import { BmpParser } from '@/napcat-image-size/src/parser/BmpParser';
import { GifParser } from '@/napcat-image-size/src/parser/GifParser';
import { JpegParser } from '@/napcat-image-size/src/parser/JpegParser';
import { PngParser } from '@/napcat-image-size/src/parser/PngParser';
import { TiffParser } from '@/napcat-image-size/src/parser/TiffParser';
import { WebpParser } from '@/napcat-image-size/src/parser/WebpParser';
import * as fs from 'fs';
import { ReadStream } from 'fs';
import { Readable } from 'stream';
export interface ImageSize {
width: number;
@@ -12,17 +19,18 @@ export enum ImageType {
BMP = 'bmp',
GIF = 'gif',
WEBP = 'webp',
TIFF = 'tiff',
UNKNOWN = 'unknown',
}
interface ImageParser {
export interface ImageParser {
readonly type: ImageType;
canParse(buffer: Buffer): boolean;
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
canParse (buffer: Buffer): boolean;
parseSize (stream: ReadStream): Promise<ImageSize | undefined>;
}
// 魔术匹配
function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
export function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
if (buffer.length < offset + magic.length) {
return false;
}
@@ -35,316 +43,39 @@ function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
return true;
}
// PNG解析器
class PngParser implements ImageParser {
readonly type = ImageType.PNG;
// PNG 魔术头89 50 4E 47 0D 0A 1A 0A
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
// 所有解析器实例
const parserInstances = {
png: new PngParser(),
jpeg: new JpegParser(),
bmp: new BmpParser(),
gif: new GifParser(),
webp: new WebpParser(),
tiff: new TiffParser(),
};
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.PNG_SIGNATURE);
}
// 首字节到可能的图片类型映射,用于快速筛选
const firstByteMap = new Map<number, ImageType[]>([
[0x42, [ImageType.BMP]], // 'B' - BMP
[0x47, [ImageType.GIF]], // 'G' - GIF
[0x49, [ImageType.TIFF]], // 'I' - TIFF (II - little endian)
[0x4D, [ImageType.TIFF]], // 'M' - TIFF (MM - big endian)
[0x52, [ImageType.WEBP]], // 'R' - RIFF (WebP)
[0x89, [ImageType.PNG]], // PNG signature
[0xFF, [ImageType.JPEG]], // JPEG SOI
]);
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(24) as Buffer;
if (!buf || buf.length < 24) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// 类型到解析器的映射
const typeToParser = new Map<ImageType, ImageParser>([
[ImageType.PNG, parserInstances.png],
[ImageType.JPEG, parserInstances.jpeg],
[ImageType.BMP, parserInstances.bmp],
[ImageType.GIF, parserInstances.gif],
[ImageType.WEBP, parserInstances.webp],
[ImageType.TIFF, parserInstances.tiff],
]);
// JPEG解析器
class JpegParser implements ImageParser {
readonly type = ImageType.JPEG;
// JPEG 魔术头FF D8
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
// JPEG标记常量
private readonly SOF_MARKERS = {
SOF0: 0xC0, // 基线DCT
SOF1: 0xC1, // 扩展顺序DCT
SOF2: 0xC2, // 渐进式DCT
SOF3: 0xC3, // 无损
} as const;
// 非SOF标记
private readonly NON_SOF_MARKERS: number[] = [
0xC4, // DHT
0xC8, // JPEG扩展
0xCC, // DAC
] as const;
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.JPEG_SIGNATURE);
}
isSOFMarker (marker: number): boolean {
return (
marker === this.SOF_MARKERS.SOF0 ||
marker === this.SOF_MARKERS.SOF1 ||
marker === this.SOF_MARKERS.SOF2 ||
marker === this.SOF_MARKERS.SOF3
);
}
isNonSOFMarker (marker: number): boolean {
return this.NON_SOF_MARKERS.includes(marker);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise<ImageSize | undefined>((resolve, reject) => {
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
let buffer = Buffer.alloc(0);
let offset = 0;
let found = false;
// 处理错误
stream.on('error', (err) => {
stream.destroy();
reject(err);
});
// 处理数据块
stream.on('data', (chunk: Buffer | string) => {
// 追加新数据到缓冲区
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
offset = 0;
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
const bufferSize = buffer.length;
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
// 从JPEG头部后开始扫描
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
// 寻找FF标记
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
const marker = buffer[offset + 1];
if (!marker) {
break;
}
// 跳过非SOF标记
if (this.isNonSOFMarker(marker)) {
offset += 2;
continue;
}
// 处理SOF标记 (包含尺寸信息)
if (this.isSOFMarker(marker)) {
// 确保缓冲区中有足够数据读取尺寸
if (offset + 9 < bufferSize) {
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
found = true;
stream.destroy();
resolve({ width, height });
return;
} else {
// 如果缓冲区内数据不够,保留当前位置等待更多数据
break;
}
}
}
offset++;
}
// 缓冲区管理: 如果处理了许多数据但没找到标记,
// 保留最后N字节用于跨块匹配丢弃之前的数据
if (offset > BUFFER_SIZE) {
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
if (offset > KEEP_BYTES) {
buffer = buffer.subarray(offset - KEEP_BYTES);
offset = KEEP_BYTES;
}
}
});
// 处理流结束
stream.on('end', () => {
if (!found) {
resolve(undefined);
}
});
});
}
}
// BMP解析器
class BmpParser implements ImageParser {
readonly type = ImageType.BMP;
// BMP 魔术头42 4D (BM)
private readonly BMP_SIGNATURE = [0x42, 0x4D];
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.BMP_SIGNATURE);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(26) as Buffer;
if (!buf || buf.length < 26) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32LE(18);
const height = buf.readUInt32LE(22);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// GIF解析器
class GifParser implements ImageParser {
readonly type = ImageType.GIF;
// GIF87a 魔术头47 49 46 38 37 61
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
// GIF89a 魔术头47 49 46 38 39 61
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
canParse (buffer: Buffer): boolean {
return (
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
matchMagic(buffer, this.GIF89A_SIGNATURE)
);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(10) as Buffer;
if (!buf || buf.length < 10) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt16LE(6);
const height = buf.readUInt16LE(8);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
class WebpParser implements ImageParser {
readonly type = ImageType.WEBP;
// WEBP RIFF 头52 49 46 46 (RIFF)
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
// WEBP 魔术头57 45 42 50 (WEBP)
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
// WEBP 块头
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
canParse (buffer: Buffer): boolean {
return (
buffer.length >= 12 &&
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
);
}
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
// 需要读取足够的字节来检测所有三种格式
const MAX_HEADER_SIZE = 32;
let totalBytes = 0;
let buffer = Buffer.alloc(0);
stream.on('error', reject);
stream.on('data', (chunk: Buffer | string) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer, chunkBuffer]);
totalBytes += chunk.length;
// 检查是否有足够的字节进行格式检测
if (totalBytes >= MAX_HEADER_SIZE) {
stream.destroy();
// 检查基本的WEBP签名
if (!this.canParse(buffer)) {
return resolve(undefined);
}
// 检查chunk头部位于字节12-15
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
// VP8格式 - 标准WebP
// 宽度和高度在帧头中
const width = buffer.readUInt16LE(26) & 0x3FFF;
const height = buffer.readUInt16LE(28) & 0x3FFF;
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
// VP8L格式 - 无损WebP
// 1字节标记后是14位宽度和14位高度
const bits = buffer.readUInt32LE(21);
const width = 1 + (bits & 0x3FFF);
const height = 1 + ((bits >> 14) & 0x3FFF);
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
// VP8X格式 - 扩展WebP
// 24位宽度和高度(减去1)
if (!buffer[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) {
return resolve(undefined);
}
const width = 1 + ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xFFFFFF);
const height = 1 + ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xFFFFFF);
return resolve({ width, height });
} else {
// 未知的WebP子格式
return resolve(undefined);
}
}
});
stream.on('end', () => {
// 如果没有读到足够的字节
if (totalBytes < MAX_HEADER_SIZE) {
resolve(undefined);
}
});
});
}
}
const parsers: ReadonlyArray<ImageParser> = [
new PngParser(),
new JpegParser(),
new BmpParser(),
new GifParser(),
new WebpParser(),
];
// 所有解析器列表(用于回退)
const parsers: ReadonlyArray<ImageParser> = Object.values(parserInstances);
export async function detectImageType (filePath: string): Promise<ImageType> {
return new Promise((resolve, reject) => {
@@ -354,18 +85,22 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
end: 63,
});
let buffer: Buffer | null = null;
const chunks: Buffer[] = [];
stream.once('error', (err) => {
stream.on('error', (err) => {
stream.destroy();
reject(err);
});
stream.once('readable', () => {
buffer = stream.read(64) as Buffer;
stream.destroy();
stream.on('data', (chunk: Buffer | string) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(chunkBuffer);
});
if (!buffer) {
stream.on('end', () => {
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) {
return resolve(ImageType.UNKNOWN);
}
@@ -377,12 +112,6 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
resolve(ImageType.UNKNOWN);
});
stream.once('end', () => {
if (!buffer) {
resolve(ImageType.UNKNOWN);
}
});
});
}
@@ -390,7 +119,7 @@ export async function imageSizeFromFile (filePath: string): Promise<ImageSize |
try {
// 先检测类型
const type = await detectImageType(filePath);
const parser = parsers.find(p => p.type === type);
const parser = typeToParser.get(type);
if (!parser) {
return undefined;
}
@@ -422,3 +151,71 @@ export async function imageSizeFallBack (
): Promise<ImageSize> {
return await imageSizeFromFile(filePath) ?? fallback;
}
// 从 Buffer 创建可读流
function bufferToReadStream (buffer: Buffer): ReadStream {
const readable = new Readable({
read () {
this.push(buffer);
this.push(null);
}
});
return readable as unknown as ReadStream;
}
// 从 Buffer 检测图片类型(使用首字节快速筛选)
export function detectImageTypeFromBuffer (buffer: Buffer): ImageType {
if (buffer.length === 0) {
return ImageType.UNKNOWN;
}
const firstByte = buffer[0]!;
const possibleTypes = firstByteMap.get(firstByte);
if (possibleTypes) {
// 根据首字节快速筛选可能的类型
for (const type of possibleTypes) {
const parser = typeToParser.get(type);
if (parser && parser.canParse(buffer)) {
return parser.type;
}
}
}
// 回退:遍历所有解析器
for (const parser of parsers) {
if (parser.canParse(buffer)) {
return parser.type;
}
}
return ImageType.UNKNOWN;
}
// 从 Buffer 解析图片尺寸
export async function imageSizeFromBuffer (buffer: Buffer): Promise<ImageSize | undefined> {
const type = detectImageTypeFromBuffer(buffer);
const parser = typeToParser.get(type);
if (!parser) {
return undefined;
}
try {
const stream = bufferToReadStream(buffer);
return await parser.parseSize(stream);
} catch (err) {
console.error(`解析图片尺寸出错: ${err}`);
return undefined;
}
}
// 从 Buffer 解析图片尺寸,带回退值
export async function imageSizeFromBufferFallBack (
buffer: Buffer,
fallback: ImageSize = {
width: 1024,
height: 1024,
}
): Promise<ImageSize> {
return await imageSizeFromBuffer(buffer) ?? fallback;
}

View File

@@ -0,0 +1,32 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// BMP解析器
export class BmpParser implements ImageParser {
readonly type = ImageType.BMP;
// BMP 魔术头42 4D (BM)
private readonly BMP_SIGNATURE = [0x42, 0x4D];
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.BMP_SIGNATURE);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(26) as Buffer;
if (!buf || buf.length < 26) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32LE(18);
const height = buf.readUInt32LE(22);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}

View File

@@ -0,0 +1,37 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// GIF解析器
export class GifParser implements ImageParser {
readonly type = ImageType.GIF;
// GIF87a 魔术头47 49 46 38 37 61
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
// GIF89a 魔术头47 49 46 38 39 61
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
canParse (buffer: Buffer): boolean {
return (
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
matchMagic(buffer, this.GIF89A_SIGNATURE)
);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(10) as Buffer;
if (!buf || buf.length < 10) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt16LE(6);
const height = buf.readUInt16LE(8);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}

View File

@@ -0,0 +1,123 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// JPEG解析器
export class JpegParser implements ImageParser {
readonly type = ImageType.JPEG;
// JPEG 魔术头FF D8
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
// JPEG标记常量
private readonly SOF_MARKERS = {
SOF0: 0xC0, // 基线DCT
SOF1: 0xC1, // 扩展顺序DCT
SOF2: 0xC2, // 渐进式DCT
SOF3: 0xC3, // 无损
} as const;
// 非SOF标记
private readonly NON_SOF_MARKERS: number[] = [
0xC4, // DHT
0xC8, // JPEG扩展
0xCC, // DAC
] as const;
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.JPEG_SIGNATURE);
}
isSOFMarker (marker: number): boolean {
return (
marker === this.SOF_MARKERS.SOF0 ||
marker === this.SOF_MARKERS.SOF1 ||
marker === this.SOF_MARKERS.SOF2 ||
marker === this.SOF_MARKERS.SOF3
);
}
isNonSOFMarker (marker: number): boolean {
return this.NON_SOF_MARKERS.includes(marker);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise<ImageSize | undefined>((resolve, reject) => {
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
let buffer = Buffer.alloc(0);
let offset = 0;
let found = false;
// 处理错误
stream.on('error', (err) => {
stream.destroy();
reject(err);
});
// 处理数据块
stream.on('data', (chunk: Buffer | string) => {
// 追加新数据到缓冲区
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
offset = 0;
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
const bufferSize = buffer.length;
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
// 从JPEG头部后开始扫描
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
// 寻找FF标记
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
const marker = buffer[offset + 1];
if (!marker) {
break;
}
// 跳过非SOF标记
if (this.isNonSOFMarker(marker)) {
offset += 2;
continue;
}
// 处理SOF标记 (包含尺寸信息)
if (this.isSOFMarker(marker)) {
// 确保缓冲区中有足够数据读取尺寸
if (offset + 9 < bufferSize) {
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
found = true;
stream.destroy();
resolve({ width, height });
return;
} else {
// 如果缓冲区内数据不够,保留当前位置等待更多数据
break;
}
}
}
offset++;
}
// 缓冲区管理: 如果处理了许多数据但没找到标记,
// 保留最后N字节用于跨块匹配丢弃之前的数据
if (offset > BUFFER_SIZE) {
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
if (offset > KEEP_BYTES) {
buffer = buffer.subarray(offset - KEEP_BYTES);
offset = KEEP_BYTES;
}
}
});
// 处理流结束
stream.on('end', () => {
if (!found) {
resolve(undefined);
}
});
});
}
}

View File

@@ -0,0 +1,32 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// PNG解析器
export class PngParser implements ImageParser {
readonly type = ImageType.PNG;
// PNG 魔术头89 50 4E 47 0D 0A 1A 0A
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.PNG_SIGNATURE);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(24) as Buffer;
if (!buf || buf.length < 24) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}

View File

@@ -0,0 +1,124 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// TIFF解析器
export class TiffParser implements ImageParser {
readonly type = ImageType.TIFF;
// TIFF Little Endian 魔术头49 49 2A 00 (II)
private readonly TIFF_LE_SIGNATURE = [0x49, 0x49, 0x2A, 0x00];
// TIFF Big Endian 魔术头4D 4D 00 2A (MM)
private readonly TIFF_BE_SIGNATURE = [0x4D, 0x4D, 0x00, 0x2A];
canParse (buffer: Buffer): boolean {
return (
matchMagic(buffer, this.TIFF_LE_SIGNATURE) ||
matchMagic(buffer, this.TIFF_BE_SIGNATURE)
);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
const MAX_BYTES = 64 * 1024; // 最多读取 64KB
stream.on('error', reject);
stream.on('data', (chunk: Buffer | string) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(chunkBuffer);
totalBytes += chunkBuffer.length;
if (totalBytes >= MAX_BYTES) {
stream.destroy();
}
});
stream.on('end', () => {
const buffer = Buffer.concat(chunks);
const size = this.parseTiffSize(buffer);
resolve(size);
});
stream.on('close', () => {
if (chunks.length > 0) {
const buffer = Buffer.concat(chunks);
const size = this.parseTiffSize(buffer);
resolve(size);
}
});
});
}
private parseTiffSize (buffer: Buffer): ImageSize | undefined {
if (buffer.length < 8) {
return undefined;
}
// 判断字节序
const isLittleEndian = buffer[0] === 0x49; // 'I'
const readUInt16 = isLittleEndian
? (offset: number) => buffer.readUInt16LE(offset)
: (offset: number) => buffer.readUInt16BE(offset);
const readUInt32 = isLittleEndian
? (offset: number) => buffer.readUInt32LE(offset)
: (offset: number) => buffer.readUInt32BE(offset);
// 获取第一个 IFD 的偏移量
const ifdOffset = readUInt32(4);
if (ifdOffset + 2 > buffer.length) {
return undefined;
}
// 读取 IFD 条目数量
const numEntries = readUInt16(ifdOffset);
let width: number | undefined;
let height: number | undefined;
// TIFF 标签
const TAG_IMAGE_WIDTH = 0x0100;
const TAG_IMAGE_HEIGHT = 0x0101;
// 遍历 IFD 条目
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + i * 12;
if (entryOffset + 12 > buffer.length) {
break;
}
const tag = readUInt16(entryOffset);
const type = readUInt16(entryOffset + 2);
// const count = readUInt32(entryOffset + 4);
// 根据类型读取值
let value: number;
if (type === 3) {
// SHORT (2 bytes)
value = readUInt16(entryOffset + 8);
} else if (type === 4) {
// LONG (4 bytes)
value = readUInt32(entryOffset + 8);
} else {
continue;
}
if (tag === TAG_IMAGE_WIDTH) {
width = value;
} else if (tag === TAG_IMAGE_HEIGHT) {
height = value;
}
if (width !== undefined && height !== undefined) {
return { width, height };
}
}
if (width !== undefined && height !== undefined) {
return { width, height };
}
return undefined;
}
}

View File

@@ -0,0 +1,90 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
export class WebpParser implements ImageParser {
readonly type = ImageType.WEBP;
// WEBP RIFF 头52 49 46 46 (RIFF)
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
// WEBP 魔术头57 45 42 50 (WEBP)
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
// WEBP 块头
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
canParse (buffer: Buffer): boolean {
return (
buffer.length >= 12 &&
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
);
}
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
// 需要读取足够的字节来检测所有三种格式
const MAX_HEADER_SIZE = 32;
let totalBytes = 0;
let buffer = Buffer.alloc(0);
stream.on('error', reject);
stream.on('data', (chunk: Buffer | string) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer, chunkBuffer]);
totalBytes += chunk.length;
// 检查是否有足够的字节进行格式检测
if (totalBytes >= MAX_HEADER_SIZE) {
stream.destroy();
// 检查基本的WEBP签名
if (!this.canParse(buffer)) {
return resolve(undefined);
}
// 检查chunk头部位于字节12-15
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
// VP8格式 - 标准WebP
// 宽度和高度在帧头中
const width = buffer.readUInt16LE(26) & 0x3FFF;
const height = buffer.readUInt16LE(28) & 0x3FFF;
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
// VP8L格式 - 无损WebP
// 1字节标记后是14位宽度和14位高度
const bits = buffer.readUInt32LE(21);
const width = 1 + (bits & 0x3FFF);
const height = 1 + ((bits >> 14) & 0x3FFF);
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
// VP8X格式 - 扩展WebP
// 24位宽度和高度(减去1)
if (buffer.length < 30) {
return resolve(undefined);
}
const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xFFFFFF);
const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xFFFFFF);
return resolve({ width, height });
} else {
// 未知的WebP子格式
return resolve(undefined);
}
}
});
stream.on('end', () => {
// 如果没有读到足够的字节
if (totalBytes < MAX_HEADER_SIZE) {
resolve(undefined);
}
});
});
}
}

View File

@@ -4,8 +4,8 @@ import { Type } from '@sinclair/typebox';
export class BotExit extends OneBotAction<void, void> {
override actionName = ActionName.Exit;
override payloadSchema = Type.Void();
override returnSchema = Type.Void();
override payloadSchema = Type.Object({});
override returnSchema = Type.Object({});
override actionSummary = '退出登录';
override actionTags = ['系统扩展'];
override payloadExample = {};

View File

@@ -12,7 +12,7 @@ type ReturnType = Static<typeof ReturnSchema>;
export class GetClientkey extends OneBotAction<void, ReturnType> {
override actionName = ActionName.GetClientkey;
override payloadSchema = Type.Void();
override payloadSchema = Type.Object({});
override returnSchema = ReturnSchema;
override actionSummary = '获取ClientKey';
override actionDescription = '获取当前登录帐号的ClientKey';

View File

@@ -18,7 +18,7 @@ type ReturnType = Static<typeof ReturnSchema>;
export class GetFriendWithCategory extends OneBotAction<void, ReturnType> {
override actionName = ActionName.GetFriendsWithCategory;
override payloadSchema = Type.Void();
override payloadSchema = Type.Object({});
override returnSchema = ReturnSchema;
override actionSummary = '获取带分组的好友列表';
override actionTags = ['用户扩展'];

View File

@@ -22,7 +22,7 @@ type ReturnType = Static<typeof ReturnSchema>;
export default class GetGroupAddRequest extends OneBotAction<void, ReturnType> {
override actionName = ActionName.GetGroupIgnoreAddRequest;
override payloadSchema = Type.Void();
override payloadSchema = Type.Object({});
override returnSchema = ReturnSchema;
override actionSummary = '获取群被忽略的加群请求';
override actionTags = ['群组接口'];

View File

@@ -8,7 +8,7 @@ type ReturnType = Static<typeof ReturnSchema>;
export class GetRkey extends GetPacketStatusDepends<void, ReturnType> {
override actionName = ActionName.GetRkey;
override payloadSchema = Type.Void();
override payloadSchema = Type.Object({});
override returnSchema = ReturnSchema;
override actionSummary = '获取 RKey';
override actionTags = ['系统扩展'];

View File

@@ -14,7 +14,7 @@ export class GetRobotUinRange extends OneBotAction<void, ReturnType> {
override returnExample = [
{ minUin: '12345678', maxUin: '87654321' }
];
override payloadSchema = Type.Void();
override payloadSchema = Type.Object({});
override returnSchema = ReturnSchema;
async _handle () {

View File

@@ -19,7 +19,7 @@ type ReturnType = Static<typeof ReturnSchema>;
export class GetUnidirectionalFriendList extends OneBotAction<void, ReturnType> {
override actionName = ActionName.GetUnidirectionalFriendList;
override payloadSchema = Type.Void();
override payloadSchema = Type.Object({});
override returnSchema = ReturnSchema;
override actionSummary = '获取单向好友列表';
override actionTags = ['用户扩展'];

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

View File

@@ -1,135 +1,51 @@
import { ActionMap } from '../action';
import { NapCatCore } from 'napcat-core';
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import { PluginConfig } from '../config/config';
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 './plugin/config';
import { PluginLoader } from './plugin/loader';
import {
PluginEntry,
PluginLogger,
PluginStatusConfig,
NapCatPluginContext,
IPluginManager,
} from './plugin/types';
import { PluginRouterRegistryImpl } from './plugin/router-registry';
export interface PluginPackageJson {
name?: string;
version?: string;
main?: string;
description?: string;
author?: string;
}
export interface PluginConfigItem {
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;
}
export class NapCatConfig {
static text (key: string, label: string, defaultValue?: string, description?: string): PluginConfigItem {
return { key, type: 'string', label, default: defaultValue, description };
}
static number (key: string, label: string, defaultValue?: number, description?: string): PluginConfigItem {
return { key, type: 'number', label, default: defaultValue, description };
}
static boolean (key: string, label: string, defaultValue?: boolean, description?: string): PluginConfigItem {
return { key, type: 'boolean', label, default: defaultValue, description };
}
static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string): PluginConfigItem {
return { key, type: 'select', label, options, default: defaultValue, description };
}
static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string): PluginConfigItem {
return { key, type: 'multi-select', label, options, default: defaultValue, description };
}
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;
}
}
export type PluginConfigSchema = PluginConfigItem[];
/**
* 插件日志接口 - 简化的日志 API
*/
export interface PluginLogger {
/** 普通日志 */
log (...args: any[]): void;
/** 调试日志 */
debug (...args: any[]): void;
/** 信息日志 */
info (...args: any[]): void;
/** 警告日志 */
warn (...args: any[]): void;
/** 错误日志 */
error (...args: any[]): void;
}
export interface NapCatPluginContext {
core: NapCatCore;
oneBot: NapCatOneBot11Adapter;
actions: ActionMap;
pluginName: string;
pluginPath: string;
configPath: string;
dataPath: string;
NapCatConfig: typeof NapCatConfig;
adapterName: string;
pluginManager: OB11PluginMangerAdapter;
/** 插件日志器 - 自动添加插件名称前缀 */
logger: PluginLogger;
}
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
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) => any | Promise<any>;
plugin_set_config?: (ctx: NapCatPluginContext, config: any) => void | Promise<void>;
}
export interface LoadedPlugin {
name: string;
dirname: string; // Actual directory name for path resolution
version?: string;
pluginPath: string;
entryPath: string;
packageJson?: PluginPackageJson;
module: PluginModule;
context: NapCatPluginContext; // Store context
}
export interface PluginStatusConfig {
[key: string]: boolean; // key: pluginName, value: enabled
}
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
export { PluginPackageJson } from './plugin/types';
export { PluginConfigItem } from './plugin/types';
export { PluginConfigUIController } from './plugin/types';
export { NapCatConfig } from './plugin/config';
export { PluginConfigSchema } from './plugin/types';
export { PluginLogger } from './plugin/types';
export { NapCatPluginContext } from './plugin/types';
export { PluginModule } from './plugin/types';
export { PluginStatusConfig } from './plugin/types';
export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types';
export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types';
export { MemoryStaticFile, MemoryFileGenerator } from './plugin/types';
export { PluginRouterRegistryImpl } from './plugin/router-registry';
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
private readonly pluginPath: string;
private readonly configPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
// Track failed plugins: ID -> Error Message
private failedPlugins: Map<string, string> = new Map();
private readonly loader: PluginLoader;
/** 插件注册表: ID -> 插件条目 */
private plugins: Map<string, PluginEntry> = new Map();
/** 插件路由注册表: ID -> 路由注册器 */
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
declare config: PluginConfig;
public NapCatConfig = NapCatConfig;
override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0;
return this.isEnable && this.getLoadedPlugins().length > 0;
}
constructor (
@@ -148,209 +64,143 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
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);
}
private loadPluginConfig (): PluginStatusConfig {
if (fs.existsSync(this.configPath)) {
try {
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
} catch (e) {
this.logger.logWarn('[Plugin Adapter] Error parsing plugins.json', e);
// ==================== 插件状态配置 ====================
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);
}
}
return {};
}
private savePluginConfig (config: PluginStatusConfig) {
try {
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (e) {
this.logger.logError('[Plugin Adapter] Error saving plugins.json', e);
}
const loadedCount = this.getLoadedPlugins().length;
this.logger.log(`[PluginManager] Loaded ${loadedCount} plugins`);
}
/**
* 扫描并加载插件
* 加载单个插件
*/
private async loadPlugins (): Promise<void> {
try {
// 确保插件目录存在
if (!fs.existsSync(this.pluginPath)) {
this.logger.logWarn(
`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`
);
fs.mkdirSync(this.pluginPath, { recursive: true });
return;
}
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
const pluginConfig = this.loadPluginConfig();
// 扫描文件和目录 (Only support directories as plugins now)
for (const item of items) {
if (!item.isDirectory()) {
continue;
}
const pluginId = item.name;
// Check if plugin is disabled in config
if (pluginConfig[pluginId] === false) {
this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled in config, skipping`);
continue;
}
// 处理目录插件
await this.loadDirectoryPlugin(item.name);
}
this.logger.log(
`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`
);
} catch (error) {
this.logger.logError('[Plugin Adapter] Error loading plugins:', error);
}
}
// loadFilePlugin removed
/**
* 加载目录插件
*/
public async loadDirectoryPlugin (dirname: string): Promise<void> {
const pluginDir = path.join(this.pluginPath, dirname);
const pluginConfig = this.loadPluginConfig();
const pluginId = dirname; // Use directory name as unique ID
if (pluginConfig[pluginId] === false) {
this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled by user`);
return;
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 {
// 尝试读取 package.json
let packageJson: PluginPackageJson | undefined;
const packageJsonPath = path.join(pluginDir, 'package.json');
await module.plugin_init(context);
if (fs.existsSync(packageJsonPath)) {
try {
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
packageJson = JSON.parse(packageContent);
} catch (error) {
this.logger.logWarn(
`[Plugin Adapter] Invalid package.json in ${dirname}:`,
error
);
}
}
// 确定入口文件
const entryFile = this.findEntryFile(pluginDir, packageJson);
if (!entryFile) {
this.logger.logWarn(
`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`
);
return;
}
const entryPath = path.join(pluginDir, entryFile);
const module = await this.importModule(entryPath);
if (!this.isValidPluginModule(module)) {
this.logger.logWarn(
`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`
);
return;
}
const plugin: LoadedPlugin = {
name: packageJson?.name || pluginId, // Use package.json name for API lookups, fallback to dir name
dirname: pluginId, // Keep track of actual directory name for path resolution
version: packageJson?.version,
pluginPath: pluginDir,
entryPath,
packageJson,
entry.loaded = true;
entry.runtime = {
status: 'loaded',
module,
context: {} as NapCatPluginContext // Will be populated in registerPlugin
context,
};
await this.registerPlugin(plugin);
} catch (error) {
this.logger.logError(
`[Plugin Adapter] Error loading directory plugin ${dirname}:`,
error
);
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 findEntryFile (
pluginDir: string,
packageJson?: PluginPackageJson
): string | null {
// 优先级package.json main > 默认文件名
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;
}
/**
* 动态导入模块
*/
private async importModule (filePath: string): Promise<any> {
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
// Add timestamp to force reload cache if supported or just import
// Note: dynamic import caching is tricky in ESM. Adding query param might help?
const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`;
return await import(fileUrlWithQuery);
}
/**
* 检查模块是否为有效的插件模块
*/
private isValidPluginModule (module: any): module is PluginModule {
return module && typeof module.plugin_init === 'function';
}
/**
* 注册插件
*/
/**
* 注册插件
*/
private async registerPlugin (plugin: LoadedPlugin): Promise<void> {
// 检查名称冲突
if (this.loadedPlugins.has(plugin.name)) {
this.logger.logWarn(
`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`
);
private async unloadPlugin (entry: PluginEntry): Promise<void> {
if (!entry.loaded || entry.runtime.status !== 'loaded') {
return;
}
// Create Context
// Use dirname for path resolution, name for identification
const dataPath = path.join(this.pluginPath, plugin.dirname, 'data');
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);
}
}
// 清理插件路由
const routerRegistry = this.pluginRouters.get(entry.id);
if (routerRegistry) {
routerRegistry.clear();
this.pluginRouters.delete(entry.id);
}
// 清理模块缓存
this.loader.clearCache(entry.pluginPath);
// 重置状态
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');
// Create plugin-specific logger with prefix
const pluginPrefix = `[Plugin: ${plugin.name}]`;
// 创建插件专用日志器
const pluginPrefix = `[Plugin: ${entry.id}]`;
const coreLogger = this.logger;
const pluginLogger: PluginLogger = {
log: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
@@ -360,110 +210,265 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args),
};
const context: NapCatPluginContext = {
// 创建插件路由注册器
const routerRegistry = new PluginRouterRegistryImpl(entry.id, entry.pluginPath);
// 保存到路由注册表
this.pluginRouters.set(entry.id, routerRegistry);
// 创建获取其他插件导出的方法
const getPluginExports = <T = any> (pluginId: string): T | undefined => {
const targetEntry = this.plugins.get(pluginId);
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
return undefined;
}
return targetEntry.runtime.module as T;
};
return {
core: this.core,
oneBot: this.obContext,
actions: this.actions,
pluginName: plugin.name, // Use package name for identification
pluginPath: plugin.pluginPath,
dataPath: dataPath,
configPath: configPath,
NapCatConfig: NapCatConfig,
pluginName: entry.id,
pluginPath: entry.pluginPath,
dataPath,
configPath,
NapCatConfig,
adapterName: this.name,
pluginManager: this,
logger: pluginLogger
logger: pluginLogger,
router: routerRegistry,
getPluginExports,
};
plugin.context = context; // Store context on plugin object
this.loadedPlugins.set(plugin.name, plugin);
this.logger.log(
`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''
}`
);
// 调用插件初始化方法(必须存在)
try {
await plugin.module.plugin_init(context);
this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`);
} catch (error: any) {
this.logger.logError(
`[Plugin Adapter] Error initializing plugin ${plugin.name}:`,
error
);
// Mark as failed
this.failedPlugins.set(plugin.name, error.message || 'Initialization failed');
this.loadedPlugins.delete(plugin.name);
}
}
// ==================== 公共 API ====================
/**
* 卸载插件
* 获取插件目录路径
*/
private async unloadPlugin (pluginName: string): Promise<void> {
const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) {
return;
}
// 调用插件清理方法
if (typeof plugin.module.plugin_cleanup === 'function') {
try {
await plugin.module.plugin_cleanup(plugin.context);
this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`);
} catch (error) {
this.logger.logError(
`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`,
error
);
}
}
this.loadedPlugins.delete(pluginName);
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
public async unregisterPlugin (pluginName: string): Promise<void> {
return this.unloadPlugin(pluginName);
}
public getPluginPath (): string {
return this.pluginPath;
}
public getPluginConfig (): PluginStatusConfig {
return this.loadPluginConfig();
/**
* 获取所有插件条目
*/
public getAllPlugins (): PluginEntry[] {
return Array.from(this.plugins.values());
}
public setPluginStatus (pluginName: string, enable: boolean): void {
// Try to find plugin by package name first
const plugin = this.loadedPlugins.get(pluginName);
// Use dirname for config storage (if plugin is loaded), otherwise assume pluginName is dirname
const configKey = plugin?.dirname || pluginName;
/**
* 获取已加载的插件列表
*/
public getLoadedPlugins (): PluginEntry[] {
return Array.from(this.plugins.values()).filter(p => p.loaded);
}
const config = this.loadPluginConfig();
config[configKey] = enable;
/**
* 通过 ID 获取插件信息
*/
public getPluginInfo (pluginId: string): PluginEntry | undefined {
return this.plugins.get(pluginId);
}
/**
* 获取插件路由注册器
*/
public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined {
return this.pluginRouters.get(pluginId);
}
/**
* 获取所有插件路由注册器
*/
public getAllPluginRouters (): Map<string, PluginRouterRegistryImpl> {
return this.pluginRouters;
}
/**
* 设置插件状态(启用/禁用)
*/
public async setPluginStatus (pluginId: string, enable: boolean): Promise<void> {
const config = this.getPluginConfig();
config[pluginId] = enable;
this.savePluginConfig(config);
if (!enable && plugin) {
// Unload by plugin.name (package name, which is the key in loadedPlugins)
this.unloadPlugin(plugin.name).catch(e => this.logger.logError('Error unloading', e));
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);
}
}
}
async onEvent<T extends OB11EmitEventContent> (event: T) {
/**
* 通过 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;
}
if (!entry.enable) {
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
return false;
}
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');
if (entry.loaded) {
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');
}
// ==================== 事件处理 ====================
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {
if (!this.isEnable) {
return;
}
try {
await Promise.allSettled(
Array.from(this.loadedPlugins.values()).map((plugin) =>
this.callPluginEventHandler(plugin, event)
this.getLoadedPlugins().map((entry) =>
this.callPluginEventHandler(entry, event)
)
);
} catch (error) {
this.logger.logError('[Plugin Adapter] Error handling event:', error);
this.logger.logError('[PluginManager] Error handling event:', error);
}
}
@@ -471,133 +476,75 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
* 调用插件的事件处理方法
*/
private async callPluginEventHandler (
plugin: LoadedPlugin,
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 plugin.module.plugin_onevent === 'function') {
await plugin.module.plugin_onevent(
plugin.context,
event
);
if (typeof module.plugin_onevent === 'function') {
await module.plugin_onevent(context, event);
}
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
if (
(event as any).message_type &&
typeof plugin.module.plugin_onmessage === 'function'
typeof module.plugin_onmessage === 'function'
) {
await plugin.module.plugin_onmessage(
plugin.context,
event as OB11Message
);
await module.plugin_onmessage(context, event as OB11Message);
}
} catch (error) {
this.logger.logError(
`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`,
error
);
this.logger.logError(`[PluginManager] Error calling plugin ${entry.id} event handler:`, error);
}
}
async open () {
// ==================== 生命周期 ====================
async open (): Promise<void> {
if (this.isEnable) {
return;
}
this.logger.log('[Plugin Adapter] Opening plugin adapter...');
this.logger.log('[PluginManager] Opening plugin manager...');
this.isEnable = true;
// 加载所有插件
await this.loadPlugins();
// 扫描并加载所有插件
await this.scanAndLoadPlugins();
this.logger.log(
`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`
);
this.logger.log(`[PluginManager] Plugin manager opened with ${this.getLoadedPlugins().length} plugins loaded`);
}
async close () {
async close (): Promise<void> {
if (!this.isEnable) {
return;
}
this.logger.log('[Plugin Adapter] Closing plugin adapter...');
this.logger.log('[PluginManager] Closing plugin manager...');
this.isEnable = false;
// 卸载所有插件
const pluginNames = Array.from(this.loadedPlugins.keys());
for (const pluginName of pluginNames) {
await this.unloadPlugin(pluginName);
// 卸载所有已加载的插件
for (const entry of this.plugins.values()) {
if (entry.loaded) {
await this.unloadPlugin(entry);
}
}
this.logger.log('[Plugin Adapter] Plugin adapter closed');
this.logger.log('[PluginManager] Plugin manager closed');
}
async reload () {
this.logger.log('[Plugin Adapter] Reloading plugin adapter...');
async reload (): Promise<OB11NetworkReloadType> {
this.logger.log('[PluginManager] Reloading plugin manager...');
// 先关闭然后重新打开
await this.close();
await this.open();
this.logger.log('[Plugin Adapter] Plugin adapter reloaded');
this.logger.log('[PluginManager] Plugin manager reloaded');
return OB11NetworkReloadType.Normal;
}
/**
* 获取已加载的插件列表
*/
public getLoadedPlugins (): LoadedPlugin[] {
return Array.from(this.loadedPlugins.values());
}
/**
* 获取插件信息
*/
public getPluginInfo (pluginName: string): LoadedPlugin | undefined {
return this.loadedPlugins.get(pluginName);
}
/**
* 重载指定插件
*/
public async reloadPlugin (pluginName: string): Promise<boolean> {
const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) {
this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginName} not found`);
return false;
}
const dirname = plugin.dirname;
try {
// 卸载插件
await this.unloadPlugin(pluginName);
// 重新加载插件 - use dirname for directory loading
await this.loadDirectoryPlugin(dirname);
this.logger.log(
`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`
);
return true;
} catch (error) {
this.logger.logError(
`[Plugin Adapter] Error reloading plugin ${pluginName}:`,
error
);
return false;
}
}
public getPluginDataPath (pluginName: string): string {
// Lookup plugin by name (package name) and use dirname for path
const plugin = this.loadedPlugins.get(pluginName);
const dirname = plugin?.dirname || pluginName; // fallback to pluginName if not found
return path.join(this.pluginPath, dirname, 'data');
}
public getPluginConfigPath (pluginName: string): string {
return path.join(this.getPluginDataPath(pluginName), 'config.json');
}
}

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,320 @@
import fs from 'fs';
import path from 'path';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
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] ?? (pluginId === 'napcat-plugin-builtin');
// 创建插件条目
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] ?? (dirname === 'napcat-plugin-builtin'),
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;
}
/**
* 清除插件文件的 require 缓存
* 用于确保卸载插件时清理 CJS 模块缓存
*/
clearCache (pluginPath: string): void {
try {
// 规范化路径以确保匹配正确
const normalizedPluginPath = path.resolve(pluginPath);
// 遍历缓存并删除属于该插件目录的模块
Object.keys(require.cache).forEach((id) => {
if (id.startsWith(normalizedPluginPath)) {
delete require.cache[id];
this.logger.logDebug(`[PluginLoader] Cleared cache for: ${id}`);
}
});
} catch (e) {
this.logger.logError('[PluginLoader] Error clearing module cache:', e);
}
}
}

View File

@@ -0,0 +1,528 @@
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);
}
// 创建获取其他插件导出的方法
const getPluginExports = <T = any> (pluginId: string): T | undefined => {
const targetEntry = this.plugins.get(pluginId);
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
return undefined;
}
return targetEntry.runtime.module as T;
};
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,
getPluginExports,
};
}
// ==================== 公共 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;
}
if (!entry.enable) {
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
return false;
}
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,315 @@
import { Router, Request, Response, NextFunction } from 'express';
import path from 'path';
import {
PluginRouterRegistry,
PluginRequestHandler,
PluginApiRouteDefinition,
PluginPageDefinition,
PluginHttpRequest,
PluginHttpResponse,
HttpMethod,
MemoryStaticFile,
} 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;
}
/**
* 插件路由注册器实现
* 为每个插件创建独立的路由注册器,收集路由定义
*/
/** 内存静态路由定义 */
interface MemoryStaticRoute {
urlPath: string;
files: MemoryStaticFile[];
}
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
private apiRoutes: PluginApiRouteDefinition[] = [];
private apiNoAuthRoutes: PluginApiRouteDefinition[] = [];
private pageDefinitions: PluginPageDefinition[] = [];
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
private memoryStaticRoutes: MemoryStaticRoute[] = [];
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);
}
// ==================== 无认证 API 路由注册 ====================
apiNoAuth (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuthRoutes.push({ method, path: routePath, handler });
}
getNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('get', routePath, handler);
}
postNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('post', routePath, handler);
}
putNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('put', routePath, handler);
}
deleteNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('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 });
}
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void {
this.memoryStaticRoutes.push({ urlPath, files });
}
// ==================== 构建路由 ====================
/**
* 构建 Express Router用于 API 路由)
* 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理
*/
buildApiRouter (): Router {
const router = Router();
// 注册 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;
}
/**
* 检查是否有注册的无认证 API 路由
*/
hasApiNoAuthRoutes (): boolean {
return this.apiNoAuthRoutes.length > 0;
}
/**
* 构建无认证 Express Router用于 /plugin/{pluginId}/api/ 路径)
*/
buildApiNoAuthRouter (): Router {
const router = Router();
for (const route of this.apiNoAuthRoutes) {
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;
}
/**
* 检查是否有注册的静态资源路由
*/
hasStaticRoutes (): boolean {
return this.staticRoutes.length > 0 || this.memoryStaticRoutes.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;
}
/**
* 获取所有注册的静态路由
*/
getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> {
return [...this.staticRoutes];
}
/**
* 获取所有注册的内存静态路由
*/
getMemoryStaticRoutes (): MemoryStaticRoute[] {
return [...this.memoryStaticRoutes];
}
/**
* 清空路由(用于插件卸载)
*/
clear (): void {
this.apiRoutes = [];
this.apiNoAuthRoutes = [];
this.pageDefinitions = [];
this.staticRoutes = [];
this.memoryStaticRoutes = [];
}
}

View File

@@ -0,0 +1,392 @@
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 type MemoryFileGenerator = () => string | Buffer | Promise<string | Buffer>;
/** 内存静态文件定义 */
export interface MemoryStaticFile {
/** 文件路径(相对于 urlPath */
path: string;
/** 文件内容或生成器 */
content: string | Buffer | MemoryFileGenerator;
/** 可选的 MIME 类型 */
contentType?: string;
}
/** 插件路由注册器 */
export interface PluginRouterRegistry {
// ==================== API 路由注册(需要认证) ====================
/**
* 注册单个 API 路由(需要认证,挂载到 /api/Plugin/ext/{pluginId}/
* @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;
// ==================== 无认证 API 路由注册 ====================
/**
* 注册单个无认证 API 路由(挂载到 /plugin/{pluginId}/api/
* @param method HTTP 方法
* @param path 路由路径
* @param handler 请求处理器
*/
apiNoAuth (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
/** 注册 GET API无认证 */
getNoAuth (path: string, handler: PluginRequestHandler): void;
/** 注册 POST API无认证 */
postNoAuth (path: string, handler: PluginRequestHandler): void;
/** 注册 PUT API无认证 */
putNoAuth (path: string, handler: PluginRequestHandler): void;
/** 注册 DELETE API无认证 */
deleteNoAuth (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;
/**
* 提供内存生成的静态文件服务
* @param urlPath URL 路径
* @param files 内存文件列表
*/
staticOnMem (urlPath: string, files: MemoryStaticFile[]): 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}/
* 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/
*/
router: PluginRouterRegistry;
/**
* 获取其他插件的导出模块
* @param pluginId 目标插件 ID
* @returns 插件导出的模块,如果插件未加载则返回 undefined
*/
getPluginExports: <T = PluginModule>(pluginId: string) => T | undefined;
}
// ==================== 插件模块接口 ====================
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

@@ -1,10 +1,12 @@
import type { ActionMap } from 'napcat-types/napcat-onebot/action/index';
import { EventType } from 'napcat-types/napcat-onebot/event/index';
import type { PluginModule, PluginLogger, PluginConfigSchema } 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 startTime: number = Date.now();
let logger: PluginLogger | null = null;
@@ -14,6 +16,8 @@ interface BuiltinPluginConfig {
description: string;
theme?: string;
features?: string[];
apiUrl?: string;
apiEndpoints?: string[];
[key: string]: unknown;
}
@@ -30,9 +34,11 @@ 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.</p></div>'),
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' },
@@ -57,14 +63,162 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
logger?.warn('Failed to load config', e);
}
// ==================== 注册 WebUI 路由示例 ====================
// 注册静态资源目录
// 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权)
ctx.router.static('/static', 'webui');
// 注册内存生成的静态资源(无需鉴权)
// 可通过 /plugin/{pluginId}/mem/dynamic/info.json 访问
ctx.router.staticOnMem('/dynamic', [
{
path: '/info.json',
contentType: 'application/json',
// 使用生成器函数动态生成内容
content: () => JSON.stringify({
pluginName: ctx.pluginName,
generatedAt: new Date().toISOString(),
uptime: Date.now() - startTime,
config: currentConfig
}, null, 2)
},
{
path: '/readme.txt',
contentType: 'text/plain',
content: `NapCat Builtin Plugin\n=====================\nThis is a demonstration of the staticOnMem feature.\nPlugin: ${ctx.pluginName}\nPath: ${ctx.pluginPath}`
}
]);
// 注册 API 路由(需要鉴权,挂载到 /api/Plugin/ext/{pluginId}/
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 });
}
});
// ==================== 无认证 API 路由示例 ====================
// 路由挂载到 /plugin/{pluginId}/api/,无需 WebUI 登录即可访问
// 获取插件公开信息(无需鉴权)
ctx.router.getNoAuth('/public/info', (_req, res) => {
const uptime = Date.now() - startTime;
res.json({
code: 0,
data: {
pluginName: ctx.pluginName,
uptime,
uptimeFormatted: formatUptime(uptime),
platform: process.platform
}
});
});
// 健康检查接口(无需鉴权)
ctx.router.getNoAuth('/health', (_req, res) => {
res.json({
code: 0,
data: {
status: 'ok',
timestamp: new Date().toISOString()
}
});
});
// ==================== 插件互调用示例 ====================
// 演示如何调用其他插件的导出方法
ctx.router.get('/call-plugin/:pluginId', (req, res) => {
const { pluginId } = req.params;
if (!pluginId) {
res.status(400).json({
code: -1,
message: 'Plugin ID is required'
});
return;
}
// 使用 getPluginExports 获取其他插件的导出模块
const targetPlugin = ctx.getPluginExports<PluginModule>(pluginId);
if (!targetPlugin) {
res.status(404).json({
code: -1,
message: `Plugin '${pluginId}' not found or not loaded`
});
return;
}
// 返回目标插件的信息
res.json({
code: 0,
data: {
pluginId,
hasInit: typeof targetPlugin.plugin_init === 'function',
hasOnMessage: typeof targetPlugin.plugin_onmessage === 'function',
hasOnEvent: typeof targetPlugin.plugin_onevent === 'function',
hasCleanup: typeof targetPlugin.plugin_cleanup === 'function',
hasConfigSchema: Array.isArray(targetPlugin.plugin_config_schema),
hasConfigUI: Array.isArray(targetPlugin.plugin_config_ui),
}
});
});
// 注册扩展页面(无需鉴权,可通过 /plugin/{pluginId}/page/dashboard 访问)
ctx.router.page({
path: 'dashboard',
title: '插件仪表盘',
icon: '📊',
htmlFile: 'webui/dashboard.html',
description: '查看内置插件的运行状态和配置'
});
logger.info('WebUI 路由已注册:');
logger.info(' - API 路由(需认证): /api/Plugin/ext/' + ctx.pluginName + '/');
logger.info(' - API 路由(无认证): /plugin/' + ctx.pluginName + '/api/');
logger.info(' - 扩展页面: /plugin/' + ctx.pluginName + '/page/dashboard');
logger.info(' - 静态资源: /plugin/' + ctx.pluginName + '/files/static/');
logger.info(' - 内存资源: /plugin/' + ctx.pluginName + '/mem/dynamic/');
};
export const plugin_get_config = async () => {
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
return currentConfig;
};
export const plugin_set_config = async (ctx: any, config: BuiltinPluginConfig) => {
currentConfig = config;
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;
@@ -80,6 +234,74 @@ export const plugin_set_config = async (ctx: any, config: BuiltinPluginConfig) =
}
};
/**
* 响应式配置控制器 - 当插件配置界面打开时调用
* 用于初始化动态 UI 控制
*/
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;

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.9"
"napcat-types": "0.0.16"
},
"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,447 @@
<!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>
<button class="btn btn-primary" onclick="testMemoryResource()">
获取 info.json内存生成
</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>
// 从 localStorage 获取 token与父页面同源可直接访问
// 兼容旧版:如果 URL 有 webui_token 参数则优先使用
const urlParams = new URLSearchParams(window.location.search);
const webuiToken = localStorage.getItem('token') || '';
// 插件 API 基础路径(需要鉴权)
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
// 插件静态资源基础路径(不需要鉴权)
const staticBase = '/plugin/napcat-plugin-builtin/files';
// 插件内存资源基础路径(不需要鉴权)
const memBase = '/plugin/napcat-plugin-builtin/mem';
// 封装 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 fetch(`${staticBase}/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>`;
}
}
// 测试内存资源
async function testMemoryResource () {
const resultDiv = document.getElementById('static-result');
resultDiv.innerHTML = '<div class="loading">加载中</div>';
try {
// 内存资源不需要鉴权,直接请求
const response = await fetch(`${memBase}/dynamic/info.json`);
if (response.ok) {
const json = await response.json();
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;">${JSON.stringify(json, null, 2)}</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

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

@@ -0,0 +1,25 @@
{
"name": "napcat-rpc",
"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"
},
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -0,0 +1,366 @@
import {
type DeepProxyOptions,
type ProxyMeta,
RpcOperationType,
PROXY_META,
type RpcRequest,
} from './types.js';
import {
serialize,
deserialize,
SimpleCallbackRegistry,
extractCallbackIds,
} from './serializer.js';
/**
* 生成唯一请求 ID
*/
function generateRequestId (): string {
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
/**
* 创建深层 RPC 代理
*
* 将所有属性访问、方法调用等操作转换为 RPC 请求
*/
export function createDeepProxy<T = unknown> (options: DeepProxyOptions): T {
const {
transport,
rootPath = [],
refId: rootRefId,
// callbackTimeout 可供未来扩展使用
} = options;
void options.callbackTimeout;
const callbackRegistry = new SimpleCallbackRegistry();
// 注册回调处理器
if (transport.onCallback) {
transport.onCallback(async (callbackId, serializedArgs) => {
const callback = callbackRegistry.get(callbackId);
if (!callback) {
throw new Error(`Callback not found: ${callbackId}`);
}
const args = serializedArgs.map(arg => deserialize(arg, {
callbackResolver: (id) => {
const cb = callbackRegistry.get(id);
if (!cb) throw new Error(`Nested callback not found: ${id}`);
return cb;
},
proxyCreator: (path, proxyRefId, cachedProps) => createProxyAtPath(path, proxyRefId, cachedProps),
}));
const result = await callback(...args);
return serialize(result, { callbackRegistry });
});
}
/**
* 在指定路径创建代理
* @param path 路径
* @param refId 远程对象引用 ID
* @param cachedProps 缓存的属性值(避免属性访问需要 RPC
*/
function createProxyAtPath (path: PropertyKey[], refId?: string, cachedProps?: Record<string, unknown>): unknown {
const proxyMeta: ProxyMeta = {
path: [...path],
isProxy: true,
refId,
};
// 创建一个函数目标,以支持 apply 和 construct
const target = function () { } as unknown as Record<PropertyKey, unknown>;
return new Proxy(target, {
get (_target, prop) {
// 返回代理元数据
if (prop === PROXY_META) {
return proxyMeta;
}
// then 方法特殊处理,使代理可以被 await
if (prop === 'then') {
return undefined;
}
// 检查缓存属性(仅顶层代理,即 path 为空时)
if (path.length === 0 && cachedProps && typeof prop === 'string' && prop in cachedProps) {
return cachedProps[prop];
}
// 返回新的子路径代理(继承 refId不继承 cachedProps
return createProxyAtPath([...path, prop], refId);
},
set (_target, prop, value) {
const request: RpcRequest = {
id: generateRequestId(),
type: RpcOperationType.SET,
path: [...path, prop],
args: [serialize(value, { callbackRegistry })],
refId,
};
// 同步返回,但实际是异步操作
transport.send(request).catch(() => { /* ignore */ });
return true;
},
apply (_target, _thisArg, args) {
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry }));
const callbackIds = extractCallbackIds(serializedArgs);
const request: RpcRequest = {
id: generateRequestId(),
type: RpcOperationType.APPLY,
path,
args: serializedArgs,
callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined,
refId,
};
return createAsyncResultProxy(request);
},
construct (_target, args): object {
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry }));
const callbackIds = extractCallbackIds(serializedArgs);
const request: RpcRequest = {
id: generateRequestId(),
type: RpcOperationType.CONSTRUCT,
path,
args: serializedArgs,
callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined,
refId,
};
return createAsyncResultProxy(request) as object;
},
has (_target, prop) {
// 检查是否为代理元数据符号
if (prop === PROXY_META) {
return true;
}
// 同步返回 true实际检查通过异步完成
return true;
},
ownKeys () {
// 返回空数组,实际键需要通过异步获取
return [];
},
getOwnPropertyDescriptor (_target, _prop) {
return {
configurable: true,
enumerable: true,
writable: true,
};
},
deleteProperty (_target, prop) {
const request: RpcRequest = {
id: generateRequestId(),
type: RpcOperationType.DELETE,
path: [...path, prop],
refId,
};
transport.send(request).catch(() => { /* ignore */ });
return true;
},
getPrototypeOf () {
return Object.prototype;
},
});
}
/**
* 创建异步结果代理
* 返回一个 Promise-like 对象,可以被 await
* 同时也可以继续链式访问属性
*/
function createAsyncResultProxy (request: RpcRequest): unknown {
let resultPromise: Promise<unknown> | null = null;
const getResult = async (): Promise<unknown> => {
if (!resultPromise) {
resultPromise = (async () => {
const response = await transport.send(request);
if (!response.success) {
const error = new Error(response.error ?? 'RPC call failed');
if (response.stack) {
error.stack = response.stack;
}
throw error;
}
if (response.result === undefined) {
return undefined;
}
// 如果结果是可代理对象,返回代理
if (response.isProxyable && response.result) {
const deserialized = deserialize(response.result, {
callbackResolver: (id) => {
const cb = callbackRegistry.get(id);
if (!cb) throw new Error(`Callback not found: ${id}`);
return cb;
},
proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps),
});
return deserialized;
}
return deserialize(response.result, {
callbackResolver: (id) => {
const cb = callbackRegistry.get(id);
if (!cb) throw new Error(`Callback not found: ${id}`);
return cb;
},
proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps),
});
})();
}
return resultPromise;
};
// 创建一个可链式访问的代理
const target = function () { } as unknown as Record<PropertyKey, unknown>;
return new Proxy(target, {
get (_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => {
getResult().then(resolve, reject);
};
}
if (prop === 'catch') {
return (reject: (error: unknown) => void) => {
getResult().catch(reject);
};
}
if (prop === 'finally') {
return (callback: () => void) => {
getResult().finally(callback);
};
}
if (prop === PROXY_META) {
return undefined;
}
// 链式访问:等待结果后访问其属性
return createChainedProxy(getResult(), [prop]);
},
apply (_target, _thisArg, args) {
// 等待结果后调用
return getResult().then(result => {
if (typeof result === 'function') {
return result(...args);
}
throw new Error('Result is not callable');
});
},
});
}
/**
* 创建链式代理
* 用于处理 await result.prop.method() 这样的链式调用
*/
function createChainedProxy (parentPromise: Promise<unknown>, path: PropertyKey[]): unknown {
const target = function () { } as unknown as Record<PropertyKey, unknown>;
return new Proxy(target, {
get (_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => {
parentPromise
.then(parent => {
let value: unknown = parent;
for (const key of path) {
if (value === null || value === undefined) {
return undefined;
}
value = (value as Record<PropertyKey, unknown>)[key];
}
resolve(value);
})
.catch(reject);
};
}
if (prop === 'catch') {
return (reject: (error: unknown) => void) => {
parentPromise.catch(reject);
};
}
if (prop === 'finally') {
return (callback: () => void) => {
parentPromise.finally(callback);
};
}
return createChainedProxy(parentPromise, [...path, prop]);
},
apply (_target, _thisArg, args) {
return parentPromise.then(parent => {
let value: unknown = parent;
const pathToMethod = path.slice(0, -1);
const methodName = path[path.length - 1];
for (const key of pathToMethod) {
if (value === null || value === undefined) {
throw new Error(`Cannot access property '${String(key)}' of ${value}`);
}
value = (value as Record<PropertyKey, unknown>)[key];
}
const method = (value as Record<PropertyKey, unknown>)[methodName!];
if (typeof method !== 'function') {
throw new Error(`${String(methodName)} is not a function`);
}
return method.call(value, ...args);
});
},
});
}
return createProxyAtPath(rootPath, rootRefId) as T;
}
/**
* 获取代理的元数据
*/
export function getProxyMeta (proxy: unknown): ProxyMeta | undefined {
if (proxy != null && (typeof proxy === 'object' || typeof proxy === 'function')) {
try {
// 直接访问 Symbol 属性,代理的 get 陷阱会返回元数据
const meta = (proxy as Record<symbol, ProxyMeta | undefined>)[PROXY_META];
if (meta && meta.isProxy === true) {
return meta;
}
} catch {
// 忽略访问错误
}
}
return undefined;
}
/**
* 检查是否为 RPC 代理
*/
export function isRpcProxy (value: unknown): boolean {
return getProxyMeta(value) !== undefined;
}

View File

@@ -0,0 +1,130 @@
/**
* 简化版 RPC API
*
* 提供一键创建完全隔离的 client/server 对
* 在 client 端操作就像直接操作 server 端的变量一样
*/
import { LocalTransport } from './transport.js';
import { createDeepProxy, getProxyMeta, isRpcProxy } from './client.js';
import { RpcServer } from './server.js';
import type { ProxyMeta } from './types.js';
/**
* RPC 配对结果
*/
export interface RpcPair<T> {
/** 客户端代理 - 在这里操作就像直接操作服务端的变量 */
client: T;
/** 服务端原始对象 */
server: T;
/** 关闭连接 */
close (): void;
}
/**
* 创建 RPC 配对
*
* 快速创建完全隔离的 client/server 对client 端的所有操作都会通过 RPC 传递到 server 端执行
*
* @example
* ```ts
* const { client, server } = createRpcPair({
* name: 'test',
* greet: (msg: string) => `Hello, ${msg}!`,
* register: (handlers: { onSuccess: Function, onError: Function }) => {
* handlers.onSuccess('done');
* }
* });
*
* // 在 client 端操作,就像直接操作 server 端的变量
* await client.greet('world'); // 返回 'Hello, world!'
*
* // 支持包含多个回调的对象
* await client.register({
* onSuccess: (result) => console.log(result),
* onError: (err) => console.error(err)
* });
* ```
*/
export function createRpcPair<T extends object> (target: T): RpcPair<T> {
const transport = new LocalTransport(target);
const client = createDeepProxy<T>({ transport });
return {
client,
server: target,
close: () => transport.close(),
};
}
/**
* 模拟远程变量
*
* 将一个本地变量包装成"看起来像远程变量"的代理,所有操作都通过 RPC 隔离
*
* @example
* ```ts
* const remoteApi = mockRemote({
* counter: 0,
* increment() { return ++this.counter; },
* async fetchData(id: number) { return { id, data: 'test' }; }
* });
*
* // 所有操作都是异步的,通过 RPC 隔离
* await remoteApi.increment(); // 1
* await remoteApi.fetchData(123); // { id: 123, data: 'test' }
* ```
*/
export function mockRemote<T extends object> (target: T): T {
return createRpcPair(target).client;
}
/**
* 创建 RPC 服务端
*
* @example
* ```ts
* const server = createServer({
* users: new Map(),
* addUser(id: string, name: string) {
* this.users.set(id, { name });
* return true;
* }
* });
*
* // 获取传输层供客户端连接
* const transport = server.getTransport();
* ```
*/
export function createServer<T extends object> (target: T): {
target: T;
handler: RpcServer;
getTransport (): LocalTransport;
} {
const handler = new RpcServer({ target });
return {
target,
handler,
getTransport: () => new LocalTransport(target),
};
}
/**
* 创建指向服务端的客户端
*
* @example
* ```ts
* const server = createServer(myApi);
* const client = createClient<typeof myApi>(server.getTransport());
*
* await client.someMethod();
* ```
*/
export function createClient<T extends object> (transport: LocalTransport): T {
return createDeepProxy<T>({ transport });
}
// 重新导出常用工具
export { getProxyMeta, isRpcProxy };
export type { ProxyMeta };

View File

@@ -0,0 +1,60 @@
/**
* napcat-rpc
*
* 深层 RPC 代理库 - 将对象的所有层级操作转换为 RPC 调用
*/
// 简化 API推荐使用
export {
createRpcPair,
mockRemote,
createServer,
createClient,
} from './easy.js';
// 类型导出
export {
RpcOperationType,
SerializedValueType,
PROXY_META,
type RpcRequest,
type RpcResponse,
type SerializedValue,
type RpcTransport,
type RpcServerHandler,
type RpcServerOptions,
type DeepProxyOptions,
type ProxyMeta,
} from './types.js';
// 序列化工具
export {
serialize,
deserialize,
extractCallbackIds,
SimpleCallbackRegistry,
type CallbackRegistry,
type SerializeContext,
type DeserializeContext,
} from './serializer.js';
// 客户端代理
export {
createDeepProxy,
getProxyMeta,
isRpcProxy,
} from './client.js';
// 服务端
export {
RpcServer,
createRpcServer,
} from './server.js';
// 传输层
export {
LocalTransport,
MessageTransport,
createMessageServerHandler,
type MessageTransportOptions,
} from './transport.js';

View File

@@ -0,0 +1,410 @@
import {
SerializedValue,
SerializedValueType,
PROXY_META,
type ProxyMeta,
} from './types.js';
/**
* 回调注册器接口
*/
export interface CallbackRegistry {
register (fn: Function): string;
get (id: string): Function | undefined;
remove (id: string): void;
}
/**
* 简单的回调注册器实现
*/
export class SimpleCallbackRegistry implements CallbackRegistry {
private callbacks = new Map<string, Function>();
private counter = 0;
register (fn: Function): string {
const id = `cb_${++this.counter}_${Date.now()}`;
this.callbacks.set(id, fn);
return id;
}
get (id: string): Function | undefined {
return this.callbacks.get(id);
}
remove (id: string): void {
this.callbacks.delete(id);
}
clear (): void {
this.callbacks.clear();
}
}
/**
* 序列化上下文
*/
export interface SerializeContext {
/** 回调注册器 */
callbackRegistry?: CallbackRegistry;
/** 已序列化对象映射(用于循环引用检测) */
seen?: WeakMap<object, SerializedValue>;
/** 深度限制 */
maxDepth?: number;
/** 当前深度 */
currentDepth?: number;
}
/**
* 反序列化上下文
*/
export interface DeserializeContext {
/** 回调解析器 */
callbackResolver?: (id: string) => Function;
/** 代理创建器 */
proxyCreator?: (path: PropertyKey[], refId?: string, cachedProps?: Record<string, unknown>) => unknown;
/** 对象引用解析器 */
refResolver?: (refId: string) => unknown;
}
/**
* 将值序列化为可传输格式
*/
export function serialize (value: unknown, context: SerializeContext = {}): SerializedValue {
const {
callbackRegistry,
seen = new WeakMap(),
maxDepth = 50,
currentDepth = 0,
} = context;
// 深度检查
if (currentDepth > maxDepth) {
return { type: SerializedValueType.STRING, value: '[Max depth exceeded]' };
}
// 基本类型处理
if (value === undefined) {
return { type: SerializedValueType.UNDEFINED };
}
if (value === null) {
return { type: SerializedValueType.NULL };
}
const valueType = typeof value;
if (valueType === 'boolean') {
return { type: SerializedValueType.BOOLEAN, value };
}
if (valueType === 'number') {
const numValue = value as number;
if (Number.isNaN(numValue)) {
return { type: SerializedValueType.NUMBER, value: 'NaN' };
}
if (!Number.isFinite(numValue)) {
return { type: SerializedValueType.NUMBER, value: numValue > 0 ? 'Infinity' : '-Infinity' };
}
return { type: SerializedValueType.NUMBER, value };
}
if (valueType === 'bigint') {
return { type: SerializedValueType.BIGINT, value: (value as bigint).toString() };
}
if (valueType === 'string') {
return { type: SerializedValueType.STRING, value };
}
if (valueType === 'symbol') {
return {
type: SerializedValueType.SYMBOL,
value: (value as symbol).description ?? '',
};
}
if (valueType === 'function') {
const fn = value as Function;
if (callbackRegistry) {
const callbackId = callbackRegistry.register(fn);
return {
type: SerializedValueType.FUNCTION,
callbackId,
className: fn.name || 'anonymous',
};
}
return {
type: SerializedValueType.FUNCTION,
className: fn.name || 'anonymous',
};
}
// 对象类型处理
const obj = value as object;
// 检查是否为代理对象
if (PROXY_META in obj) {
const meta = (obj as Record<symbol, ProxyMeta | undefined>)[PROXY_META];
if (meta) {
return {
type: SerializedValueType.PROXY_REF,
proxyPath: meta.path,
};
}
}
// 循环引用检测
if (seen.has(obj)) {
return seen.get(obj)!;
}
// Date
if (obj instanceof Date) {
return { type: SerializedValueType.DATE, value: obj.toISOString() };
}
// RegExp
if (obj instanceof RegExp) {
return {
type: SerializedValueType.REGEXP,
value: { source: obj.source, flags: obj.flags },
};
}
// Error
if (obj instanceof Error) {
return {
type: SerializedValueType.ERROR,
value: obj.message,
className: obj.constructor.name,
properties: {
stack: serialize(obj.stack, { ...context, seen, currentDepth: currentDepth + 1 }),
},
};
}
// Buffer / Uint8Array
if (obj instanceof Uint8Array) {
return {
type: SerializedValueType.BUFFER,
value: Array.from(obj as Uint8Array),
};
}
// Node.js Buffer
if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) {
const BufferClass = (globalThis as unknown as { Buffer: { isBuffer (obj: unknown): boolean; }; }).Buffer;
if (BufferClass.isBuffer(obj)) {
return {
type: SerializedValueType.BUFFER,
value: Array.from(obj as unknown as Uint8Array),
};
}
}
// Map
if (obj instanceof Map) {
const entries: SerializedValue[] = [];
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
for (const [k, v] of obj) {
entries.push(serialize([k, v], nextContext));
}
return {
type: SerializedValueType.MAP,
elements: entries,
};
}
// Set
if (obj instanceof Set) {
const elements: SerializedValue[] = [];
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
for (const v of obj) {
elements.push(serialize(v, nextContext));
}
return {
type: SerializedValueType.SET,
elements,
};
}
// Promise
if (obj instanceof Promise) {
return { type: SerializedValueType.PROMISE };
}
// Array
if (Array.isArray(obj)) {
const result: SerializedValue = {
type: SerializedValueType.ARRAY,
elements: [],
};
seen.set(obj, result);
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
result.elements = obj.map(item => serialize(item, nextContext));
return result;
}
// 普通对象
const result: SerializedValue = {
type: SerializedValueType.OBJECT,
className: obj.constructor?.name ?? 'Object',
properties: {},
};
seen.set(obj, result);
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
for (const key of Object.keys(obj)) {
result.properties![key] = serialize((obj as Record<string, unknown>)[key], nextContext);
}
return result;
}
/**
* 将序列化数据还原为值
*/
export function deserialize (data: SerializedValue, context: DeserializeContext = {}): unknown {
const { callbackResolver, proxyCreator, refResolver } = context;
switch (data.type) {
case SerializedValueType.UNDEFINED:
return undefined;
case SerializedValueType.NULL:
return null;
case SerializedValueType.BOOLEAN:
return data.value;
case SerializedValueType.NUMBER:
if (data.value === 'NaN') return NaN;
if (data.value === 'Infinity') return Infinity;
if (data.value === '-Infinity') return -Infinity;
return data.value;
case SerializedValueType.BIGINT:
return BigInt(data.value as string);
case SerializedValueType.STRING:
return data.value;
case SerializedValueType.SYMBOL:
return Symbol(data.value as string);
case SerializedValueType.FUNCTION:
if (data.callbackId && callbackResolver) {
return callbackResolver(data.callbackId);
}
// 返回一个占位函数
return function placeholder () {
throw new Error('Remote function cannot be called without callback resolver');
};
case SerializedValueType.DATE:
return new Date(data.value as string);
case SerializedValueType.REGEXP: {
const { source, flags } = data.value as { source: string; flags: string; };
return new RegExp(source, flags);
}
case SerializedValueType.ERROR: {
const error = new Error(data.value as string);
if (data.properties?.['stack']) {
error.stack = deserialize(data.properties['stack'], context) as string;
}
return error;
}
case SerializedValueType.BUFFER: {
const arr = data.value as number[];
if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) {
const BufferClass = (globalThis as unknown as { Buffer: { from (arr: number[]): Uint8Array; }; }).Buffer;
return BufferClass.from(arr);
}
return new Uint8Array(arr);
}
case SerializedValueType.MAP: {
const map = new Map();
if (data.elements) {
for (const element of data.elements) {
const [k, v] = deserialize(element, context) as [unknown, unknown];
map.set(k, v);
}
}
return map;
}
case SerializedValueType.SET: {
const set = new Set();
if (data.elements) {
for (const element of data.elements) {
set.add(deserialize(element, context));
}
}
return set;
}
case SerializedValueType.PROMISE:
return Promise.resolve(undefined);
case SerializedValueType.ARRAY:
return (data.elements ?? []).map(elem => deserialize(elem, context));
case SerializedValueType.PROXY_REF:
if (data.proxyPath && proxyCreator) {
return proxyCreator(data.proxyPath);
}
return {};
case SerializedValueType.OBJECT_REF:
// 对象引用:在客户端创建代理,在服务端解析为实际对象
if (data.refId) {
// 优先使用 refResolver服务端场景
if (refResolver) {
return refResolver(data.refId);
}
// 否则创建代理(客户端场景)
if (proxyCreator) {
// 反序列化缓存的属性
let cachedValues: Record<string, unknown> | undefined;
if (data.cachedProps) {
cachedValues = {};
for (const [key, val] of Object.entries(data.cachedProps)) {
cachedValues[key] = deserialize(val, context);
}
}
return proxyCreator([], data.refId, cachedValues);
}
}
return {};
case SerializedValueType.OBJECT: {
const obj: Record<string, unknown> = {};
if (data.properties) {
for (const [key, val] of Object.entries(data.properties)) {
obj[key] = deserialize(val, context);
}
}
return obj;
}
default:
return undefined;
}
}
/**
* 提取序列化参数中的回调ID映射
*/
export function extractCallbackIds (args: SerializedValue[]): Record<number, string> {
const result: Record<number, string> = {};
args.forEach((arg, index) => {
if (arg.type === SerializedValueType.FUNCTION && arg.callbackId) {
result[index] = arg.callbackId;
}
});
return result;
}

View File

@@ -0,0 +1,577 @@
import {
type RpcRequest,
type RpcResponse,
type RpcServerOptions,
type SerializedValue,
RpcOperationType,
SerializedValueType,
} from './types.js';
import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js';
/**
* 生成唯一引用 ID
*/
function generateRefId (): string {
return `ref_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
/**
* 默认的代理判断函数
* 判断返回值是否应该保持代理引用(而非完全序列化)
* 策略class 实例和有方法的对象保持代理,普通对象直接序列化
*/
function defaultShouldProxyResult (value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value !== 'object' && typeof value !== 'function') {
return false;
}
// 函数保持代理
if (typeof value === 'function') {
return true;
}
// 可安全序列化的内置类型不代理
if (value instanceof Date || value instanceof RegExp || value instanceof Error) {
return false;
}
if (value instanceof Map || value instanceof Set) {
return false;
}
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
return false;
}
// 数组不代理
if (Array.isArray(value)) {
return false;
}
// 检查对象原型是否为 Object.prototype普通对象
const proto = Object.getPrototypeOf(value);
if (proto === Object.prototype || proto === null) {
// 普通对象检查是否有方法
const hasMethod = Object.values(value as object).some(v => typeof v === 'function');
return hasMethod;
}
// 非普通对象class 实例)- 保持代理
return true;
}
/**
* RPC 服务端
*
* 处理来自客户端的 RPC 请求,在目标对象上执行操作
*/
export class RpcServer {
private target: unknown;
private callbackInvoker?: (callbackId: string, args: unknown[]) => Promise<unknown>;
private localCallbacks = new SimpleCallbackRegistry();
/** 对象引用存储 */
private objectRefs = new Map<string, unknown>();
/** 代理判断函数 */
private shouldProxyResult: (value: unknown) => boolean;
constructor (options: RpcServerOptions) {
this.target = options.target;
this.callbackInvoker = options.callbackInvoker;
this.shouldProxyResult = options.shouldProxyResult ?? defaultShouldProxyResult;
}
/**
* 处理 RPC 请求
*/
async handleRequest (request: RpcRequest): Promise<RpcResponse> {
try {
switch (request.type) {
case RpcOperationType.GET:
return this.handleGet(request);
case RpcOperationType.SET:
return this.handleSet(request);
case RpcOperationType.APPLY:
return await this.handleApply(request);
case RpcOperationType.CONSTRUCT:
return await this.handleConstruct(request);
case RpcOperationType.HAS:
return this.handleHas(request);
case RpcOperationType.OWNKEYS:
return this.handleOwnKeys(request);
case RpcOperationType.DELETE:
return this.handleDelete(request);
case RpcOperationType.GET_DESCRIPTOR:
return this.handleGetDescriptor(request);
case RpcOperationType.GET_PROTOTYPE:
return this.handleGetPrototype(request);
case RpcOperationType.RELEASE:
return this.handleRelease(request);
default:
return {
id: request.id,
success: false,
error: `Unknown operation type: ${request.type}`,
};
}
} catch (error) {
return this.createErrorResponse(request.id, error);
}
}
/**
* 解析路径获取目标值,支持 refId
*/
private resolvePath (path: PropertyKey[], refId?: string): { parent: unknown; key: PropertyKey | undefined; value: unknown; } {
// 如果有 refId从引用存储中获取根对象
let current = refId ? this.objectRefs.get(refId) : this.target;
if (refId && current === undefined) {
throw new Error(`Object reference not found: ${refId}`);
}
let parent: unknown = null;
let key: PropertyKey | undefined;
for (let i = 0; i < path.length; i++) {
parent = current;
key = path[i];
if (key === undefined) {
throw new Error('Path contains undefined key');
}
if (current === null || current === undefined) {
throw new Error(`Cannot access property '${String(key)}' of ${current}`);
}
current = (current as Record<PropertyKey, unknown>)[key];
}
return { parent, key, value: current };
}
/**
* 存储对象引用并返回序列化的引用
* 同时序列化可序列化的属性值,避免属性访问需要额外 RPC
*/
private storeObjectRef (value: unknown): SerializedValue {
const refId = generateRefId();
this.objectRefs.set(refId, value);
const className = value?.constructor?.name;
// 序列化非函数属性
const cachedProps: Record<string, SerializedValue> = {};
if (value && typeof value === 'object') {
for (const key of Object.keys(value)) {
const propValue = (value as Record<string, unknown>)[key];
// 跳过函数(方法需要远程调用)
if (typeof propValue === 'function') {
continue;
}
// 序列化属性值
try {
cachedProps[key] = serialize(propValue, { callbackRegistry: this.localCallbacks });
} catch {
// 序列化失败的属性跳过,让客户端通过 RPC 获取
}
}
}
return {
type: SerializedValueType.OBJECT_REF,
refId,
className: className !== 'Object' ? className : undefined,
cachedProps: Object.keys(cachedProps).length > 0 ? cachedProps : undefined,
};
}
/**
* 序列化结果值,如果需要代理则存储引用
*/
private serializeResult (value: unknown): { result: SerializedValue; isProxyable: boolean; refId?: string; } {
const shouldProxy = this.shouldProxyResult(value);
if (shouldProxy) {
const ref = this.storeObjectRef(value);
return {
result: ref,
isProxyable: true,
refId: ref.refId,
};
}
return {
result: serialize(value, { callbackRegistry: this.localCallbacks }),
isProxyable: false,
};
}
/**
* 处理 GET 操作
*/
private handleGet (request: RpcRequest): RpcResponse {
const { value } = this.resolvePath(request.path, request.refId);
const { result, isProxyable, refId } = this.serializeResult(value);
return {
id: request.id,
success: true,
result,
isProxyable,
refId,
};
}
/**
* 处理 SET 操作
*/
private handleSet (request: RpcRequest): RpcResponse {
const path = request.path;
if (path.length === 0 && !request.refId) {
throw new Error('Cannot set root object');
}
const parentPath = path.slice(0, -1);
const key = path[path.length - 1]!;
const { value: parent } = this.resolvePath(parentPath, request.refId);
if (parent === null || parent === undefined) {
throw new Error(`Cannot set property '${String(key)}' of ${parent}`);
}
const newValue = request.args?.[0]
? deserialize(request.args[0], {
callbackResolver: this.createCallbackResolver(request),
refResolver: (refId) => this.objectRefs.get(refId),
})
: undefined;
(parent as Record<PropertyKey, unknown>)[key] = newValue;
return {
id: request.id,
success: true,
};
}
/**
* 处理 APPLY 操作
*/
private async handleApply (request: RpcRequest): Promise<RpcResponse> {
const path = request.path;
// 如果有 refId 且 path 为空,说明引用对象本身是函数
if (path.length === 0 && request.refId) {
const func = this.objectRefs.get(request.refId);
if (typeof func !== 'function') {
throw new Error('Referenced object is not callable');
}
const args = (request.args ?? []).map(arg =>
deserialize(arg, {
callbackResolver: this.createCallbackResolver(request),
refResolver: (refId) => this.objectRefs.get(refId),
})
);
let result = func(...args);
if (result instanceof Promise) {
result = await result;
}
const { result: serializedResult, isProxyable, refId } = this.serializeResult(result);
return {
id: request.id,
success: true,
result: serializedResult,
isProxyable,
refId,
};
}
if (path.length === 0) {
throw new Error('Cannot call root object');
}
const methodPath = path.slice(0, -1);
const methodName = path[path.length - 1]!;
const { value: parent } = this.resolvePath(methodPath, request.refId);
if (parent === null || parent === undefined) {
throw new Error(`Cannot call method on ${parent}`);
}
const method = (parent as Record<PropertyKey, unknown>)[methodName];
if (typeof method !== 'function') {
throw new Error(`${String(methodName)} is not a function`);
}
const args = (request.args ?? []).map(arg =>
deserialize(arg, {
callbackResolver: this.createCallbackResolver(request),
refResolver: (refId) => this.objectRefs.get(refId),
})
);
let result = method.call(parent, ...args);
// 处理 Promise
if (result instanceof Promise) {
result = await result;
}
const { result: serializedResult, isProxyable, refId } = this.serializeResult(result);
return {
id: request.id,
success: true,
result: serializedResult,
isProxyable,
refId,
};
}
/**
* 处理 CONSTRUCT 操作
*/
private async handleConstruct (request: RpcRequest): Promise<RpcResponse> {
const { value: Constructor } = this.resolvePath(request.path, request.refId);
if (typeof Constructor !== 'function') {
throw new Error('Target is not a constructor');
}
const args = (request.args ?? []).map(arg =>
deserialize(arg, {
callbackResolver: this.createCallbackResolver(request),
refResolver: (refId) => this.objectRefs.get(refId),
})
);
const instance = new (Constructor as new (...args: unknown[]) => unknown)(...args);
const { result, isProxyable, refId } = this.serializeResult(instance);
return {
id: request.id,
success: true,
result,
isProxyable,
refId,
};
}
/**
* 处理 HAS 操作
*/
private handleHas (request: RpcRequest): RpcResponse {
const path = request.path;
if (path.length === 0) {
return {
id: request.id,
success: true,
result: serialize(true),
};
}
const parentPath = path.slice(0, -1);
const key = path[path.length - 1]!;
const { value: parent } = this.resolvePath(parentPath, request.refId);
const has = parent !== null && parent !== undefined && key in (parent as object);
return {
id: request.id,
success: true,
result: serialize(has),
};
}
/**
* 处理 OWNKEYS 操作
*/
private handleOwnKeys (request: RpcRequest): RpcResponse {
const { value } = this.resolvePath(request.path, request.refId);
if (value === null || value === undefined) {
return {
id: request.id,
success: true,
result: serialize([]),
};
}
const keys = Reflect.ownKeys(value as object);
return {
id: request.id,
success: true,
result: serialize(keys.map(k => (typeof k === 'symbol' ? k.description ?? '' : String(k)))),
};
}
/**
* 处理 DELETE 操作
*/
private handleDelete (request: RpcRequest): RpcResponse {
const path = request.path;
if (path.length === 0 && !request.refId) {
throw new Error('Cannot delete root object');
}
const parentPath = path.slice(0, -1);
const key = path[path.length - 1]!;
const { value: parent } = this.resolvePath(parentPath, request.refId);
if (parent === null || parent === undefined) {
throw new Error(`Cannot delete property from ${parent}`);
}
const deleted = delete (parent as Record<PropertyKey, unknown>)[key];
return {
id: request.id,
success: true,
result: serialize(deleted),
};
}
/**
* 处理 GET_DESCRIPTOR 操作
*/
private handleGetDescriptor (request: RpcRequest): RpcResponse {
const path = request.path;
if (path.length === 0) {
return {
id: request.id,
success: true,
result: serialize(undefined),
};
}
const parentPath = path.slice(0, -1);
const key = path[path.length - 1]!;
const { value: parent } = this.resolvePath(parentPath, request.refId);
if (parent === null || parent === undefined) {
return {
id: request.id,
success: true,
result: serialize(undefined),
};
}
const descriptor = Object.getOwnPropertyDescriptor(parent as object, key);
if (!descriptor) {
return {
id: request.id,
success: true,
result: serialize(undefined),
};
}
// 序列化描述符(排除 value 和 get/set 函数)
return {
id: request.id,
success: true,
result: serialize({
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
writable: descriptor.writable,
}),
};
}
/**
* 处理 GET_PROTOTYPE 操作
*/
private handleGetPrototype (request: RpcRequest): RpcResponse {
const { value } = this.resolvePath(request.path, request.refId);
if (value === null || value === undefined) {
return {
id: request.id,
success: true,
result: serialize(null),
};
}
const proto = Object.getPrototypeOf(value);
const name = proto?.constructor?.name ?? 'Object';
return {
id: request.id,
success: true,
result: serialize({ name }),
};
}
/**
* 处理 RELEASE 操作
*/
private handleRelease (request: RpcRequest): RpcResponse {
// 如果有 refId释放该引用
if (request.refId) {
this.objectRefs.delete(request.refId);
}
return {
id: request.id,
success: true,
};
}
/**
* 创建回调解析器
*/
private createCallbackResolver (_request: RpcRequest): (id: string) => Function {
return (callbackId: string) => {
// 创建一个代理函数,调用时会通过 callbackInvoker 发送回客户端
return async (...args: unknown[]) => {
if (!this.callbackInvoker) {
throw new Error('Callback invoker not configured');
}
return this.callbackInvoker(callbackId, args);
};
};
}
/**
* 创建错误响应
*/
private createErrorResponse (requestId: string, error: unknown): RpcResponse {
if (error instanceof Error) {
return {
id: requestId,
success: false,
error: error.message,
stack: error.stack,
};
}
return {
id: requestId,
success: false,
error: String(error),
};
}
/**
* 调用客户端回调
*/
async invokeCallback (callbackId: string, args: unknown[]): Promise<SerializedValue> {
if (!this.callbackInvoker) {
throw new Error('Callback invoker not configured');
}
const result = await this.callbackInvoker(callbackId, args);
return serialize(result, { callbackRegistry: this.localCallbacks });
}
}
/**
* 创建 RPC 服务端
*/
export function createRpcServer (options: RpcServerOptions): RpcServer {
return new RpcServer(options);
}

View File

@@ -0,0 +1,204 @@
import {
type RpcTransport,
type RpcRequest,
type RpcResponse,
type SerializedValue,
} from './types.js';
import { RpcServer } from './server.js';
import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js';
/**
* 本地传输层
*
* 用于在同一进程内进行 RPC 调用,主要用于测试
*/
export class LocalTransport implements RpcTransport {
private server: RpcServer;
private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>;
private clientCallbacks = new SimpleCallbackRegistry();
constructor (target: unknown) {
this.server = new RpcServer({
target,
callbackInvoker: async (callbackId, args) => {
if (!this.callbackHandler) {
throw new Error('Callback handler not registered');
}
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry: this.clientCallbacks }));
const result = await this.callbackHandler(callbackId, serializedArgs);
return deserialize(result);
},
});
}
async send (request: RpcRequest): Promise<RpcResponse> {
// 模拟网络延迟(可选)
// await new Promise(resolve => setTimeout(resolve, 0));
return this.server.handleRequest(request);
}
onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void {
this.callbackHandler = handler;
}
close (): void {
this.clientCallbacks.clear();
}
}
/**
* 消息传输层接口
*/
export interface MessageTransportOptions {
/** 发送消息 */
sendMessage: (message: string) => void | Promise<void>;
/** 接收消息时的回调 */
onMessage: (handler: (message: string) => void) => void;
}
/**
* 基于消息的传输层
*
* 可用于跨进程/网络通信
*/
export class MessageTransport implements RpcTransport {
private pendingRequests = new Map<string, {
resolve: (response: RpcResponse) => void;
reject: (error: Error) => void;
}>();
private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>;
private sendMessage: (message: string) => void | Promise<void>;
constructor (options: MessageTransportOptions) {
this.sendMessage = options.sendMessage;
options.onMessage(async (message) => {
const data = JSON.parse(message) as {
type: 'response' | 'callback' | 'callback_response';
id: string;
response?: RpcResponse;
callbackId?: string;
args?: SerializedValue[];
result?: SerializedValue;
error?: string;
};
if (data.type === 'response') {
const pending = this.pendingRequests.get(data.id);
if (pending && data.response) {
this.pendingRequests.delete(data.id);
pending.resolve(data.response);
}
} else if (data.type === 'callback') {
// 处理来自服务端的回调调用
if (this.callbackHandler && data.callbackId && data.args) {
try {
const result = await this.callbackHandler(data.callbackId, data.args);
await this.sendMessage(JSON.stringify({
type: 'callback_response',
id: data.id,
result,
}));
} catch (error) {
await this.sendMessage(JSON.stringify({
type: 'callback_response',
id: data.id,
error: error instanceof Error ? error.message : String(error),
}));
}
}
}
});
}
async send (request: RpcRequest): Promise<RpcResponse> {
return new Promise((resolve, reject) => {
this.pendingRequests.set(request.id, { resolve, reject });
const message = JSON.stringify({
type: 'request',
request,
});
Promise.resolve(this.sendMessage(message)).catch(reject);
});
}
onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void {
this.callbackHandler = handler;
}
close (): void {
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error('Transport closed'));
}
this.pendingRequests.clear();
}
}
/**
* 创建消息传输层的服务端处理器
*/
export function createMessageServerHandler (target: unknown, options: {
sendMessage: (message: string) => void | Promise<void>;
onMessage: (handler: (message: string) => void) => void;
}): void {
const pendingCallbacks = new Map<string, {
resolve: (result: SerializedValue) => void;
reject: (error: Error) => void;
}>();
let callbackIdCounter = 0;
const server = new RpcServer({
target,
callbackInvoker: async (callbackId, args) => {
const id = `cb_call_${++callbackIdCounter}`;
const serializedArgs = args.map(arg => serialize(arg));
return new Promise<unknown>((resolve, reject) => {
pendingCallbacks.set(id, {
resolve: (result) => resolve(deserialize(result)),
reject,
});
options.sendMessage(JSON.stringify({
type: 'callback',
id,
callbackId,
args: serializedArgs,
}));
});
},
});
options.onMessage(async (message) => {
const data = JSON.parse(message) as {
type: 'request' | 'callback_response';
id: string;
request?: RpcRequest;
result?: SerializedValue;
error?: string;
};
if (data.type === 'request' && data.request) {
const response = await server.handleRequest(data.request);
await options.sendMessage(JSON.stringify({
type: 'response',
id: data.request.id,
response,
}));
} else if (data.type === 'callback_response') {
const pending = pendingCallbacks.get(data.id);
if (pending) {
pendingCallbacks.delete(data.id);
if (data.error) {
pending.reject(new Error(data.error));
} else if (data.result) {
pending.resolve(data.result);
}
}
}
});
}

View File

@@ -0,0 +1,195 @@
/**
* RPC 操作类型
*/
export enum RpcOperationType {
/** 获取属性 */
GET = 'get',
/** 设置属性 */
SET = 'set',
/** 调用方法 */
APPLY = 'apply',
/** 构造函数调用 */
CONSTRUCT = 'construct',
/** 检查属性是否存在 */
HAS = 'has',
/** 获取所有键 */
OWNKEYS = 'ownKeys',
/** 删除属性 */
DELETE = 'deleteProperty',
/** 获取属性描述符 */
GET_DESCRIPTOR = 'getOwnPropertyDescriptor',
/** 获取原型 */
GET_PROTOTYPE = 'getPrototypeOf',
/** 回调调用 */
CALLBACK = 'callback',
/** 释放资源 */
RELEASE = 'release',
}
/**
* RPC 请求消息
*/
export interface RpcRequest {
/** 请求 ID */
id: string;
/** 操作类型 */
type: RpcOperationType;
/** 访问路径 (从根对象开始) */
path: PropertyKey[];
/** 参数 (用于 set, apply, construct) */
args?: SerializedValue[];
/** 回调 ID 映射 (参数索引 -> 回调 ID) */
callbackIds?: Record<number, string>;
/** 远程对象引用 ID用于对引用对象的操作 */
refId?: string;
}
/**
* RPC 响应消息
*/
export interface RpcResponse {
/** 请求 ID */
id: string;
/** 是否成功 */
success: boolean;
/** 返回值 */
result?: SerializedValue;
/** 错误信息 */
error?: string;
/** 错误堆栈 */
stack?: string;
/** 结果是否为可代理对象 */
isProxyable?: boolean;
/** 远程对象引用 ID用于深层对象代理 */
refId?: string;
}
/**
* 序列化后的值
*/
export interface SerializedValue {
/** 值类型 */
type: SerializedValueType;
/** 原始值(用于基本类型) */
value?: unknown;
/** 对象类型名称 */
className?: string;
/** 回调 ID用于函数 */
callbackId?: string;
/** 代理路径(用于可代理对象) */
proxyPath?: PropertyKey[];
/** 数组元素或对象属性 */
properties?: Record<string, SerializedValue>;
/** 数组元素 */
elements?: SerializedValue[];
/** 远程对象引用 ID用于保持代理能力 */
refId?: string;
/** 缓存的属性值OBJECT_REF 时使用,避免属性访问需要 RPC */
cachedProps?: Record<string, SerializedValue>;
}
/**
* 序列化值类型
*/
export enum SerializedValueType {
UNDEFINED = 'undefined',
NULL = 'null',
BOOLEAN = 'boolean',
NUMBER = 'number',
BIGINT = 'bigint',
STRING = 'string',
SYMBOL = 'symbol',
FUNCTION = 'function',
OBJECT = 'object',
ARRAY = 'array',
DATE = 'date',
REGEXP = 'regexp',
ERROR = 'error',
PROMISE = 'promise',
PROXY_REF = 'proxyRef',
BUFFER = 'buffer',
MAP = 'map',
SET = 'set',
/** 远程对象引用 - 保持代理能力 */
OBJECT_REF = 'objectRef',
}
/**
* 对象引用信息
*/
export interface ObjectRef {
/** 引用 ID */
refId: string;
/** 对象类型名称 */
className?: string;
}
/**
* RPC 传输层接口
*/
export interface RpcTransport {
/** 发送请求并等待响应 */
send (request: RpcRequest): Promise<RpcResponse>;
/** 注册回调处理器 */
onCallback?(handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void;
/** 关闭连接 */
close?(): void;
}
/**
* RPC 服务端处理器接口
*/
export interface RpcServerHandler {
/** 处理请求 */
handleRequest (request: RpcRequest): Promise<RpcResponse>;
/** 调用客户端回调 */
invokeCallback?(callbackId: string, args: unknown[]): Promise<unknown>;
}
/**
* 深层代理选项
*/
export interface DeepProxyOptions {
/** 传输层 */
transport: RpcTransport;
/** 根路径 */
rootPath?: PropertyKey[];
/** 是否缓存属性 */
cacheProperties?: boolean;
/** 回调超时时间 (ms) */
callbackTimeout?: number;
/** 远程对象引用 ID用于引用对象的代理 */
refId?: string;
}
/**
* RPC 服务端选项
*/
export interface RpcServerOptions {
/** 目标对象 */
target: unknown;
/** 回调调用器 */
callbackInvoker?: (callbackId: string, args: unknown[]) => Promise<unknown>;
/**
* 判断返回值是否应保持代理引用(而非完全序列化)
* 默认对 class 实例和包含方法的对象返回 true
*/
shouldProxyResult?: (value: unknown) => boolean;
}
/**
* 代理元数据符号
*/
export const PROXY_META = Symbol('PROXY_META');
/**
* 代理元数据
*/
export interface ProxyMeta {
/** 访问路径 */
path: PropertyKey[];
/** 是否为代理 */
isProxy: true;
/** 远程对象引用 ID */
refId?: string;
}

View File

@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": [
"../*/"
]
}
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

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,13 @@ export default defineConfig({
resolve: {
conditions: ['node', 'default'],
alias: {
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@/napcat-common': resolve(__dirname, '../napcat-common'),
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
'@/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'),
'@/napcat-image-size': resolve(__dirname, '../napcat-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';
@@ -220,6 +220,52 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
}
});
});
// 注册密码登录回调
WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => {
return await new Promise((resolve) => {
if (uin && passwordMd5) {
logger.log('正在密码登录 ', uin);
loginService.passwordLogin({
uin,
passwordMd5,
step: 0,
newDeviceLoginSig: '',
proofWaterSig: '',
proofWaterRand: '',
proofWaterSid: '',
}).then(res => {
if (res.result === '140022008') {
const errMsg = '需要验证码,暂不支持';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else if (res.result === '140022010') {
const errMsg = '新设备需要扫码登录,暂不支持';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
}
}).catch((e) => {
logger.logError(e);
WebUiDataRuntime.setQQLoginError('密码登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '密码登录发生错误' });
});
} else {
resolve({ result: false, message: '密码登录失败:参数不完整' });
}
});
});
if (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin);
@@ -475,10 +521,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

@@ -7,11 +7,33 @@ import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
// ES 模块中获取 __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const pathWrapper = new NapCatPathWrapper();
const envPath = path.join(__dirname, 'config', '.env');
if (fs.existsSync(envPath)) {
try {
const data = fs.readFileSync(envPath, 'utf8');
let loadedCount = 0;
data.split(/\r?\n/).forEach(line => {
line = line.trim();
if (line && !line.startsWith('#')) {
const parts = line.split('=');
const key = parts[0]?.trim();
const value = parts.slice(1).join('=').trim();
if (key && value) {
process.env[key] = value;
loadedCount++;
}
}
});
} catch (e) {
console.log('[NapCat] Failed to load .env file:', e);
}
}
// 环境变量配置
const ENV = {
@@ -20,6 +42,7 @@ const ENV = {
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
} as const;
// Worker 消息类型
interface WorkerMessage {
type: 'restart' | 'restart-prepare' | 'shutdown';
@@ -27,8 +50,7 @@ interface WorkerMessage {
port?: number;
}
// 初始化日志
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);
// 进程管理器和当前 Worker 进程引用
@@ -38,6 +60,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 +244,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

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

View File

@@ -0,0 +1,346 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import {
detectImageTypeFromBuffer,
imageSizeFromBuffer,
imageSizeFromBufferFallBack,
imageSizeFromFile,
matchMagic,
ImageType,
} from '@/napcat-image-size/src';
// resource 目录路径
const resourceDir = path.resolve(__dirname, '../napcat-image-size/resource');
// 测试用的 Buffer 数据
const testBuffers = {
// PNG 测试图片 (100x200)
png: Buffer.from([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8,
0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]),
// JPEG 测试图片 (320x240)
jpeg: Buffer.from([
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10,
0x4A, 0x46, 0x49, 0x46, 0x00,
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
0xFF, 0xC0, 0x00, 0x0B, 0x08,
0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00,
]),
// BMP 测试图片 (640x480)
bmp: (() => {
const buf = Buffer.alloc(54);
buf.write('BM', 0);
buf.writeUInt32LE(54, 2);
buf.writeUInt32LE(0, 6);
buf.writeUInt32LE(54, 10);
buf.writeUInt32LE(40, 14);
buf.writeUInt32LE(640, 18);
buf.writeUInt32LE(480, 22);
buf.writeUInt16LE(1, 26);
buf.writeUInt16LE(24, 28);
return buf;
})(),
// GIF87a 测试图片 (800x600)
gif87a: Buffer.from([
0x47, 0x49, 0x46, 0x38, 0x37, 0x61,
0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00,
]),
// GIF89a 测试图片 (1024x768)
gif89a: Buffer.from([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00,
]),
// WebP VP8 测试图片 (1920x1080)
webpVP8: (() => {
const buf = Buffer.alloc(32);
buf.write('RIFF', 0);
buf.writeUInt32LE(24, 4);
buf.write('WEBP', 8);
buf.write('VP8 ', 12);
buf.writeUInt32LE(14, 16);
buf.writeUInt8(0x9D, 20);
buf.writeUInt8(0x01, 21);
buf.writeUInt8(0x2A, 22);
buf.writeUInt16LE(1920 & 0x3FFF, 26);
buf.writeUInt16LE(1080 & 0x3FFF, 28);
return buf;
})(),
// WebP VP8L 测试图片 (256x128)
webpVP8L: (() => {
const buf = Buffer.alloc(32);
buf.write('RIFF', 0);
buf.writeUInt32LE(24, 4);
buf.write('WEBP', 8);
buf.write('VP8L', 12);
buf.writeUInt32LE(10, 16);
buf.writeUInt8(0x2F, 20);
const vp8lBits = (256 - 1) | ((128 - 1) << 14);
buf.writeUInt32LE(vp8lBits, 21);
return buf;
})(),
// WebP VP8X 测试图片 (512x384)
webpVP8X: (() => {
const buf = Buffer.alloc(32);
buf.write('RIFF', 0);
buf.writeUInt32LE(24, 4);
buf.write('WEBP', 8);
buf.write('VP8X', 12);
buf.writeUInt32LE(10, 16);
buf.writeUInt8((512 - 1) & 0xFF, 24);
buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25);
buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26);
buf.writeUInt8((384 - 1) & 0xFF, 27);
buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28);
buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29);
return buf;
})(),
// TIFF Little Endian 测试图片
tiffLE: Buffer.from([
0x49, 0x49, 0x2A, 0x00, // II + magic
0x08, 0x00, 0x00, 0x00, // IFD offset = 8
0x02, 0x00, // 2 entries
// Entry 1: ImageWidth = 100
0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
// Entry 2: ImageHeight = 200
0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00,
]),
// TIFF Big Endian 测试图片
tiffBE: Buffer.from([
0x4D, 0x4D, 0x00, 0x2A, // MM + magic
0x00, 0x00, 0x00, 0x08, // IFD offset = 8
0x00, 0x02, // 2 entries
// Entry 1: ImageWidth = 100
0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00,
// Entry 2: ImageHeight = 200
0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00,
]),
invalid: Buffer.from('This is not an image file'),
empty: Buffer.alloc(0),
};
describe('napcat-image-size', () => {
describe('matchMagic', () => {
it('should match magic bytes at the beginning', () => {
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true);
});
it('should match magic bytes at offset', () => {
const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]);
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true);
});
it('should return false for non-matching magic', () => {
const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
});
it('should return false for buffer too short', () => {
const buffer = Buffer.from([0x89, 0x50]);
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
});
it('should return false for offset beyond buffer', () => {
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false);
});
});
describe('detectImageTypeFromBuffer', () => {
it('should detect PNG image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG);
});
it('should detect JPEG image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG);
});
it('should detect BMP image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP);
});
it('should detect GIF87a image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF);
});
it('should detect GIF89a image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF);
});
it('should detect WebP VP8 image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP);
});
it('should detect WebP VP8L image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP);
});
it('should detect WebP VP8X image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP);
});
it('should detect TIFF Little Endian image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF);
});
it('should detect TIFF Big Endian image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF);
});
it('should return UNKNOWN for invalid data', () => {
expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN);
});
it('should return UNKNOWN for empty buffer', () => {
expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN);
});
});
describe('imageSizeFromBuffer', () => {
it('should parse PNG image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 });
});
it('should parse JPEG image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 });
});
it('should parse BMP image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 });
});
it('should parse GIF87a image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 });
});
it('should parse GIF89a image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 });
});
it('should parse WebP VP8 image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 });
});
it('should parse WebP VP8L image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 });
});
it('should parse WebP VP8X image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 });
});
it('should parse TIFF Little Endian image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 });
});
it('should parse TIFF Big Endian image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 });
});
it('should return undefined for invalid data', async () => {
expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined();
});
it('should return undefined for empty buffer', async () => {
expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined();
});
});
describe('imageSizeFromBufferFallBack', () => {
it('should return actual size for valid image', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 });
});
it('should return default fallback for invalid data', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 });
});
it('should return custom fallback for invalid data', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 });
});
it('should return default fallback for empty buffer', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 });
});
it('should return custom fallback for empty buffer', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 });
});
});
describe('ImageType enum', () => {
it('should have correct enum values', () => {
expect(ImageType.JPEG).toBe('jpeg');
expect(ImageType.PNG).toBe('png');
expect(ImageType.BMP).toBe('bmp');
expect(ImageType.GIF).toBe('gif');
expect(ImageType.WEBP).toBe('webp');
expect(ImageType.TIFF).toBe('tiff');
expect(ImageType.UNKNOWN).toBe('unknown');
});
});
describe('Real image files from resource directory', () => {
it('should detect and parse test-20x20.jpg', async () => {
const filePath = path.join(resourceDir, 'test-20x20.jpg');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 20, height: 20 });
});
it('should detect and parse test-20x20.png', async () => {
const filePath = path.join(resourceDir, 'test-20x20.png');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 20, height: 20 });
});
it('should detect and parse test-20x20.tiff', async () => {
const filePath = path.join(resourceDir, 'test-20x20.tiff');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 20, height: 20 });
});
it('should detect and parse test-20x20.webp', async () => {
const filePath = path.join(resourceDir, 'test-20x20.webp');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 20, height: 20 });
});
it('should detect and parse test-490x498.gif', async () => {
const filePath = path.join(resourceDir, 'test-490x498.gif');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 490, height: 498 });
});
it('should parse real images using imageSizeFromFile', async () => {
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 });
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 });
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 });
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 });
expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 });
});
});
});

View File

@@ -11,6 +11,8 @@
"vitest": "^4.0.9"
},
"dependencies": {
"napcat-core": "workspace:*"
"napcat-core": "workspace:*",
"napcat-rpc": "workspace:*",
"napcat-image-size": "workspace:*"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,11 @@ export default defineConfig({
},
resolve: {
alias: {
'@': resolve(__dirname, '../../'),
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
'@/napcat-test': resolve(__dirname, '.'),
'@/napcat-common': resolve(__dirname, '../napcat-common'),
'@/napcat-core': resolve(__dirname, '../napcat-core'),
},
},
});

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.9",
"version": "0.0.16",
"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

@@ -27,6 +27,8 @@ import compression from 'compression';
import { napCatVersion } from 'napcat-common/src/version';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -123,9 +125,14 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
return;
}
// 检查并更新默认密码仅在启用WebUI时
if (config.token === 'napcat' || !config.token) {
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
// 优先使用环境变量覆盖 Token
if (process.env['NAPCAT_WEBUI_SECRET_KEY'] && config.token !== process.env['NAPCAT_WEBUI_SECRET_KEY']) {
await WebUiConfig.UpdateWebUIConfig({ token: process.env['NAPCAT_WEBUI_SECRET_KEY'] });
logger.log(`[NapCat] [WebUi] 检测到环境变量配置,已更新 WebUI Token 为 ${process.env['NAPCAT_WEBUI_SECRET_KEY']}`);
config = await WebUiConfig.GetWebUIConfig();
} else if (config.token === 'napcat' || !config.token) {
// 只有没设置环境变量,且是默认密码时,才生成随机密码
const randomToken = getRandomToken(8);
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
@@ -226,10 +233,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// 添加字体变量
if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
} else {
css += '--font-family-base: var(--font-family-fallbacks) !important;';
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
}
css += '}';
@@ -240,10 +250,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// 添加字体变量
if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
} else {
css += '--font-family-base: var(--font-family-fallbacks) !important;';
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
}
css += '}';
@@ -283,6 +296,130 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
app.use('/webui', express.static(pathWrapper.staticPath, {
maxAge: '1d',
}));
// 插件内存静态资源路由(不需要鉴权)
// 路径格式: /plugin/:pluginId/mem/:urlPath/*
app.use('/plugin/:pluginId/mem', async (req, res) => {
const { pluginId } = req.params;
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
const routerRegistry = pluginManager.getPluginRouter(pluginId);
const memoryRoutes = routerRegistry?.getMemoryStaticRoutes() || [];
for (const { urlPath, files } of memoryRoutes) {
const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath;
if (req.path.startsWith(prefix)) {
const filePath = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || '');
const memFile = files.find(f => ('/' + f.path.replace(/^\//, '')) === filePath);
if (memFile) {
try {
const content = typeof memFile.content === 'function' ? await memFile.content() : memFile.content;
res.setHeader('Content-Type', memFile.contentType || 'application/octet-stream');
return res.send(content);
} catch (err) {
console.error(`[Plugin: ${pluginId}] Error serving memory file:`, err);
return res.status(500).json({ code: -1, message: 'Error serving memory file' });
}
}
}
}
return res.status(404).json({ code: -1, message: 'Memory file not found' });
});
// 插件无认证 API 路由(不需要鉴权)
// 路径格式: /plugin/:pluginId/api/*
app.use('/plugin/:pluginId/api', (req, res, next) => {
const { pluginId } = req.params;
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
const routerRegistry = pluginManager.getPluginRouter(pluginId);
if (!routerRegistry || !routerRegistry.hasApiNoAuthRoutes()) {
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered no-auth API routes` });
}
// 构建并执行插件无认证 API 路由
const pluginRouter = routerRegistry.buildApiNoAuthRouter();
return pluginRouter(req, res, next);
});
// 插件页面路由(不需要鉴权)
// 路径格式: /plugin/:pluginId/page/:pagePath
app.get('/plugin/:pluginId/page/:pagePath', (req, res) => {
const { pluginId, pagePath } = req.params;
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
const routerRegistry = pluginManager.getPluginRouter(pluginId);
if (!routerRegistry || !routerRegistry.hasPages()) {
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` });
}
const pages = routerRegistry.getPages();
const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath);
if (!page) {
return res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` });
}
const pluginPath = routerRegistry.getPluginPath();
if (!pluginPath) {
return res.status(500).json({ code: -1, message: 'Plugin path not available' });
}
const htmlFilePath = join(pluginPath, page.htmlFile);
if (!existsSync(htmlFilePath)) {
return res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` });
}
return res.sendFile(htmlFilePath);
});
// 插件文件系统静态资源路由(不需要鉴权)
// 路径格式: /plugin/:pluginId/files/*
app.use('/plugin/:pluginId/files', (req, res, next) => {
const { pluginId } = req.params;
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
const routerRegistry = pluginManager.getPluginRouter(pluginId);
const staticRoutes = routerRegistry?.getStaticRoutes() || [];
for (const { urlPath, localPath } of staticRoutes) {
const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath;
if (req.path.startsWith(prefix) || req.path === prefix.slice(0, -1)) {
const staticMiddleware = express.static(localPath, { maxAge: '1d' });
const originalUrl = req.url;
req.url = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || '');
return staticMiddleware(req, res, (err) => {
req.url = originalUrl;
err ? next(err) : next();
});
}
}
res.status(404).json({ code: -1, message: 'Static resource not found' });
});
// 初始化WebSocket服务器
const sslCerts = await checkCertificates(logger);
const isHttps = !!sslCerts;

View File

@@ -0,0 +1,151 @@
import { RequestHandler } from 'express';
import { existsSync, readdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join, normalize } from 'node:path';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import compressing from 'compressing';
import { Readable } from 'node:stream';
// 使用compressing库进行流式压缩导出
export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (!isLogin) {
return sendError(res, 'Not Login');
}
try {
const configPath = webUiPathWrapper.configPath;
if (!existsSync(configPath)) {
return sendError(res, '配置目录不存在');
}
const formatDate = (date: Date) => {
return date.toISOString().replace(/[:.]/g, '-');
};
const zipFileName = `config_backup_${formatDate(new Date())}.zip`;
// 设置响应头
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
// 使用compressing的Stream API进行流式压缩
const stream = new compressing.zip.Stream();
// 添加目录下的所有文件到压缩流(单层平坦结构)
const entries = readdirSync(configPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile()) {
const entryPath = join(configPath, entry.name);
stream.addEntry(entryPath, { relativePath: entry.name });
}
}
// 管道传输到响应
stream.pipe(res);
// 处理流错误
stream.on('error', (err) => {
console.error('压缩流错误:', err);
if (!res.headersSent) {
sendError(res, '流式压缩失败');
}
});
} catch (error) {
const msg = (error as Error).message;
console.error('导出配置失败:', error);
if (!res.headersSent) {
return sendError(res, `导出配置失败: ${msg}`);
}
}
};
// 从内存Buffer流式解压返回文件名和内容的映射
async function extractZipToMemory (buffer: Buffer): Promise<Map<string, Buffer>> {
return new Promise((resolve, reject) => {
const files = new Map<string, Buffer>();
const bufferStream = Readable.from(buffer);
const uncompressStream = new compressing.zip.UncompressStream();
uncompressStream.on('entry', (header, stream, next) => {
// 只处理文件,忽略目录
if (header.type === 'file') {
const chunks: Buffer[] = [];
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
stream.on('end', () => {
// 取文件名(忽略路径中的目录部分)
const fileName = header.name.split('/').pop() || header.name;
files.set(fileName, Buffer.concat(chunks));
next();
});
stream.on('error', (err) => {
console.error(`读取文件失败: ${header.name}`, err);
next();
});
} else {
stream.resume();
next();
}
});
uncompressStream.on('finish', () => resolve(files));
uncompressStream.on('error', reject);
bufferStream.pipe(uncompressStream);
});
}
// 导入配置 - 流式处理,完全在内存中解压
export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
// 检查是否有文件上传multer memoryStorage 模式下文件在 req.file.buffer
if (!req.file || !req.file.buffer) {
return sendError(res, '请选择要导入的配置文件');
}
try {
const configPath = webUiPathWrapper.configPath;
// 从内存中解压zip
const extractedFiles = await extractZipToMemory(req.file.buffer);
if (extractedFiles.size === 0) {
return sendError(res, '配置文件为空或格式不正确');
}
// 备份当前配置到内存
const backupFiles = new Map<string, Buffer>();
if (existsSync(configPath)) {
const currentFiles = readdirSync(configPath, { withFileTypes: true });
for (const entry of currentFiles) {
if (entry.isFile()) {
const filePath = join(configPath, entry.name);
backupFiles.set(entry.name, readFileSync(filePath));
}
}
}
// 写入新的配置文件
for (const [fileName, content] of extractedFiles) {
// 防止路径穿越攻击
const destPath = join(configPath, fileName);
const normalizedPath = normalize(destPath);
if (!normalizedPath.startsWith(normalize(configPath))) {
continue;
}
writeFileSync(destPath, content);
}
return sendSuccess(res, {
message: '配置导入成功,重启后生效~',
filesImported: extractedFiles.size,
filesBackedUp: backupFiles.size
});
} catch (error) {
console.error('导入配置失败:', error);
const msg = (error as Error).message;
return sendError(res, `导入配置失败: ${msg}`);
}
};

View File

@@ -653,3 +653,13 @@ export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
return sendError(res, '删除字体文件失败');
}
};
// 检查WebUI字体文件是否存在
export const CheckWebUIFontExistHandler: RequestHandler = async (_req, res) => {
try {
const exists = await WebUiConfig.CheckWebUIFontExist();
return sendSuccess(res, exists);
} catch (_error) {
return sendError(res, '检查字体文件失败');
}
};

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

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