Compare commits

...

45 Commits

Author SHA1 Message Date
手瓜一十雪
38f6dad118 Lower NTQQ build check from 41679 to 39038
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Update NTQQFriendApi to use a lower minimum NTQQ build threshold (39038) when deciding which buddyListV2 API path to call. The change replaces occurrences of '41679' with '39038' in packages/napcat-core/apis/friend.ts (affecting getBuddyV2SimpleInfoMap and the buddyListV2 retrieval logic) to broaden compatibility with older NTQQ builds.
2026-02-03 22:56:12 +08:00
手瓜一十雪
c3b29f1ee6 Improve extension tab UI and add /plugin proxy
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Update dashboard extension UI to prevent title overflow: add truncate and max-width to the tab title, include the plugin name in the title tooltip, and hide the plugin-name label on small screens (visible from md+). Also add a '/plugin' proxy entry to the Vite dev server config so plugin requests are forwarded to the backend debug URL.
2026-02-03 18:58:40 +08:00
手瓜一十雪
3e85d18ab5 refactor: serviceworker
重构sw.js,实现更智能的缓存,根据路由设计缓存
2026-02-03 18:09:12 +08:00
手瓜一十雪
df48c01ce4 Add externalVersion and refine API types
Add externalVersion boolean to LoginInitConfig and pass false at initialization; bump appid qua for 9.9.27 entry. Refine several IPC/wrapper typings to use Promise return types, adjust pathIsReadableAndWriteable signature to accept a type and return Promise<number>, and update NodeIKernelNodeMiscService method signatures (styling) while adding getQimei36WithNewSdk(). These changes improve type accuracy for async/native calls and add a new SDK helper.
2026-02-03 15:59:31 +08:00
手瓜一十雪
209776a9e8 Cache QQ installer and handle win64 files
Update GitHub Actions release workflow to use a cached QQ x64 installer (updated NT_URL), add cache key generation and actions/cache usage, and implement a download-and-extract step that reuses the cache. Adjust temporary directory usage (WORK_TMPDIR/NODE_EXTRACT), add LightQuic.dll to copy targets, and copy specific win64 files into an OUT_DIR/win64 folder.

Update packages/napcat-develop/loadNapCat.cjs to recognize and copy win64 artifacts: add TARGET_WIN64_DIR, include LightQuic.dll, define win64ItemsToCopy (SSOShareInfoHelper64.dll, parent-ipc-core-x64.dll), validate their presence, ensure target directories, and copy win64 files into dist/win64. These changes speed CI by caching the installer and ensure required win64 DLLs are packaged alongside the main distribution.
2026-02-03 15:28:13 +08:00
Qiao
09dae7269a style(webui): 优化插件商店样式,使用固定头部 (#1585)
* fix: 修复 qq_login.tsx 类型错误

- onSelectionChange 的 key 参数可能为 null,添加空值检查

* style(webui): refactor plugin store layout with sticky header
2026-02-03 14:23:13 +08:00
Qiao
2dcf8004ab fix: 插件更新时保留 data 文件夹 (#1584)
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
* fix: 修复 qq_login.tsx 类型错误

- onSelectionChange 的 key 参数可能为 null,添加空值检查

* fix: 插件更新时保留 data 文件夹

- 更新插件时备份 data 文件夹,解压后恢复

- 添加异常处理,确保解压失败时也能恢复 data 文件夹
2026-02-03 09:55:21 +08:00
手瓜一十雪
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
98 changed files with 6995 additions and 949 deletions

View File

@@ -125,18 +125,54 @@ jobs:
cd "$TMPDIR" cd "$TMPDIR"
# ----------------------------- # -----------------------------
# 1) 下载 QQ x64 # 1) 下载 QQ x64 (使用缓存)
# ----------------------------- # -----------------------------
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js" # JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js" # JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+') # NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe" NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
QQ_ZIP="$(basename "$NT_URL")" QQ_ZIP="$(basename "$NT_URL")"
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL" # 根据 URL 生成缓存键
QQ_CACHE_KEY="qq-x64-$(echo "$NT_URL" | md5sum | cut -d' ' -f1)"
echo "QQ_CACHE_KEY=$QQ_CACHE_KEY" >> $GITHUB_ENV
echo "QQ_ZIP=$QQ_ZIP" >> $GITHUB_ENV
echo "NT_URL=$NT_URL" >> $GITHUB_ENV
- name: Cache QQ x64 Installer
id: cache-qq
uses: actions/cache@v4
with:
path: ~/qq-cache
key: ${{ env.QQ_CACHE_KEY }}
- name: Download and Extract QQ x64
run: |
set -euo pipefail
TMPDIR=$(mktemp -d)
cd "$TMPDIR"
QQ_CACHE_DIR="$HOME/qq-cache"
mkdir -p "$QQ_CACHE_DIR"
if [ -f "$QQ_CACHE_DIR/$QQ_ZIP" ]; then
echo "Using cached QQ installer: $QQ_ZIP"
cp "$QQ_CACHE_DIR/$QQ_ZIP" "$QQ_ZIP"
else
echo "Downloading QQ installer: $QQ_ZIP"
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
cp "$QQ_ZIP" "$QQ_CACHE_DIR/$QQ_ZIP"
fi
QQ_EXTRACT="$TMPDIR/qq_extracted" QQ_EXTRACT="$TMPDIR/qq_extracted"
mkdir -p "$QQ_EXTRACT" mkdir -p "$QQ_EXTRACT"
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null 7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
echo "QQ_EXTRACT=$QQ_EXTRACT" >> $GITHUB_ENV
echo "WORK_TMPDIR=$TMPDIR" >> $GITHUB_ENV
- name: Download Node.js and Assemble NapCat.Shell.Windows.Node.zip
run: |
set -euo pipefail
cd "$WORK_TMPDIR"
# ----------------------------- # -----------------------------
# 2) 下载 Node.js Windows x64 zip 22.11.0 # 2) 下载 Node.js Windows x64 zip 22.11.0
@@ -146,7 +182,7 @@ jobs:
NODE_ZIP="node-v$NODE_VER-win-x64.zip" NODE_ZIP="node-v$NODE_VER-win-x64.zip"
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL" aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
NODE_EXTRACT="$TMPDIR/node_extracted" NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
mkdir -p "$NODE_EXTRACT" mkdir -p "$NODE_EXTRACT"
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT" unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
@@ -164,11 +200,18 @@ jobs:
# ----------------------------- # -----------------------------
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node # 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
# ----------------------------- # -----------------------------
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node") QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node" "LightQuic.dll")
for name in "${QQ_TARGETS[@]}"; do for name in "${QQ_TARGETS[@]}"; do
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
done done
# -----------------------------
# 5.1) 拷贝 win64 目录下的文件
# -----------------------------
mkdir -p "$OUT_DIR/win64"
find "$QQ_EXTRACT" -ipath "*/win64/SSOShareInfoHelper64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
find "$QQ_EXTRACT" -ipath "*/win64/parent-ipc-core-x64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
# ----------------------------- # -----------------------------
# 6) 拷贝仓库文件 napcat.bat 和 index.js # 6) 拷贝仓库文件 napcat.bat 和 index.js
# ----------------------------- # -----------------------------
@@ -178,6 +221,7 @@ jobs:
# ----------------------------- # -----------------------------
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node # 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
# ----------------------------- # -----------------------------
NODE_VER="22.11.0"
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
- name: Upload Artifact - name: Upload Artifact

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ bun.lockb
tests/run/ tests/run/
guild1.db-wal guild1.db-wal
guild1.db-shm guild1.db-shm
packages/napcat-develop/config/.env

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"build:shell": "pnpm --filter napcat-shell run build || exit 1", "build:shell": "pnpm --filter napcat-shell run build || exit 1",
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || 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:framework": "pnpm --filter napcat-framework run build || exit 1",
"build:webui": "pnpm --filter napcat-webui-frontend 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", "build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",

View File

@@ -18,7 +18,7 @@ export class NTQQFriendApi {
async getBuddyV2SimpleInfoMap () { async getBuddyV2SimpleInfoMap () {
const buddyService = this.context.session.getBuddyService(); const buddyService = this.context.session.getBuddyService();
let uids: string[] = []; let uids: string[] = [];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) { if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL); const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
uids = buddyListV2NT.data.flatMap(item => item.buddyUids); uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
} else { } else {
@@ -55,7 +55,7 @@ export class NTQQFriendApi {
const buddyService = this.context.session.getBuddyService(); const buddyService = this.context.session.getBuddyService();
let uids: string[] = []; let uids: string[] = [];
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data']; let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) { if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data; buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
uids = buddyListV2.flatMap(item => item.buddyUids); uids = buddyListV2.flatMap(item => item.buddyUids);
} else { } else {

View File

@@ -518,5 +518,13 @@
"9.9.26-44725": { "9.9.26-44725": {
"appid": 537337569, "appid": 537337569,
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B" "qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
},
"9.9.27-45627": {
"appid": 537340060,
"qua": "V1_WIN_NQ_9.9.27_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": { "9.9.26-44725-x64": {
"send": "0A18D0C", "send": "0A18D0C",
"recv": "1D4BF0D" "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": { "9.9.26-44725-x64": {
"send": "2CEBB20", "send": "2CEBB20",
"recv": "2CEF0A0" "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'; import { PacketMsg } from '@/napcat-core/packet/message/message';
interface ForwardMsgJson { interface ForwardMsgJson {
app: string app: string;
config: ForwardMsgJsonConfig, config: ForwardMsgJsonConfig,
desc: string, desc: string,
extra: ForwardMsgJsonExtra, extra: ForwardMsgJsonExtra,
meta: ForwardMsgJsonMeta, meta: ForwardMsgJsonMeta,
prompt: string, prompt: string,
ver: string, ver: string,
view: string view: string;
} }
interface ForwardMsgJsonConfig { interface ForwardMsgJsonConfig {
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
forward: number, forward: number,
round: number, round: number,
type: string, type: string,
width: number width: number;
} }
interface ForwardMsgJsonExtra { interface ForwardMsgJsonExtra {
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
} }
interface ForwardMsgJsonMeta { interface ForwardMsgJsonMeta {
detail: ForwardMsgJsonMetaDetail detail: ForwardMsgJsonMetaDetail;
} }
interface ForwardMsgJsonMetaDetail { interface ForwardMsgJsonMetaDetail {
news: { news: {
text: string text: string;
}[], }[],
resid: string, resid: string,
source: string, source: string,
summary: string, summary: string,
uniseq: string uniseq: string;
} }
interface ForwardAdaptMsg { interface ForwardAdaptMsg {
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
} }
export class ForwardMsgBuilder { export class ForwardMsgBuilder {
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson { private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
const id = crypto.randomUUID(); const id = uuid ?? crypto.randomUUID();
const isGroupMsg = msg.some(m => m.isGroupMsg); const isGroupMsg = msg.some(m => m.isGroupMsg);
if (!source) { if (!source) {
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录'); 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, []); 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 => ({ return this.build(resId, packetMsg.map(msg => ({
senderName: msg.senderName, senderName: msg.senderName,
isGroupMsg: msg.groupId !== undefined, isGroupMsg: msg.groupId !== undefined,
msg: msg.msg.map(m => ({ msg: msg.msg.map(m => ({
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]', preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
})), })),
})), source, news, summary, prompt); })),
source,
news,
summary,
prompt,
uuid,
);
} }
} }

View File

@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult'; import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib'; import { gunzipSync } from 'zlib';
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter'; import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
export class PacketOperationContext { export class PacketOperationContext {
private readonly context: PacketContext; private readonly context: PacketContext;
@@ -26,7 +27,7 @@ export class PacketOperationContext {
this.context = context; 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); return await this.context.client.sendOidbPacket(pkt, rsp);
} }
@@ -94,12 +95,15 @@ export class PacketOperationContext {
.filter(Boolean) .filter(Boolean)
); );
const res = await Promise.allSettled(reqList); const res = await Promise.allSettled(reqList);
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}`); const failedCount = res.filter((r) => r.status === 'rejected').length;
res.forEach((result, index) => { if (failedCount > 0) {
if (result.status === 'rejected') { this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}`);
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`); res.forEach((result, index) => {
} if (result.status === 'rejected') {
}); this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
}
});
}
} }
async UploadImage (img: PacketMsgPicElement) { async UploadImage (img: PacketMsgPicElement) {
@@ -224,7 +228,15 @@ export class PacketOperationContext {
const res = trans.UploadForwardMsg.parse(resp); const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId; 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 ( async MoveGroupFile (
groupUin: number, groupUin: number,
fileUUID: string, 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 FetchGroupMessage } from './FetchGroupMessage';
export { default as FetchC2CMessage } from './FetchC2CMessage'; export { default as FetchC2CMessage } from './FetchC2CMessage';
export { default as DownloadForwardMsg } from './DownloadForwardMsg'; export { default as DownloadForwardMsg } from './DownloadForwardMsg';
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';

View File

@@ -8,6 +8,7 @@ export interface LoginInitConfig {
commonPath: string; commonPath: string;
clientVer: string; clientVer: string;
hostName: string; hostName: string;
externalVersion: boolean;
} }
export interface PasswordLoginRetType { export interface PasswordLoginRetType {
@@ -21,7 +22,7 @@ export interface PasswordLoginRetType {
jumpWord: string; jumpWord: string;
tipsTitle: string; tipsTitle: string;
tipsContent: string; tipsContent: string;
} };
} }
export interface PasswordLoginArgType { export interface PasswordLoginArgType {
@@ -55,37 +56,37 @@ export interface QuickLoginResult {
jumpUrl: string, jumpUrl: string,
jumpWord: string, jumpWord: string,
tipsTitle: string, tipsTitle: string,
tipsContent: string tipsContent: string;
}; };
} }
export interface NodeIKernelLoginService { export interface NodeIKernelLoginService {
getMsfStatus: () => number; 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 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

@@ -1,15 +1,17 @@
import { GeneralCallResult } from './common'; import { GeneralCallResult } from './common';
export interface NodeIKernelNodeMiscService { export interface NodeIKernelNodeMiscService {
writeVersionToRegistry(version: string): void; writeVersionToRegistry (version: string): void;
getMiniAppPath(): unknown; getMiniAppPath (): unknown;
setMiniAppVersion(version: string): unknown; setMiniAppVersion (version: string): unknown;
wantWinScreenOCR(imagepath: string): Promise<GeneralCallResult>; wantWinScreenOCR (imagepath: string): Promise<GeneralCallResult>;
SendMiniAppMsg(arg1: string, arg2: string, arg3: string): unknown; SendMiniAppMsg (arg1: string, arg2: string, arg3: string): unknown;
startNewMiniApp(appfile: string, params: string): unknown; startNewMiniApp (appfile: string, params: string): unknown;
getQimei36WithNewSdk (): Promise<string>;
} }

View File

@@ -87,9 +87,9 @@ export interface NodeQQNTWrapperUtil {
fullWordToHalfWord (word: string): unknown; fullWordToHalfWord (word: string): unknown;
getNTUserDataInfoConfig (): unknown; getNTUserDataInfoConfig (): Promise<string>;
pathIsReadableAndWriteable (path: string): unknown; // 直接的猜测 pathIsReadableAndWriteable (path: string, type: number): Promise<number>; // type 2 , result 0 成功
resetUserDataSavePathToDocument (): unknown; resetUserDataSavePathToDocument (): unknown;
@@ -158,7 +158,7 @@ export interface NodeIQQNTStartupSessionWrapper {
stop (): void; stop (): void;
start (): void; start (): void;
createWithModuleList (uk: unknown): unknown; createWithModuleList (uk: unknown): unknown;
getSessionIdList (): unknown; getSessionIdList (): Promise<Map<unknown, unknown>>;
} }
export interface NodeIQQNTWrapperSession { export interface NodeIQQNTWrapperSession {
getNTWrapperSession (str: string): NodeIQQNTWrapperSession; getNTWrapperSession (str: string): NodeIQQNTWrapperSession;

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

@@ -32,6 +32,7 @@ if (versionFolders.length === 0) {
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app'); const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
console.log(`BASE_DIR: ${BASE_DIR}`); console.log(`BASE_DIR: ${BASE_DIR}`);
const TARGET_DIR = path.join(__dirname, 'dist'); const TARGET_DIR = path.join(__dirname, 'dist');
const TARGET_WIN64_DIR = path.join(__dirname, 'dist', 'win64');
const QQNT_FILE = path.join(__dirname, 'QQNT.dll'); const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs'); const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
@@ -46,6 +47,12 @@ const itemsToCopy = [
'package.json', 'package.json',
'QBar.dll', 'QBar.dll',
'wrapper.node', 'wrapper.node',
'LightQuic.dll'
];
const win64ItemsToCopy = [
'SSOShareInfoHelper64.dll',
'parent-ipc-core-x64.dll'
]; ];
async function copyAll () { async function copyAll () {
@@ -53,13 +60,23 @@ async function copyAll () {
const configPath = path.join(TARGET_DIR, 'config.json'); const configPath = path.join(TARGET_DIR, 'config.json');
const allItemsExist = await fs.pathExists(qqntDllPath) && const allItemsExist = await fs.pathExists(qqntDllPath) &&
await fs.pathExists(configPath) && await fs.pathExists(configPath) &&
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists); (await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists) &&
(await Promise.all(win64ItemsToCopy.map(item => fs.pathExists(path.join(TARGET_WIN64_DIR, item))))).every(exists => exists);
if (!allItemsExist) { if (!allItemsExist) {
console.log('Copying required files...'); console.log('Copying required files...');
await fs.ensureDir(TARGET_DIR); await fs.ensureDir(TARGET_DIR);
await fs.ensureDir(TARGET_WIN64_DIR);
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true }); await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true }); await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
// 复制 win64 目录下的文件
await Promise.all(win64ItemsToCopy.map(async (item) => {
await fs.copy(path.join(BASE_DIR, 'win64', item), path.join(TARGET_WIN64_DIR, item), { overwrite: true });
console.log(`Copied ${item} to win64`);
}));
// 复制根目录下的文件
await Promise.all(itemsToCopy.map(async (item) => { await Promise.all(itemsToCopy.map(async (item) => {
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true }); await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
console.log(`Copied ${item}`); console.log(`Copied ${item}`);
@@ -78,7 +95,7 @@ async function copyAll () {
process.env.NAPCAT_WORKDIR = TARGET_DIR; process.env.NAPCAT_WORKDIR = TARGET_DIR;
// 开发环境使用固定密钥 // 开发环境使用固定密钥
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key'; 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...'); console.log('Loading NapCat module...');
await import(pathToFileURL(NAPCAT_MJS_PATH).href); await import(pathToFileURL(NAPCAT_MJS_PATH).href);
} }

View File

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

View File

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

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 * as fs from 'fs';
import { ReadStream } from 'fs'; import { ReadStream } from 'fs';
import { Readable } from 'stream';
export interface ImageSize { export interface ImageSize {
width: number; width: number;
@@ -12,17 +19,18 @@ export enum ImageType {
BMP = 'bmp', BMP = 'bmp',
GIF = 'gif', GIF = 'gif',
WEBP = 'webp', WEBP = 'webp',
TIFF = 'tiff',
UNKNOWN = 'unknown', UNKNOWN = 'unknown',
} }
interface ImageParser { export interface ImageParser {
readonly type: ImageType; readonly type: ImageType;
canParse(buffer: Buffer): boolean; canParse (buffer: Buffer): boolean;
parseSize(stream: ReadStream): Promise<ImageSize | undefined>; 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) { if (buffer.length < offset + magic.length) {
return false; return false;
} }
@@ -35,316 +43,39 @@ function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
return true; return true;
} }
// PNG解析器 // 所有解析器实例
class PngParser implements ImageParser { const parserInstances = {
readonly type = ImageType.PNG; png: new PngParser(),
// PNG 魔术头89 50 4E 47 0D 0A 1A 0A jpeg: new JpegParser(),
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; 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) => { const typeToParser = new Map<ImageType, ImageParser>([
stream.once('error', reject); [ImageType.PNG, parserInstances.png],
stream.once('readable', () => { [ImageType.JPEG, parserInstances.jpeg],
const buf = stream.read(24) as Buffer; [ImageType.BMP, parserInstances.bmp],
if (!buf || buf.length < 24) { [ImageType.GIF, parserInstances.gif],
return resolve(undefined); [ImageType.WEBP, parserInstances.webp],
} [ImageType.TIFF, parserInstances.tiff],
if (this.canParse(buf)) { ]);
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// JPEG解析器 // 所有解析器列表(用于回退)
class JpegParser implements ImageParser { const parsers: ReadonlyArray<ImageParser> = Object.values(parserInstances);
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(),
];
export async function detectImageType (filePath: string): Promise<ImageType> { export async function detectImageType (filePath: string): Promise<ImageType> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -354,18 +85,22 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
end: 63, end: 63,
}); });
let buffer: Buffer | null = null; const chunks: Buffer[] = [];
stream.once('error', (err) => { stream.on('error', (err) => {
stream.destroy(); stream.destroy();
reject(err); reject(err);
}); });
stream.once('readable', () => { stream.on('data', (chunk: Buffer | string) => {
buffer = stream.read(64) as Buffer; const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
stream.destroy(); chunks.push(chunkBuffer);
});
if (!buffer) { stream.on('end', () => {
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) {
return resolve(ImageType.UNKNOWN); return resolve(ImageType.UNKNOWN);
} }
@@ -377,12 +112,6 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
resolve(ImageType.UNKNOWN); resolve(ImageType.UNKNOWN);
}); });
stream.once('end', () => {
if (!buffer) {
resolve(ImageType.UNKNOWN);
}
});
}); });
} }
@@ -390,7 +119,7 @@ export async function imageSizeFromFile (filePath: string): Promise<ImageSize |
try { try {
// 先检测类型 // 先检测类型
const type = await detectImageType(filePath); const type = await detectImageType(filePath);
const parser = parsers.find(p => p.type === type); const parser = typeToParser.get(type);
if (!parser) { if (!parser) {
return undefined; return undefined;
} }
@@ -422,3 +151,71 @@ export async function imageSizeFallBack (
): Promise<ImageSize> { ): Promise<ImageSize> {
return await imageSizeFromFile(filePath) ?? fallback; 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> { export class BotExit extends OneBotAction<void, void> {
override actionName = ActionName.Exit; override actionName = ActionName.Exit;
override payloadSchema = Type.Void(); override payloadSchema = Type.Object({});
override returnSchema = Type.Void(); override returnSchema = Type.Object({});
override actionSummary = '退出登录'; override actionSummary = '退出登录';
override actionTags = ['系统扩展']; override actionTags = ['系统扩展'];
override payloadExample = {}; override payloadExample = {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ export { PluginModule } from './plugin/types';
export { PluginStatusConfig } from './plugin/types'; export { PluginStatusConfig } from './plugin/types';
export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types'; export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types';
export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types'; export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types';
export { MemoryStaticFile, MemoryFileGenerator } from './plugin/types';
export { PluginRouterRegistryImpl } from './plugin/router-registry'; export { PluginRouterRegistryImpl } from './plugin/router-registry';
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager { export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
private readonly pluginPath: string; private readonly pluginPath: string;
@@ -179,6 +180,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
this.pluginRouters.delete(entry.id); this.pluginRouters.delete(entry.id);
} }
// 清理模块缓存
this.loader.clearCache(entry.pluginPath);
// 重置状态 // 重置状态
entry.loaded = false; entry.loaded = false;
entry.runtime = { entry.runtime = {
@@ -211,6 +215,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
// 保存到路由注册表 // 保存到路由注册表
this.pluginRouters.set(entry.id, routerRegistry); 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 { return {
core: this.core, core: this.core,
oneBot: this.obContext, oneBot: this.obContext,
@@ -224,6 +237,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
pluginManager: this, pluginManager: this,
logger: pluginLogger, logger: pluginLogger,
router: routerRegistry, router: routerRegistry,
getPluginExports,
}; };
} }
@@ -316,6 +330,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
entry = newEntry; entry = newEntry;
} }
if (!entry.enable) {
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
return false;
}
return await this.loadPlugin(entry); return await this.loadPlugin(entry);
} }

View File

@@ -1,5 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
import { LogWrapper } from 'napcat-core/helper/log'; import { LogWrapper } from 'napcat-core/helper/log';
import { import {
PluginPackageJson, PluginPackageJson,
@@ -123,8 +125,8 @@ export class PluginLoader {
const entryFile = this.findEntryFile(pluginDir, packageJson); const entryFile = this.findEntryFile(pluginDir, packageJson);
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined; const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
// 获取启用状态(默认启用 // 获取启用状态(默认禁用,内置插件除外
const enable = statusConfig[pluginId] !== false; const enable = statusConfig[pluginId] ?? (pluginId === 'napcat-plugin-builtin');
// 创建插件条目 // 创建插件条目
const entry: PluginEntry = { const entry: PluginEntry = {
@@ -159,7 +161,7 @@ export class PluginLoader {
id: dirname, // 使用目录名作为 ID id: dirname, // 使用目录名作为 ID
fileId: dirname, fileId: dirname,
pluginPath: path.join(this.pluginPath, dirname), pluginPath: path.join(this.pluginPath, dirname),
enable: statusConfig[dirname] !== false, enable: statusConfig[dirname] ?? (dirname === 'napcat-plugin-builtin'),
loaded: false, loaded: false,
runtime: { runtime: {
status: 'error', status: 'error',
@@ -295,4 +297,24 @@ export class PluginLoader {
return null; 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

@@ -194,6 +194,15 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
this.pluginRouters.set(entry.id, router); 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 { return {
core: this.core, core: this.core,
oneBot: this.obContext, oneBot: this.obContext,
@@ -207,6 +216,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
pluginManager: this, pluginManager: this,
logger: pluginLogger, logger: pluginLogger,
router, router,
getPluginExports,
}; };
} }
@@ -285,6 +295,11 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
entry = newEntry; entry = newEntry;
} }
if (!entry.enable) {
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
return false;
}
return await this.loadPlugin(entry); return await this.loadPlugin(entry);
} }

View File

@@ -1,4 +1,4 @@
import { Router, static as expressStatic, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import path from 'path'; import path from 'path';
import { import {
PluginRouterRegistry, PluginRouterRegistry,
@@ -8,6 +8,7 @@ import {
PluginHttpRequest, PluginHttpRequest,
PluginHttpResponse, PluginHttpResponse,
HttpMethod, HttpMethod,
MemoryStaticFile,
} from './types'; } from './types';
/** /**
@@ -59,10 +60,18 @@ function wrapResponse (res: Response): PluginHttpResponse {
* 插件路由注册器实现 * 插件路由注册器实现
* 为每个插件创建独立的路由注册器,收集路由定义 * 为每个插件创建独立的路由注册器,收集路由定义
*/ */
/** 内存静态路由定义 */
interface MemoryStaticRoute {
urlPath: string;
files: MemoryStaticFile[];
}
export class PluginRouterRegistryImpl implements PluginRouterRegistry { export class PluginRouterRegistryImpl implements PluginRouterRegistry {
private apiRoutes: PluginApiRouteDefinition[] = []; private apiRoutes: PluginApiRouteDefinition[] = [];
private apiNoAuthRoutes: PluginApiRouteDefinition[] = [];
private pageDefinitions: PluginPageDefinition[] = []; private pageDefinitions: PluginPageDefinition[] = [];
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = []; private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
private memoryStaticRoutes: MemoryStaticRoute[] = [];
constructor ( constructor (
private readonly pluginId: string, private readonly pluginId: string,
@@ -91,6 +100,28 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
this.api('delete', routePath, handler); 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 { page (pageDef: PluginPageDefinition): void {
@@ -111,19 +142,19 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
this.staticRoutes.push({ urlPath, localPath: absolutePath }); this.staticRoutes.push({ urlPath, localPath: absolutePath });
} }
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void {
this.memoryStaticRoutes.push({ urlPath, files });
}
// ==================== 构建路由 ==================== // ==================== 构建路由 ====================
/** /**
* 构建 Express Router用于 API 路由) * 构建 Express Router用于 API 路由)
* 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理
*/ */
buildApiRouter (): Router { buildApiRouter (): Router {
const router = Router(); const router = Router();
// 注册静态文件路由
for (const { urlPath, localPath } of this.staticRoutes) {
router.use(urlPath, expressStatic(localPath));
}
// 注册 API 路由 // 注册 API 路由
for (const route of this.apiRoutes) { for (const route of this.apiRoutes) {
const handler = this.wrapHandler(route.handler); const handler = this.wrapHandler(route.handler);
@@ -176,10 +207,57 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
// ==================== 查询方法 ==================== // ==================== 查询方法 ====================
/** /**
* 检查是否有注册的 API 路由 * 检查是否有注册的 API 路由(需要认证)
*/ */
hasApiRoutes (): boolean { hasApiRoutes (): boolean {
return this.apiRoutes.length > 0 || this.staticRoutes.length > 0; 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;
} }
/** /**
@@ -210,12 +288,28 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
return this.pluginPath; return this.pluginPath;
} }
/**
* 获取所有注册的静态路由
*/
getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> {
return [...this.staticRoutes];
}
/**
* 获取所有注册的内存静态路由
*/
getMemoryStaticRoutes (): MemoryStaticRoute[] {
return [...this.memoryStaticRoutes];
}
/** /**
* 清空路由(用于插件卸载) * 清空路由(用于插件卸载)
*/ */
clear (): void { clear (): void {
this.apiRoutes = []; this.apiRoutes = [];
this.apiNoAuthRoutes = [];
this.pageDefinitions = []; this.pageDefinitions = [];
this.staticRoutes = []; this.staticRoutes = [];
this.memoryStaticRoutes = [];
} }
} }

View File

@@ -125,26 +125,57 @@ export interface PluginPageDefinition {
description?: 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 { export interface PluginRouterRegistry {
// ==================== API 路由注册 ==================== // ==================== API 路由注册(需要认证) ====================
/** /**
* 注册单个 API 路由 * 注册单个 API 路由(需要认证,挂载到 /api/Plugin/ext/{pluginId}/
* @param method HTTP 方法 * @param method HTTP 方法
* @param path 路由路径 * @param path 路由路径
* @param handler 请求处理器 * @param handler 请求处理器
*/ */
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void; api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
/** 注册 GET API */ /** 注册 GET API(需要认证) */
get (path: string, handler: PluginRequestHandler): void; get (path: string, handler: PluginRequestHandler): void;
/** 注册 POST API */ /** 注册 POST API(需要认证) */
post (path: string, handler: PluginRequestHandler): void; post (path: string, handler: PluginRequestHandler): void;
/** 注册 PUT API */ /** 注册 PUT API(需要认证) */
put (path: string, handler: PluginRequestHandler): void; put (path: string, handler: PluginRequestHandler): void;
/** 注册 DELETE API */ /** 注册 DELETE API(需要认证) */
delete (path: string, handler: PluginRequestHandler): void; 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;
// ==================== 页面注册 ==================== // ==================== 页面注册 ====================
/** /**
@@ -167,6 +198,13 @@ export interface PluginRouterRegistry {
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径) * @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
*/ */
static (urlPath: string, localPath: string): void; static (urlPath: string, localPath: string): void;
/**
* 提供内存生成的静态文件服务
* @param urlPath URL 路径
* @param files 内存文件列表
*/
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void;
} }
// ==================== 插件管理器接口 ==================== // ==================== 插件管理器接口 ====================
@@ -247,8 +285,15 @@ export interface NapCatPluginContext {
/** /**
* WebUI 路由注册器 * WebUI 路由注册器
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/ * 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
* 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/
*/ */
router: PluginRouterRegistry; router: PluginRouterRegistry;
/**
* 获取其他插件的导出模块
* @param pluginId 目标插件 ID
* @returns 插件导出的模块,如果插件未加载则返回 undefined
*/
getPluginExports: <T = PluginModule>(pluginId: string) => T | undefined;
} }
// ==================== 插件模块接口 ==================== // ==================== 插件模块接口 ====================

View File

@@ -65,10 +65,32 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
// ==================== 注册 WebUI 路由示例 ==================== // ==================== 注册 WebUI 路由示例 ====================
// 注册静态资源目录webui 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问) // 注册静态资源目录
// 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权)
ctx.router.static('/static', 'webui'); ctx.router.static('/static', 'webui');
// 注册 API 路由 // 注册内存生成的静态资源(无需鉴权)
// 可通过 /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) => { ctx.router.get('/status', (_req, res) => {
const uptime = Date.now() - startTime; const uptime = Date.now() - startTime;
res.json({ res.json({
@@ -107,7 +129,74 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
} }
}); });
// 注册扩展页面 // ==================== 无认证 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({ ctx.router.page({
path: 'dashboard', path: 'dashboard',
title: '插件仪表盘', title: '插件仪表盘',
@@ -116,7 +205,12 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
description: '查看内置插件的运行状态和配置' description: '查看内置插件的运行状态和配置'
}); });
logger.info('WebUI 路由已注册: /api/Plugin/ext/' + ctx.pluginName); 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: PluginModule['plugin_get_config'] = async () => { export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {

View File

@@ -7,7 +7,7 @@
"description": "NapCat 内置插件", "description": "NapCat 内置插件",
"author": "NapNeko", "author": "NapNeko",
"dependencies": { "dependencies": {
"napcat-types": "0.0.14" "napcat-types": "0.0.16"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.1" "@types/node": "^22.0.1"

View File

@@ -259,11 +259,14 @@
</div> </div>
<div id="static-content"> <div id="static-content">
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;"> <p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
测试插件静态资源服务是否正常工作 测试插件静态资源服务是否正常工作(不需要鉴权)
</p> </p>
<div class="actions" style="margin-top: 0;"> <div class="actions" style="margin-top: 0;">
<button class="btn btn-primary" onclick="testStaticResource()"> <button class="btn btn-primary" onclick="testStaticResource()">
获取 test.txt 获取 test.txt(文件系统)
</button>
<button class="btn btn-primary" onclick="testMemoryResource()">
获取 info.json内存生成
</button> </button>
</div> </div>
<div id="static-result" style="margin-top: 12px;"></div> <div id="static-result" style="margin-top: 12px;"></div>
@@ -276,12 +279,17 @@
</div> </div>
<script> <script>
// 从 URL 参数获取 webui_token // 从 localStorage 获取 token与父页面同源可直接访问
// 兼容旧版:如果 URL 有 webui_token 参数则优先使用
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const webuiToken = urlParams.get('webui_token') || ''; const webuiToken = localStorage.getItem('token') || '';
// 插件自行管理 API 调用 // 插件 API 基础路径(需要鉴权)
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin'; const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
// 插件静态资源基础路径(不需要鉴权)
const staticBase = '/plugin/napcat-plugin-builtin/files';
// 插件内存资源基础路径(不需要鉴权)
const memBase = '/plugin/napcat-plugin-builtin/mem';
// 封装 fetch自动携带认证 // 封装 fetch自动携带认证
async function authFetch (url, options = {}) { async function authFetch (url, options = {}) {
@@ -392,12 +400,13 @@
resultDiv.innerHTML = '<div class="loading">加载中</div>'; resultDiv.innerHTML = '<div class="loading">加载中</div>';
try { try {
const response = await authFetch(`${apiBase}/static/test.txt`); // 静态资源不需要鉴权,直接请求
const response = await fetch(`${staticBase}/static/test.txt`);
if (response.ok) { if (response.ok) {
const text = await response.text(); const text = await response.text();
resultDiv.innerHTML = ` resultDiv.innerHTML = `
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;"> <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> <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> <pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${text}</pre>
</div> </div>
`; `;
@@ -408,6 +417,30 @@
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`; 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> </script>
</body> </body>

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

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

View File

@@ -109,6 +109,7 @@ async function initializeLoginService (
commonPath: dataPathGlobal, commonPath: dataPathGlobal,
clientVer: basicInfoWrapper.getFullQQVersion(), clientVer: basicInfoWrapper.getFullQQVersion(),
hostName: hostname, hostName: hostname,
externalVersion: false,
}); });
} }
@@ -220,6 +221,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 (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) { if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin); logger.log('正在快速登录 ', quickLoginUin);

View File

@@ -7,11 +7,33 @@ import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
import { webUiRuntimePort } from '@/napcat-webui-backend/index'; import { webUiRuntimePort } from '@/napcat-webui-backend/index';
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api'; import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
import path from 'path'; import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
// ES 模块中获取 __dirname
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); 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 = { const ENV = {
@@ -20,6 +42,7 @@ const ENV = {
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1', isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
} as const; } as const;
// Worker 消息类型 // Worker 消息类型
interface WorkerMessage { interface WorkerMessage {
type: 'restart' | 'restart-prepare' | 'shutdown'; type: 'restart' | 'restart-prepare' | 'shutdown';
@@ -27,8 +50,7 @@ interface WorkerMessage {
port?: number; port?: number;
} }
// 初始化日志
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath); const logger = new LogWrapper(pathWrapper.logsPath);
// 进程管理器和当前 Worker 进程引用 // 进程管理器和当前 Worker 进程引用

View File

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

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" "vitest": "^4.0.9"
}, },
"dependencies": { "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: { resolve: {
alias: { 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,6 +1,6 @@
{ {
"name": "napcat-types", "name": "napcat-types",
"version": "0.0.14", "version": "0.0.16",
"private": false, "private": false,
"type": "module", "type": "module",
"types": "./napcat-types/index.d.ts", "types": "./napcat-types/index.d.ts",

View File

@@ -27,6 +27,8 @@ import compression from 'compression';
import { napCatVersion } from 'napcat-common/src/version'; import { napCatVersion } from 'napcat-common/src/version';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path'; 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 __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -123,9 +125,14 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
return; return;
} }
// 检查并更新默认密码仅在启用WebUI时 // 优先使用环境变量覆盖 Token
if (config.token === 'napcat' || !config.token) { if (process.env['NAPCAT_WEBUI_SECRET_KEY'] && config.token !== process.env['NAPCAT_WEBUI_SECRET_KEY']) {
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8); 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 }); await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码'); logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
@@ -226,10 +233,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// 添加字体变量 // 添加字体变量
if (fontMode === 'aacute') { if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;"; css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') { } else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;"; css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
} else { } else {
css += '--font-family-base: var(--font-family-fallbacks) !important;'; 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 += '}'; css += '}';
@@ -240,10 +250,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// 添加字体变量 // 添加字体变量
if (fontMode === 'aacute') { if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;"; css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') { } else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;"; css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
} else { } else {
css += '--font-family-base: var(--font-family-fallbacks) !important;'; 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 += '}'; css += '}';
@@ -283,6 +296,130 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
app.use('/webui', express.static(pathWrapper.staticPath, { app.use('/webui', express.static(pathWrapper.staticPath, {
maxAge: '1d', 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服务器 // 初始化WebSocket服务器
const sslCerts = await checkCertificates(logger); const sslCerts = await checkCertificates(logger);
const isHttps = !!sslCerts; 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, '删除字体文件失败'); 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

@@ -34,12 +34,12 @@ const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存
/** /**
* 从多个源获取插件列表,使用镜像系统 * 从多个源获取插件列表,使用镜像系统
* 带10分钟缓存 * 带10分钟缓存,支持强制刷新
*/ */
async function fetchPluginList (): Promise<PluginStoreList> { async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginStoreList> {
// 检查缓存 // 检查缓存(如果不是强制刷新)
const now = Date.now(); const now = Date.now();
if (pluginListCache && (now - cacheTimestamp) < CACHE_TTL) { if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
//console.log('Using cached plugin list'); //console.log('Using cached plugin list');
return pluginListCache; return pluginListCache;
} }
@@ -157,6 +157,8 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> { async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
const PLUGINS_DIR = getPluginsDir(); const PLUGINS_DIR = getPluginsDir();
const pluginDir = path.join(PLUGINS_DIR, pluginId); const pluginDir = path.join(PLUGINS_DIR, pluginId);
const dataDir = path.join(pluginDir, 'data');
const tempDataDir = path.join(PLUGINS_DIR, `${pluginId}.data.backup`);
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`); console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
console.log(`[extractPlugin] pluginId: ${pluginId}`); console.log(`[extractPlugin] pluginId: ${pluginId}`);
@@ -169,8 +171,19 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`); console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
} }
// 如果目录已存在,先删除 // 如果目录已存在,先备份 data 文件夹,再删除
let hasDataBackup = false;
if (fs.existsSync(pluginDir)) { if (fs.existsSync(pluginDir)) {
// 备份 data 文件夹
if (fs.existsSync(dataDir)) {
console.log(`[extractPlugin] Backing up data directory: ${dataDir}`);
if (fs.existsSync(tempDataDir)) {
fs.rmSync(tempDataDir, { recursive: true, force: true });
}
fs.renameSync(dataDir, tempDataDir);
hasDataBackup = true;
}
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`); console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
fs.rmSync(pluginDir, { recursive: true, force: true }); fs.rmSync(pluginDir, { recursive: true, force: true });
} }
@@ -179,10 +192,35 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
fs.mkdirSync(pluginDir, { recursive: true }); fs.mkdirSync(pluginDir, { recursive: true });
console.log(`[extractPlugin] Created directory: ${pluginDir}`); console.log(`[extractPlugin] Created directory: ${pluginDir}`);
// 解压 try {
await compressing.zip.uncompress(zipPath, pluginDir); // 解压
await compressing.zip.uncompress(zipPath, pluginDir);
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`); console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
// 恢复 data 文件夹
if (hasDataBackup && fs.existsSync(tempDataDir)) {
// 如果新版本也有 data 文件夹,先删除
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
console.log(`[extractPlugin] Restoring data directory: ${dataDir}`);
fs.renameSync(tempDataDir, dataDir);
}
} catch (e) {
// 解压失败时,尝试恢复 data 文件夹
if (hasDataBackup && fs.existsSync(tempDataDir)) {
console.log(`[extractPlugin] Extract failed, restoring data directory`);
if (!fs.existsSync(pluginDir)) {
fs.mkdirSync(pluginDir, { recursive: true });
}
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
fs.renameSync(tempDataDir, dataDir);
}
throw e;
}
// 列出解压后的文件 // 列出解压后的文件
const files = fs.readdirSync(pluginDir); const files = fs.readdirSync(pluginDir);
@@ -192,9 +230,11 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
/** /**
* 获取插件商店列表 * 获取插件商店列表
*/ */
export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => { export const GetPluginStoreListHandler: RequestHandler = async (req, res) => {
try { try {
const data = await fetchPluginList(); // 支持 forceRefresh 查询参数强制刷新缓存
const forceRefresh = req.query['forceRefresh'] === 'true';
const data = await fetchPluginList(forceRefresh);
return sendSuccess(res, data); return sendSuccess(res, data);
} catch (e: any) { } catch (e: any) {
return sendError(res, 'Failed to fetch plugin store list: ' + e.message); return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
@@ -252,11 +292,13 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
// 删除临时文件 // 删除临时文件
fs.unlinkSync(tempZipPath); fs.unlinkSync(tempZipPath);
// 如果 pluginManager 存在,立即注册插件 // 如果 pluginManager 存在,立即注册或重载插件
const pluginManager = getPluginManager(); const pluginManager = getPluginManager();
if (pluginManager) { if (pluginManager) {
// 检查是否已注册,避免重复注册 // 如果插件已存在,则重载以刷新版本信息;否则注册新插件
if (!pluginManager.getPluginInfo(id)) { if (pluginManager.getPluginInfo(id)) {
await pluginManager.reloadPlugin(id);
} else {
await pluginManager.loadPluginById(id); await pluginManager.loadPluginById(id);
} }
} }
@@ -334,11 +376,14 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
sendProgress('解压完成,正在清理...', 90); sendProgress('解压完成,正在清理...', 90);
fs.unlinkSync(tempZipPath); fs.unlinkSync(tempZipPath);
// 如果 pluginManager 存在,立即注册插件 // 如果 pluginManager 存在,立即注册或重载插件
const pluginManager = getPluginManager(); const pluginManager = getPluginManager();
if (pluginManager) { if (pluginManager) {
// 检查是否已注册,避免重复注册 // 如果插件已存在,则重载以刷新版本信息;否则注册新插件
if (!pluginManager.getPluginInfo(id)) { if (pluginManager.getPluginInfo(id)) {
sendProgress('正在刷新插件信息...', 95);
await pluginManager.reloadPlugin(id);
} else {
sendProgress('正在注册插件...', 95); sendProgress('正在注册插件...', 95);
await pluginManager.loadPluginById(id); await pluginManager.loadPluginById(id);
} }

View File

@@ -108,3 +108,29 @@ export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
await WebUiDataRuntime.refreshQRCode(); await WebUiDataRuntime.refreshQRCode();
return sendSuccess(res, null); return sendSuccess(res, null);
}; };
// 密码登录
export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
// 获取QQ号和密码MD5
const { uin, passwordMd5 } = req.body;
// 判断是否已经登录
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (isLogin) {
return sendError(res, 'QQ Is Logined');
}
// 判断QQ号是否为空
if (isEmpty(uin)) {
return sendError(res, 'uin is empty');
}
// 判断密码MD5是否为空
if (isEmpty(passwordMd5)) {
return sendError(res, 'passwordMd5 is empty');
}
// 执行密码登录
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
if (!result) {
return sendError(res, message);
}
return sendSuccess(res, null);
};

View File

@@ -340,7 +340,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json'); const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
logger.log('[NapCat Update] No pending updates found'); //logger.log('[NapCat Update] No pending updates found');
return; return;
} }

View File

@@ -1,24 +1,157 @@
const CACHE_NAME = 'napcat-webui-v{{VERSION}}'; /**
const ASSETS_TO_CACHE = [ * NapCat WebUI Service Worker
'/webui/' *
]; * 路由缓存策略设计:
*
* 【永不缓存 - Network Only】
* - /api/* WebUI API
* - /plugin/:id/api/* 插件 API
* - /files/theme.css 动态主题 CSS
* - /webui/fonts/CustomFont.woff 用户自定义字体
* - WebSocket / SSE 连接
*
* 【强缓存 - Cache First】
* - /webui/assets/* 前端静态资源(带 hash
* - /webui/fonts/* 内置字体(排除 CustomFont
* - q1.qlogo.cn QQ 头像
*
* 【网络优先 - Network First】
* - /webui/* (HTML 导航) SPA 页面
* - /plugin/:id/page/* 插件页面
* - /plugin/:id/files/* 插件文件系统静态资源
*
* 【后台更新 - Stale-While-Revalidate】
* - /plugin/:id/mem/* 插件内存静态资源
*/
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
// 缓存配置
const CACHE_CONFIG = {
// 静态资源缓存最大条目数
MAX_STATIC_ENTRIES: 200,
// QQ 头像缓存最大条目数
MAX_AVATAR_ENTRIES: 100,
// 插件资源缓存最大条目数
MAX_PLUGIN_ENTRIES: 50,
};
// ============ 路由匹配辅助函数 ============
/**
* 检查是否为永不缓存的请求
*/
function isNeverCache (url, request) {
// WebUI API
if (url.pathname.startsWith('/api/')) return true;
// 插件 API: /plugin/:id/api/*
if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true;
// 动态主题 CSS
if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true;
// 用户自定义字体
if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true;
// WebSocket 升级请求
if (request.headers.get('Upgrade') === 'websocket') return true;
// SSE 请求
if (request.headers.get('Accept') === 'text/event-stream') return true;
// Socket 相关
if (url.pathname.includes('/socket')) return true;
return false;
}
/**
* 检查是否为 WebUI 静态资源(强缓存)
*/
function isWebUIStaticAsset (url) {
// /webui/assets/* - 前端构建产物(带 hash
if (url.pathname.startsWith('/webui/assets/')) return true;
// /webui/fonts/* - 内置字体(排除 CustomFont
if (url.pathname.startsWith('/webui/fonts/') &&
!url.pathname.includes('CustomFont.woff')) return true;
return false;
}
/**
* 检查是否为外部头像(强缓存)
*/
function isQLogoAvatar (url) {
return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn';
}
/**
* 检查是否为插件文件系统静态资源(网络优先)
*/
function isPluginStaticFiles (url) {
// /plugin/:id/files/*
return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname);
}
/**
* 检查是否为插件内存静态资源Stale-While-Revalidate
*/
function isPluginMemoryAsset (url) {
// /plugin/:id/mem/*
return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname);
}
/**
* 检查是否为插件页面Network First
*/
function isPluginPage (url) {
// /plugin/:id/page/*
return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname);
}
// ============ 缓存管理函数 ============
/**
* 限制缓存条目数量
*/
async function trimCache (cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
// 删除最早的条目
const deleteCount = keys.length - maxEntries;
for (let i = 0; i < deleteCount; i++) {
await cache.delete(keys[i]);
}
console.log(`[SW] Trimmed ${deleteCount} entries from cache`);
}
}
/**
* 按类型获取缓存限制
*/
function getCacheLimitForRequest (url) {
if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES;
if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES;
return CACHE_CONFIG.MAX_STATIC_ENTRIES;
}
// ============ Service Worker 生命周期 ============
// 安装阶段:预缓存核心文件
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
self.skipWaiting(); // 强制立即接管 console.log('[SW] Installing new version:', CACHE_NAME);
event.waitUntil( self.skipWaiting();
caches.open(CACHE_NAME).then((cache) => {
// 这里的资源如果加载失败不应该阻断 SW 安装
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
})
);
}); });
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
console.log('[SW] Activating new version:', CACHE_NAME);
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { (async () => {
return Promise.all( // 清理所有旧版本缓存
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map((cacheName) => { cacheNames.map((cacheName) => {
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) { if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
console.log('[SW] Deleting old cache:', cacheName); console.log('[SW] Deleting old cache:', cacheName);
@@ -26,107 +159,178 @@ self.addEventListener('activate', (event) => {
} }
}) })
); );
}) // 立即接管所有客户端
await self.clients.claim();
})()
); );
self.clients.claim(); // 立即控制所有客户端
}); });
// 拦截请求 // ============ 请求拦截 ============
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url); const url = new URL(event.request.url);
const request = event.request;
// 1. API 请求:仅网络 (Network Only) // 1. 永不缓存的请求 - Network Only
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) { if (isNeverCache(url, request)) {
// 不调用 respondWith让请求直接穿透到网络
return; return;
} }
// 2. 强缓存策略 (Cache First) // 2. WebUI 静态资源 - Cache First
// - 外部 QQ 头像 (q1.qlogo.cn) if (isWebUIStaticAsset(url)) {
// - 静态资源 (assets, fonts) event.respondWith(cacheFirst(request, url));
// - 常见静态文件后缀
const isQLogo = url.hostname === 'q1.qlogo.cn';
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS不强缓存
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
url.pathname.includes('/webui/fonts/');
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response
// 但 fetch(event.request) 默认会继承 request 的 mode。
// 如果是 img标签发起的请求通常 mode 是 no-cors 或 cors。
// 对于 opaque response (status 0), cache API 允许缓存。
return fetch(event.request).then((response) => {
// 对 qlogo 允许 status 0 (opaque)
// 对其他资源要求 status 200
const isValidResponse = response && (
response.status === 200 ||
response.type === 'basic' ||
(isQLogo && response.type === 'opaque')
);
if (!isValidResponse) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
return; return;
} }
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First) // 3. QQ 头像 - Cache First支持 opaque response
if (event.request.mode === 'navigate') { if (isQLogoAvatar(url)) {
event.respondWith( event.respondWith(cacheFirstWithOpaque(request, url));
fetch(event.request)
.then((response) => {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
return; return;
} }
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate // 4. 插件文件系统静态资源 - Network First
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的 if (isPluginStaticFiles(url)) {
event.respondWith(networkFirst(request));
return;
}
// 5. 插件内存静态资源 - Stale-While-Revalidate
if (isPluginMemoryAsset(url)) {
event.respondWith(staleWhileRevalidate(request, url));
return;
}
// 6. 插件页面 - Network First
if (isPluginPage(url)) {
event.respondWith(networkFirst(request));
return;
}
// 7. HTML 导航请求 - Network First
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
// 8. 其他同源请求 - Network Only避免意外缓存
if (url.origin === self.location.origin) { if (url.origin === self.location.origin) {
event.respondWith( // 不缓存,直接穿透
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return networkResponse;
});
// 如果有缓存,返回缓存;否则等待网络
return cachedResponse || fetchPromise;
})
);
return; return;
} }
// 默认:网络优先 // 9. 其他外部请求 - Network Only
event.respondWith( return;
fetch(event.request).catch(() => caches.match(event.request))
);
}); });
// ============ 缓存策略实现 ============
/**
* Cache First 策略
* 优先从缓存返回,缓存未命中则从网络获取并缓存
*/
async function cacheFirst (request, url) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
// 异步清理缓存
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache First fetch failed:', error);
return new Response('Network error', { status: 503 });
}
}
/**
* Cache First 策略(支持 opaque response用于跨域头像
*/
async function cacheFirstWithOpaque (request, url) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
// opaque response 的 status 是 0但仍可缓存
const isValidResponse = networkResponse && (
networkResponse.status === 200 ||
networkResponse.type === 'opaque'
);
if (isValidResponse) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache First (opaque) fetch failed:', error);
return new Response('Network error', { status: 503 });
}
}
/**
* Network First 策略
* 优先从网络获取,网络失败则返回缓存
*/
async function networkFirst (request) {
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log('[SW] Network First: network failed, trying cache');
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
return new Response('Offline', { status: 503 });
}
}
/**
* Stale-While-Revalidate 策略
* 立即返回缓存(如果有),同时后台更新缓存
*/
async function staleWhileRevalidate (request, url) {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
// 后台刷新缓存
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
cache.put(request, networkResponse.clone());
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
}).catch((error) => {
console.log('[SW] SWR background fetch failed:', error);
return null;
});
// 如果有缓存,立即返回缓存
if (cachedResponse) {
return cachedResponse;
}
// 没有缓存,等待网络
const networkResponse = await fetchPromise;
if (networkResponse) {
return networkResponse;
}
return new Response('Network error', { status: 503 });
}

View File

@@ -33,6 +33,9 @@ const LoginRuntime: LoginRuntimeType = {
onQuickLoginRequested: async () => { onQuickLoginRequested: async () => {
return { result: false, message: '' }; return { result: false, message: '' };
}, },
onPasswordLoginRequested: async () => {
return { result: false, message: '密码登录功能未初始化' };
},
onRestartProcessRequested: async () => { onRestartProcessRequested: async () => {
return { result: false, message: '重启功能未初始化' }; return { result: false, message: '重启功能未初始化' };
}, },
@@ -136,6 +139,14 @@ export const WebUiDataRuntime = {
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin); return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'], } as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
setPasswordLoginCall (func: LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested']): void {
LoginRuntime.NapCatHelper.onPasswordLoginRequested = func;
},
requestPasswordLogin: function (uin: string, passwordMd5: string) {
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void { setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func; LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
}, },

View File

@@ -16,6 +16,7 @@ import {
UploadHandler, UploadHandler,
UploadWebUIFontHandler, UploadWebUIFontHandler,
DeleteWebUIFontHandler, // 添加上传处理器 DeleteWebUIFontHandler, // 添加上传处理器
CheckWebUIFontExistHandler, // Add this
} from '../api/File'; } from '../api/File';
const router: Router = Router(); const router: Router = Router();
@@ -46,4 +47,5 @@ router.post('/upload', UploadHandler);
router.post('/font/upload/webui', UploadWebUIFontHandler); router.post('/font/upload/webui', UploadWebUIFontHandler);
router.post('/font/delete/webui', DeleteWebUIFontHandler); router.post('/font/delete/webui', DeleteWebUIFontHandler);
router.get('/font/exists/webui', CheckWebUIFontExistHandler); // Add this
export { router as FileRouter }; export { router as FileRouter };

View File

@@ -1,11 +1,24 @@
import { Router } from 'express'; import { Router } from 'express';
import multer from 'multer';
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config'; import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/napcat-webui-backend/src/api/BackupConfig';
const router: Router = Router(); const router: Router = Router();
// 使用内存存储,配合流式处理
const upload = multer({
storage: multer.memoryStorage()
});
// router:读取配置 // router:读取配置
router.post('/GetConfig', OB11GetConfigHandler); router.post('/GetConfig', OB11GetConfigHandler);
// router:写入配置 // router:写入配置
router.post('/SetConfig', OB11SetConfigHandler); router.post('/SetConfig', OB11SetConfigHandler);
// router:导出配置
router.get('/ExportConfig', BackupExportConfigHandler);
// router:导入配置
router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler);
export { router as OB11ConfigRouter }; export { router as OB11ConfigRouter };

View File

@@ -10,6 +10,7 @@ import {
getAutoLoginAccountHandler, getAutoLoginAccountHandler,
setAutoLoginAccountHandler, setAutoLoginAccountHandler,
QQRefreshQRcodeHandler, QQRefreshQRcodeHandler,
QQPasswordLoginHandler,
} from '@/napcat-webui-backend/src/api/QQLogin'; } from '@/napcat-webui-backend/src/api/QQLogin';
const router: Router = Router(); const router: Router = Router();
@@ -31,5 +32,7 @@ router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler); router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
// router:刷新QQ登录二维码 // router:刷新QQ登录二维码
router.post('/RefreshQRcode', QQRefreshQRcodeHandler); router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
// router:密码登录
router.post('/PasswordLogin', QQPasswordLoginHandler);
export { router as QQLoginRouter }; export { router as QQLoginRouter };

View File

@@ -56,6 +56,7 @@ export interface LoginRuntimeType {
OneBotContext: any | null; // OneBot 上下文,用于调试功能 OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: { NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>; onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>; onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>; onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
QQLoginList: string[]; QQLoginList: string[];

View File

@@ -9,15 +9,30 @@ const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
// 清理旧的字体文件 // 清理旧的字体文件
const cleanOldFontFiles = (fontsPath: string) => { const cleanOldFontFiles = (fontsPath: string) => {
for (const ext of SUPPORTED_FONT_EXTENSIONS) { try {
const fontPath = path.join(fontsPath, `webui${ext}`); // 确保字体目录存在
try { if (!fs.existsSync(fontsPath)) {
if (fs.existsSync(fontPath)) { return;
fs.unlinkSync(fontPath);
}
} catch {
// 忽略删除失败
} }
// 遍历目录下所有文件
const files = fs.readdirSync(fontsPath);
for (const file of files) {
// 检查文件名是否以 webui 或 CustomFont 开头,且是支持的字体扩展名
const ext = path.extname(file).toLowerCase();
const name = path.basename(file, ext);
if (SUPPORTED_FONT_EXTENSIONS.includes(ext) && (name === 'webui' || name === 'CustomFont')) {
try {
fs.unlinkSync(path.join(fontsPath, file));
} catch (e) {
console.error(`Failed to delete old font file ${file}:`, e);
}
}
}
} catch (err) {
console.error('Failed to clean old font files:', err);
} }
}; };
@@ -36,9 +51,9 @@ export const webUIFontStorage = multer.diskStorage({
} }
}, },
filename: (_, file, cb) => { filename: (_, file, cb) => {
// 保留原始扩展名,统一文件名为 webui // 强制文件名为 CustomFont保留原始扩展名
const ext = path.extname(file.originalname).toLowerCase(); const ext = path.extname(file.originalname).toLowerCase();
cb(null, `webui${ext}`); cb(null, `CustomFont${ext}`);
}, },
}); });

View File

@@ -73,7 +73,6 @@
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"quill": "^2.0.3", "quill": "^2.0.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-color": "^2.19.3",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",

View File

@@ -1,33 +1,406 @@
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; import { Input } from "@heroui/input";
import React from 'react'; import { Popover, PopoverContent, PopoverTrigger } from "@heroui/popover";
import { ColorResult, SketchPicker } from 'react-color'; import React, { useCallback, useEffect, useRef, useState, memo } from "react";
// 假定 heroui 提供的 Popover组件
interface ColorPickerProps { interface ColorPickerProps {
color: string color: string;
onChange: (color: ColorResult) => void onChange: (color: string) => void;
} }
// 转换 HSL 字符串到对象
const parseHsl = (hslStr: string) => {
const match = hslStr.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
if (match) {
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
}
return { h: 0, s: 0, l: 0 };
};
// 转换 HEX 到 HSL
const hexToHsl = (hex: string) => {
let r = 0, g = 0, b = 0;
if (hex.length === 4) {
r = parseInt("0x" + hex[1] + hex[1]);
g = parseInt("0x" + hex[2] + hex[2]);
b = parseInt("0x" + hex[3] + hex[3]);
} else if (hex.length === 7) {
r = parseInt("0x" + hex[1] + hex[2]);
g = parseInt("0x" + hex[3] + hex[4]);
b = parseInt("0x" + hex[5] + hex[6]);
}
r /= 255;
g /= 255;
b /= 255;
const cmin = Math.min(r, g, b),
cmax = Math.max(r, g, b),
delta = cmax - cmin;
let h = 0,
s = 0,
l = 0;
if (delta === 0) h = 0;
else if (cmax === r) h = ((g - b) / delta) % 6;
else if (cmax === g) h = (b - r) / delta + 2;
else h = (r - g) / delta + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
l = (cmax + cmin) / 2;
s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
s = +(s * 100).toFixed(1);
l = +(l * 100).toFixed(1);
return { h, s, l };
};
// 转换 HSL 到 HEX
const hslToHex = (h: number, s: number, l: number) => {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (0 <= h && h < 60) {
r = c; g = x; b = 0;
} else if (60 <= h && h < 120) {
r = x; g = c; b = 0;
} else if (120 <= h && h < 180) {
r = 0; g = c; b = x;
} else if (180 <= h && h < 240) {
r = 0; g = x; b = c;
} else if (240 <= h && h < 300) {
r = x; g = 0; b = c;
} else if (300 <= h && h < 360) {
r = c; g = 0; b = x;
}
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
const toHex = (n: number) => {
const hex = n.toString(16);
return hex.length === 1 ? "0" + hex : hex;
};
return "#" + toHex(r) + toHex(g) + toHex(b);
};
interface PanelProps {
hsl: { h: number, s: number, l: number; };
onChange: (newHsl: { h: number, s: number, l: number; }) => void;
}
// 饱和度/亮度面板
const SatLightPanel = memo(({ hsl, onChange }: PanelProps) => {
const panelRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const hslRef = useRef(hsl);
useEffect(() => { hslRef.current = hsl; }, [hsl]);
const updateColor = useCallback((clientX: number, clientY: number) => {
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const s_hsv = x;
const v_hsv = 1 - y;
let l_hsl = v_hsv * (1 - s_hsv / 2);
let s_hsl = 0;
if (l_hsl === 0 || l_hsl === 1) {
s_hsl = 0;
} else {
s_hsl = (v_hsv - l_hsl) / Math.min(l_hsl, 1 - l_hsl);
}
onChange({ h: hslRef.current.h, s: s_hsl * 100, l: l_hsl * 100 });
}, [onChange]);
const handleStart = (clientX: number, clientY: number) => {
setIsDragging(true);
updateColor(clientX, clientY);
};
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
handleStart(e.clientX, e.clientY);
};
const handleTouchStart = (e: React.TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
handleStart(touch.clientX, touch.clientY);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
e.preventDefault();
updateColor(e.clientX, e.clientY);
}
};
const handleTouchMove = (e: TouchEvent) => {
if (isDragging) {
e.preventDefault();
const touch = e.touches[0];
updateColor(touch.clientX, touch.clientY);
}
};
const handleEnd = () => {
setIsDragging(false);
};
if (isDragging) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleEnd);
window.addEventListener("touchmove", handleTouchMove, { passive: false });
window.addEventListener("touchend", handleEnd);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleEnd);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleEnd);
};
}, [isDragging, updateColor]);
const l_val = hsl.l / 100;
const s_val = hsl.s / 100;
const v_hsv = l_val + s_val * Math.min(l_val, 1 - l_val);
const s_hsv = v_hsv === 0 ? 0 : 2 * (1 - l_val / v_hsv);
const markerX = s_hsv * 100;
const markerY = (1 - v_hsv) * 100;
return (
<div
ref={panelRef}
className="w-full h-40 rounded-lg relative cursor-crosshair overflow-hidden shadow-inner touch-none"
style={{
backgroundColor: "hsl(" + hsl.h + ", 100%, 50%)",
backgroundImage: "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)"
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<div
className="w-4 h-4 rounded-full border-2 border-white shadow-md absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{
left: markerX + "%",
top: markerY + "%",
backgroundColor: "hsl(" + hsl.h + ", " + hsl.s + "%, " + hsl.l + "%)"
}}
/>
</div>
);
});
SatLightPanel.displayName = "SatLightPanel";
const HueSlider = memo(({ hsl, onChange }: PanelProps) => {
const sliderRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const hslRef = useRef(hsl);
useEffect(() => { hslRef.current = hsl; }, [hsl]);
const updateHue = useCallback((clientX: number) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
let x = (clientX - rect.left) / rect.width;
x = Math.max(0, Math.min(1, x));
onChange({ ...hslRef.current, h: x * 360 });
}, [onChange]);
const handleStart = (clientX: number) => {
setIsDragging(true);
updateHue(clientX);
};
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
handleStart(e.clientX);
};
const handleTouchStart = (e: React.TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
handleStart(touch.clientX);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
e.preventDefault();
updateHue(e.clientX);
}
};
const handleTouchMove = (e: TouchEvent) => {
if (isDragging) {
e.preventDefault();
const touch = e.touches[0];
updateHue(touch.clientX);
}
};
const handleEnd = () => {
setIsDragging(false);
};
if (isDragging) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleEnd);
window.addEventListener("touchmove", handleTouchMove, { passive: false });
window.addEventListener("touchend", handleEnd);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleEnd);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleEnd);
};
}, [isDragging, updateHue]);
return (
<div
ref={sliderRef}
className="w-full h-4 rounded-full relative cursor-pointer mt-3 shadow-inner touch-none"
style={{
background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)"
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<div
className="w-4 h-4 rounded-full border-2 border-white shadow-md absolute top-0 transform -translate-x-1/2 pointer-events-none bg-white"
style={{ left: (hsl.h / 360) * 100 + "%" }}
/>
</div>
);
});
HueSlider.displayName = "HueSlider";
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => { const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
const handleChange = (colorResult: ColorResult) => { const [hsl, setHsl] = useState(parseHsl(color));
onChange(colorResult); const [hex, setHex] = useState(hslToHex(hsl.h, hsl.s, hsl.l));
const isDraggingRef = useRef(false);
useEffect(() => {
if (isDraggingRef.current) return;
const newHsl = parseHsl(color);
if (Math.abs(newHsl.h - hsl.h) > 0.1 || Math.abs(newHsl.s - hsl.s) > 0.1 || Math.abs(newHsl.l - hsl.l) > 0.1) {
setHsl(newHsl);
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
}
}, [color]);
const handleHslChange = useCallback((newHsl: { h: number, s: number, l: number; }) => {
setHsl(newHsl);
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
onChange("hsl(" + Math.round(newHsl.h) + ", " + Math.round(newHsl.s) + "%, " + Math.round(newHsl.l) + "%)");
}, [onChange]);
const handleHexChange = (value: string) => {
setHex(value);
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
const newHsl = hexToHsl(value);
handleHslChange(newHsl);
}
}; };
return ( return (
<Popover triggerScaleOnOpen={false}> <Popover triggerScaleOnOpen={false} placement="bottom">
<PopoverTrigger> <PopoverTrigger>
<div <div className="flex items-center gap-2 cursor-pointer group">
className='w-36 h-8 rounded-md cursor-pointer border border-content4' <div
style={{ background: color }} className="w-10 h-10 rounded-lg shadow-sm border-2 border-default-200 transition-transform group-hover:scale-105"
/> style={{ background: color }}
/>
<div className="flex flex-col">
<span className="text-xs font-mono text-default-500">{hex}</span>
<span className="text-xs font-mono text-default-400">HSL({Math.round(hsl.h)}, {Math.round(hsl.s)}%, {Math.round(hsl.l)}%)</span>
</div>
</div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent className="w-72 p-4 bg-background/80 backdrop-blur-xl border border-default-200 shadow-2xl rounded-2xl"
<SketchPicker onMouseDownCapture={() => { isDraggingRef.current = true; }}
color={color} onMouseUpCapture={() => { isDraggingRef.current = false; }}
onChange={handleChange} onTouchStartCapture={() => { isDraggingRef.current = true; }}
className='!bg-transparent !shadow-none' onTouchEndCapture={() => { isDraggingRef.current = false; }}
/> >
<div className="flex flex-col w-full gap-2">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-default-700"></span>
<div
className="w-6 h-6 rounded-full border border-default-200 shadow-sm"
style={{ background: color }}
/>
</div>
<SatLightPanel hsl={hsl} onChange={handleHslChange} />
<HueSlider hsl={hsl} onChange={handleHslChange} />
<div className="grid grid-cols-4 gap-2 mt-2 items-center">
<span className="text-xs text-default-500 col-span-1">HEX</span>
<Input
size="sm"
variant="flat"
value={hex}
onChange={(e) => handleHexChange(e.target.value)}
className="col-span-3 font-mono"
classNames={{
input: "text-xs uppercase",
inputWrapper: "h-8 min-h-8"
}}
/>
</div>
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-xs text-default-500 col-span-1">HSL</span>
<div className="col-span-3 flex gap-1">
<Input
size="sm" variant="flat" type="number"
value={Math.round(hsl.h).toString()}
onChange={(e) => handleHslChange({ ...hsl, h: Number(e.target.value) })}
endContent={<span className="text-xs text-default-400">H</span>}
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
/>
<Input
size="sm" variant="flat" type="number"
value={Math.round(hsl.s).toString()}
onChange={(e) => handleHslChange({ ...hsl, s: Number(e.target.value) })}
endContent={<span className="text-xs text-default-400">S</span>}
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
/>
<Input
size="sm" variant="flat" type="number"
value={Math.round(hsl.l).toString()}
onChange={(e) => handleHslChange({ ...hsl, l: Number(e.target.value) })}
endContent={<span className="text-xs text-default-400">L</span>}
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
/>
</div>
</div>
<div className="flex gap-1 mt-2 flex-wrap justify-between">
{["#006FEE", "#17C964", "#F5A524", "#F31260", "#7828C8", "#000000", "#FFFFFF"].map((c) => (
<button
key={c}
className="w-6 h-6 rounded-full border border-default-200 shadow-sm transition-transform hover:scale-110 active:scale-95"
style={{ backgroundColor: c }}
onClick={() => handleHexChange(c)}
/>
))}
</div>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View File

@@ -4,7 +4,7 @@ import clsx from 'clsx';
import key from '@/const/key'; import key from '@/const/key';
export interface ContainerProps { export interface ContainerProps {
title: string; title: React.ReactNode;
tag?: React.ReactNode; tag?: React.ReactNode;
action: React.ReactNode; action: React.ReactNode;
enableSwitch: React.ReactNode; enableSwitch: React.ReactNode;

View File

@@ -1,5 +1,6 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { Tooltip } from '@heroui/tooltip';
import { useState } from 'react'; import { useState } from 'react';
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io'; import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
@@ -20,7 +21,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
onInstall, onInstall,
installStatus = 'not-installed', installStatus = 'not-installed',
}) => { }) => {
const { name, version, author, description, tags, id } = data; const { name, version, author, description, tags, id, homepage } = data;
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const handleInstall = () => { const handleInstall = () => {
@@ -41,7 +42,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
return { return {
text: '更新', text: '更新',
icon: <IoMdDownload size={16} />, icon: <IoMdDownload size={16} />,
color: 'success' as const, color: 'default' as const,
}; };
default: default:
return { return {
@@ -53,11 +54,31 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
}; };
const buttonConfig = getButtonConfig(); const buttonConfig = getButtonConfig();
const titleContent = homepage ? (
<Tooltip
content="跳转到插件主页"
placement="top"
showArrow
offset={8}
delay={200}
>
<a
href={homepage}
target="_blank"
rel="noreferrer"
className="text-inherit inline-block bg-no-repeat bg-left-bottom [background-image:repeating-linear-gradient(90deg,currentColor_0_2px,transparent_2px_5px)] [background-size:0%_2px] hover:[background-size:100%_2px] transition-[background-size] duration-200 ease-out"
>
{name}
</a>
</Tooltip>
) : (
name
);
return ( return (
<DisplayCardContainer <DisplayCardContainer
className='w-full max-w-[420px]' className='w-full max-w-[420px]'
title={name} title={titleContent}
tag={ tag={
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
{installStatus === 'installed' && ( {installStatus === 'installed' && (

View File

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

View File

@@ -0,0 +1,122 @@
import { Avatar } from '@heroui/avatar';
import { Button } from '@heroui/button';
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@heroui/dropdown';
import { Image } from '@heroui/image';
import { Input } from '@heroui/input';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { IoChevronDown } from 'react-icons/io5';
import type { QQItem } from '@/components/quick_login';
import { isQQQuickNewItem } from '@/utils/qq';
interface PasswordLoginProps {
onSubmit: (uin: string, password: string) => void;
isLoading: boolean;
qqList: (QQItem | LoginListItem)[];
}
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => {
const [uin, setUin] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = () => {
if (!uin) {
toast.error('请输入QQ号');
return;
}
if (!password) {
toast.error('请输入密码');
return;
}
onSubmit(uin, password);
};
return (
<div className='flex flex-col gap-8'>
<div className='flex justify-center'>
<Image
className='shadow-lg'
height={100}
radius='full'
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
width={100}
alt="QQ Avatar"
/>
</div>
<div className='flex flex-col gap-4'>
<Input
type="text"
label="QQ账号"
placeholder="请输入QQ号"
value={uin}
onValueChange={setUin}
variant="bordered"
size='lg'
autoComplete="off"
endContent={
<Dropdown>
<DropdownTrigger>
<Button isIconOnly variant="light" size="sm" radius="full">
<IoChevronDown size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="QQ Login History"
items={qqList}
onAction={(key) => setUin(key.toString())}
>
{(item) => (
<DropdownItem key={item.uin} textValue={item.uin}>
<div className='flex items-center gap-2'>
<Avatar
alt={item.uin}
className='flex-shrink-0'
size='sm'
src={
isQQQuickNewItem(item)
? item.faceUrl
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
}
/>
<div className='flex flex-col'>
{isQQQuickNewItem(item)
? `${item.nickName}(${item.uin})`
: item.uin}
</div>
</div>
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
}
/>
<Input
type="password"
label="密码"
placeholder="请输入密码"
value={password}
onValueChange={setPassword}
variant="bordered"
size='lg'
autoComplete="new-password"
/>
</div>
<div className='flex justify-center mt-5'>
<Button
className='w-64 max-w-full'
color='primary'
isLoading={isLoading}
radius='full'
size='lg'
variant='shadow'
onPress={handleSubmit}
>
</Button>
</div>
</div>
);
};
export default PasswordLogin;

View File

@@ -260,14 +260,14 @@ const NewVersionTip = (props: NewVersionTipProps) => {
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}> <div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
<Chip <Chip
size="sm" size="sm"
color="danger" color="primary"
variant="flat" variant="flat"
classNames={{ classNames={{
content: "font-bold text-[10px] px-1 flex items-center justify-center", content: "font-bold text-[10px] px-1 flex items-center justify-center",
base: "h-5 min-h-5 min-w-[42px]" base: "h-5 min-h-5 min-w-[42px]"
}} }}
> >
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'} {updateStatus === 'updating' ? <Spinner size="sm" color="primary" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
</Chip> </Chip>
</div> </div>
</Tooltip> </Tooltip>

View File

@@ -218,4 +218,11 @@ export default class FileManager {
); );
return data.data; return data.data;
} }
public static async checkWebUIFontExists () {
const { data } = await serverRequest.get<ServerResponse<boolean>>(
'/File/font/exists/webui'
);
return data.data;
}
} }

View File

@@ -140,9 +140,11 @@ export default class PluginManager {
/** /**
* 获取插件商店列表 * 获取插件商店列表
* @param forceRefresh 是否强制刷新(跳过服务端缓存)
*/ */
public static async getPluginStoreList (): Promise<PluginStoreList> { public static async getPluginStoreList (forceRefresh: boolean = false): Promise<PluginStoreList> {
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List'); const params = forceRefresh ? { forceRefresh: 'true' } : {};
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List', { params });
return data.data; return data.data;
} }

View File

@@ -93,4 +93,11 @@ export default class QQManager {
uin, uin,
}); });
} }
public static async passwordLogin (uin: string, passwordMd5: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', {
uin,
passwordMd5,
});
}
} }

View File

@@ -0,0 +1,129 @@
import { Button } from '@heroui/button';
import toast from 'react-hot-toast';
import { LuDownload, LuUpload } from 'react-icons/lu';
import { requestServerWithFetch } from '@/utils/request';
// 导入配置
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 检查文件类型
if (!file.name.endsWith('.zip')) {
toast.error('请选择zip格式的配置文件');
return;
}
try {
const formData = new FormData();
formData.append('configFile', file);
const response = await requestServerWithFetch('/OB11Config/ImportConfig', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '导入配置失败');
}
const result = await response.json();
// 检查是否成功导入
if (result.code === 0) {
toast.success(result.data?.message || '配置导入成功。');
} else {
toast.error(`配置导入失败: ${result.data?.message || '未知错误'}`);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`导入配置失败: ${msg}`);
} finally {
// 重置文件输入
event.target.value = '';
}
};
// 导出配置
const handleExportConfig = async () => {
try {
const response = await requestServerWithFetch('/OB11Config/ExportConfig', {
method: 'GET',
});
if (!response.ok) {
throw new Error('导出配置失败');
}
// 创建下载链接
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const fileName = response.headers.get('Content-Disposition')?.split('=')[1]?.replace(/"/g, '') || 'config_backup.zip';
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('配置导出成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`导出配置失败: ${msg}`);
}
};
const BackupConfigCard: React.FC = () => {
return (
<div className='space-y-6'>
<div>
<h3 className='text-lg font-medium mb-4'></h3>
<p className='text-sm text-default-500 mb-4'>
/NapCat的所有设置
</p>
<div className='flex flex-wrap gap-3'>
<Button
isIconOnly
className="bg-primary hover:bg-primary/90 text-white"
radius='full'
onPress={handleExportConfig}
title="导出配置"
>
<LuDownload size={20} />
</Button>
<label className="cursor-pointer">
<input
type="file"
accept=".zip"
onChange={handleImportConfig}
className="hidden"
/>
<Button
isIconOnly
className="bg-primary hover:bg-primary/90 text-white"
radius='full'
as="span"
title="导入配置"
>
<LuUpload size={20} />
</Button>
</label>
</div>
<div className='mt-4 p-3 bg-warning/10 border border-warning/20 rounded-lg'>
<div className='flex items-start gap-2'>
<p className='text-sm text-warning'>
</p>
</div>
</div>
</div>
</div>
);
};
export default BackupConfigCard;

View File

@@ -13,6 +13,7 @@ import ServerConfigCard from './server';
import SSLConfigCard from './ssl'; import SSLConfigCard from './ssl';
import ThemeConfigCard from './theme'; import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui'; import WebUIConfigCard from './webui';
import BackupConfigCard from './backup';
export interface ConfigPageProps { export interface ConfigPageProps {
children?: React.ReactNode; children?: React.ReactNode;
@@ -108,6 +109,11 @@ export default function ConfigPage () {
<ThemeConfigCard /> <ThemeConfigCard />
</ConfigPageItem> </ConfigPageItem>
</Tab> </Tab>
<Tab title='备份与恢复' key='backup'>
<ConfigPageItem>
<BackupConfigCard />
</ConfigPageItem>
</Tab>
</Tabs> </Tabs>
</section> </section>
); );

View File

@@ -1,8 +1,8 @@
import { Accordion, AccordionItem } from '@heroui/accordion';
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card';
import { Select, SelectItem } from '@heroui/select'; import { Select, SelectItem } from '@heroui/select';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { Tab, Tabs } from '@heroui/tabs';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
@@ -162,6 +162,7 @@ const ThemeConfigCard = () => {
const [dataLoaded, setDataLoaded] = useState(false); const [dataLoaded, setDataLoaded] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [customFontExists, setCustomFontExists] = useState(false);
// 使用 useRef 存储 style 标签引用和状态 // 使用 useRef 存储 style 标签引用和状态
const styleTagRef = useRef<HTMLStyleElement | null>(null); const styleTagRef = useRef<HTMLStyleElement | null>(null);
@@ -213,6 +214,10 @@ const ThemeConfigCard = () => {
} }
setDataLoaded(true); setDataLoaded(true);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
// 检查自定义字体是否存在
FileManager.checkWebUIFontExists().then(exists => {
setCustomFontExists(exists);
}).catch(err => console.error('Failed to check custom font:', err));
}, [data, setOnebotValue]); }, [data, setOnebotValue]);
// 实时应用字体预设(预览) // 实时应用字体预设(预览)
@@ -293,138 +298,180 @@ const ThemeConfigCard = () => {
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
{/* 顶部操作栏 */} {/* 顶部操作栏 */}
<div className='sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider'> <div className='w-full px-4 pt-4 pb-2'>
<div className='flex items-center justify-between p-4'> <div className='flex items-center justify-between'>
<div className='flex items-center gap-3 flex-wrap'> <div className='flex flex-col gap-1'>
<div className='flex items-center gap-2 text-sm'> <h1 className='text-xl font-bold text-default-900 tracking-tight'></h1>
<span className='text-default-400'>:</span> <div className='flex items-center gap-3 text-tiny text-default-500'>
<Chip size='sm' color='primary' variant='flat'> <div className='flex items-center gap-1.5'>
{savedThemeName || '加载中...'} <IoIosColorPalette className='text-primary' size={16} />
</Chip> <span className='font-medium text-default-700'>{savedThemeName || '加载中...'}</span>
</div>
<div className='w-px h-2.5 bg-default-300' />
<div className='flex items-center gap-1.5'>
<FaFont className='text-secondary' size={12} />
<span className='font-medium text-default-700'>{savedFontModeDisplayName}</span>
</div>
{hasUnsavedChanges && (
<>
<div className='w-px h-2.5 bg-default-300' />
<div className='flex items-center gap-1'>
<div className='w-1.5 h-1.5 rounded-full bg-warning animate-pulse' />
<span className='text-warning font-semibold'></span>
</div>
</>
)}
</div> </div>
<div className='flex items-center gap-2 text-sm'>
<span className='text-default-400'>:</span>
<Chip size='sm' color='secondary' variant='flat'>
{savedFontModeDisplayName}
</Chip>
</div>
{hasUnsavedChanges && (
<Chip size='sm' color='warning' variant='solid'>
</Chip>
)}
</div> </div>
<div className='flex items-center gap-2'>
<div className='flex items-center gap-3'>
<Button <Button
size='sm' size='sm'
radius='full'
variant='flat' variant='flat'
className='font-medium bg-default-100 text-default-600 dark:bg-default-50/50' color='default'
className='font-medium bg-default-100 hover:bg-default-200 h-9'
onPress={() => { onPress={() => {
reset(); reset();
toast.success('已重置'); toast.success('已重置');
}} }}
isDisabled={!hasUnsavedChanges} isDisabled={!hasUnsavedChanges}
> >
</Button> </Button>
<Button <Button
size='sm' size='sm'
color='primary' color='primary'
radius='full' className='font-medium shadow-lg shadow-primary/20 px-6 h-9'
className='font-medium shadow-md shadow-primary/20'
isLoading={isSubmitting} isLoading={isSubmitting}
onPress={() => onSubmit()} onPress={() => onSubmit()}
isDisabled={!hasUnsavedChanges} isDisabled={!hasUnsavedChanges}
> >
</Button> </Button>
<div className='w-px h-6 bg-divider mx-1 hidden sm:block'></div>
<Button <Button
size='sm' size='sm'
isIconOnly isIconOnly
radius='full' variant='light'
variant='flat' className='text-default-500 hover:text-default-900 hidden sm:flex'
className='text-default-500 bg-default-100 dark:bg-default-50/50'
onPress={onRefresh} onPress={onRefresh}
> >
<IoMdRefresh size={18} /> <IoMdRefresh size={20} />
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<div className='p-4'> <div className='px-4 pt-0 pb-4 w-full h-full'>
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}> <Tabs
<AccordionItem aria-label="Theme Config Options"
key='font' color="primary"
aria-label='Font Settings' variant="underlined"
title='字体设置' disableAnimation
subtitle='自定义WebUI显示的字体' classNames={{
className='shadow-small' tabList: "gap-8 w-full relative rounded-none p-0 border-b border-divider overflow-x-auto no-scrollbar",
startContent={<FaFont />} cursor: "w-full bg-primary h-[3px] -bottom-[1.5px]",
> tab: "max-w-fit px-0 h-12 hover:opacity-100 opacity-70 data-[selected=true]:opacity-100",
<div className='flex flex-col gap-4'> tabContent: "font-semibold py-2",
<Controller panel: "py-4"
control={control} }}
name='theme.fontMode' >
render={({ field }) => ( <Tab
<Select key="font"
label='字体预设' title={
selectedKeys={field.value ? [field.value] : ['aacute']} <div className="flex items-center space-x-2">
onChange={(e) => field.onChange(e.target.value)} <FaFont />
className='max-w-xs' <span></span>
disallowEmptySelection
>
<SelectItem key='aacute'>Aa </SelectItem>
<SelectItem key='system'></SelectItem>
<SelectItem key='custom'></SelectItem>
</Select>
)}
/>
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'>
<div className='text-sm text-default-500 mb-2'>
"自定义字体"
</div>
<FileInput
label='上传字体文件'
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => {
try {
await FileManager.uploadWebUIFont(file);
toast.success('上传成功,即将刷新页面');
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error('上传失败: ' + (error as Error).message);
}
}}
onDelete={async () => {
try {
await FileManager.deleteWebUIFont();
toast.success('删除成功,即将刷新页面');
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error('删除失败: ' + (error as Error).message);
}
}}
/>
</div> </div>
</div> }
</AccordionItem>
<AccordionItem
key='select'
aria-label='Pick Color'
title='选择主题'
subtitle='点击主题卡片即可预览,记得保存'
className='shadow-small'
startContent={<IoIosColorPalette />}
> >
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'> <Card className='shadow-sm border border-default-100 bg-background/60 backdrop-blur-md w-full'>
<CardBody className='p-6'>
<div className='flex flex-col gap-6 w-full'>
<div>
<h3 className='text-lg font-medium mb-1'>WebUI </h3>
<p className='text-sm text-default-500 mb-4'></p>
<Controller
control={control}
name='theme.fontMode'
render={({ field }) => (
<Select
label='选择字体'
variant='bordered'
selectedKeys={field.value ? [field.value] : ['aacute']}
onChange={(e) => field.onChange(e.target.value)}
className='max-w-xs'
disallowEmptySelection
>
<SelectItem key='aacute'>Aa </SelectItem>
<SelectItem key='system'></SelectItem>
<SelectItem key='custom'></SelectItem>
</Select>
)}
/>
</div>
{theme.fontMode === 'custom' && (
<div className='p-4 rounded-xl bg-default-50 border border-default-100'>
<div className='flex items-center justify-between mb-4'>
<div className='text-sm font-medium'></div>
{customFontExists && (
<Chip size='sm' color='success' variant='flat' startContent={<FaCheck size={10} />}>
</Chip>
)}
</div>
<FileInput
label='上传字体文件'
placeholder='拖拽或点击上传 (.woff/.woff2/.ttf/.otf)'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => {
try {
if (customFontExists) {
try {
await FileManager.deleteWebUIFont();
} catch (e) {
console.warn('Failed to delete existing font before upload:', e);
}
}
await FileManager.uploadWebUIFont(file);
toast.success('上传成功,即将刷新页面');
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
toast.error('上传失败: ' + (error as Error).message);
}
}}
onDelete={async () => {
try {
await FileManager.deleteWebUIFont();
toast.success('删除成功,即将刷新页面');
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
toast.error('删除失败: ' + (error as Error).message);
}
}}
/>
<p className='text-xs text-default-400 mt-2'>
</p>
</div>
)}
</div>
</CardBody>
</Card>
</Tab>
<Tab
key="theme"
title={
<div className="flex items-center space-x-2">
<IoIosColorPalette size={18} />
<span></span>
</div>
}
>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4'>
{themes.map((t) => ( {themes.map((t) => (
<PreviewThemeCard <PreviewThemeCard
key={t.name} key={t.name}
@@ -436,64 +483,77 @@ const ThemeConfigCard = () => {
/> />
))} ))}
</div> </div>
</AccordionItem> </Tab>
<AccordionItem <Tab
key='pick' key="custom-color"
aria-label='Pick Color' title={
title='自定义配色' <div className="flex items-center space-x-2">
subtitle='精细调整每个颜色变量' <FaPaintbrush />
className='shadow-small' <span></span>
startContent={<FaPaintbrush />} </div>
}
> >
<div className='space-y-4'> <div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
{(['light', 'dark'] as const).map((mode) => ( {(['light', 'dark'] as const).map((mode) => (
<div <Card key={mode} className={clsx('border shadow-sm', mode === 'dark' ? 'bg-[#18181b] border-zinc-800' : 'bg-white border-zinc-200')}>
key={mode} <CardHeader className='pb-0 pt-4 px-4 flex-col items-start'>
className={clsx( <div className='flex items-center gap-2 mb-1'>
'p-4 rounded-lg', {mode === 'dark' ? <MdDarkMode className="text-zinc-400" size={20} /> : <MdLightMode className="text-orange-400" size={20} />}
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black' <h4 className={clsx('font-bold text-large', mode === 'dark' ? 'text-white' : 'text-black')}>
)} {mode === 'dark' ? '深色模式' : '浅色模式'}
> </h4>
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'> </div>
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />} <p className={clsx('text-tiny', mode === 'dark' ? 'text-zinc-400' : 'text-zinc-500')}>
{mode === 'dark' ? '深色模式' : '浅色模式'} {mode === 'dark' ? '深色' : '浅色'}
</h3> </p>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'> </CardHeader>
{colorKeys.map((colorKey) => ( <CardBody className='p-4'>
<div <div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
key={colorKey} {colorKeys.map((colorKey) => (
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5' <div
> key={colorKey}
<Controller className={clsx(
control={control} 'flex items-center gap-3 p-2 rounded-lg border transition-colors',
name={`theme.${mode}.${colorKey}`} mode === 'dark' ? 'bg-zinc-900/50 border-zinc-800 hover:bg-zinc-900' : 'bg-zinc-50 border-zinc-100 hover:bg-zinc-100'
render={({ field: { value, onChange } }) => { )}
const hslArray = value?.split(' ') ?? [0, 0, 0]; >
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`; <Controller
return ( control={control}
<ColorPicker name={`theme.${mode}.${colorKey}`}
color={color} render={({ field: { value, onChange } }) => {
onChange={(result) => { const hslArray = value?.split(' ') ?? [0, 0, 0];
onChange( const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%` return (
); <ColorPicker
}} color={color}
/> onChange={(hslString) => {
); const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
}} if (match) {
/> onChange(`${match[1]} ${match[2]}% ${match[3]}%`);
<span className='text-xs font-mono truncate flex-1' title={colorKey}> }
{colorKey.replace('--heroui-', '')} }}
</span> />
</div> );
))} }}
</div> />
</div> <div className='flex flex-col overflow-hidden'>
<span className={clsx('text-xs font-medium truncate', mode === 'dark' ? 'text-zinc-300' : 'text-zinc-700')}>
{colorKey.replace('--heroui-', '')}
</span>
<span className={clsx('text-[10px] truncate', mode === 'dark' ? 'text-zinc-500' : 'text-zinc-400')}>
Variable
</span>
</div>
</div>
))}
</div>
</CardBody>
</Card>
))} ))}
</div> </div>
</AccordionItem> </Tab>
</Accordion> </Tabs>
</div> </div>
</> </>
); );

View File

@@ -58,18 +58,17 @@ export default function ExtensionPage () {
pluginName: page.pluginName, pluginName: page.pluginName,
path: page.path, path: page.path,
icon: page.icon, icon: page.icon,
description: page.description description: page.description,
})); }));
}, [extensionPages]); }, [extensionPages]);
// 获取当前选中页面的 iframe URL // 获取当前选中页面的 iframe URL
// 新路由格式不需要鉴权: /plugin/:pluginId/page/:pagePath
const currentPageUrl = useMemo(() => { const currentPageUrl = useMemo(() => {
if (!selectedTab) return ''; if (!selectedTab) return '';
const [pluginId, ...pathParts] = selectedTab.split(':'); const [pluginId, ...pathParts] = selectedTab.split(':');
const path = pathParts.join(':').replace(/^\//, ''); const path = pathParts.join(':').replace(/^\//, '');
// 获取认证 token return `/plugin/${pluginId}/page/${path}`;
const token = localStorage.getItem('token') || '';
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${encodeURIComponent(token)}`;
}, [selectedTab]); }, [selectedTab]);
useEffect(() => { useEffect(() => {
@@ -86,74 +85,91 @@ export default function ExtensionPage () {
setIframeLoading(false); setIframeLoading(false);
}; };
// 在新窗口打开页面(新路由不需要鉴权)
const openInNewWindow = (pluginId: string, path: string) => {
const cleanPath = path.replace(/^\//, '');
const url = `/plugin/${pluginId}/page/${cleanPath}`;
window.open(url, '_blank');
};
return ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative h-full flex flex-col"> <div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
<PageLoading loading={loading} /> <PageLoading loading={loading} />
<div className="flex mb-4 items-center gap-4"> <div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
<div className="flex items-center gap-2 text-default-600"> <div className='flex items-center gap-4'>
<MdExtension size={24} /> <div className='flex items-center gap-2 text-default-600'>
<span className="text-lg font-medium"></span> <MdExtension size={24} />
<span className='text-lg font-medium'></span>
</div>
<Button
isIconOnly
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
radius='full'
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
</div> </div>
<Button {extensionPages.length > 0 && (
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
</div>
{extensionPages.length === 0 && !loading ? (
<div className="flex-1 flex flex-col items-center justify-center text-default-400">
<MdExtension size={64} className="mb-4 opacity-50" />
<p className="text-lg"></p>
<p className="text-sm mt-2"> WebUI </p>
</div>
) : (
<div className="flex-1 flex flex-col min-h-0">
<Tabs <Tabs
aria-label="Extension Pages" aria-label='Extension Pages'
className="max-w-full" className='max-w-full'
selectedKey={selectedTab} selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)} onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{ classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-wrap', tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm', cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'flex-1 min-h-0 p-0' panel: 'hidden',
}} }}
> >
{tabs.map((tab) => ( {tabs.map((tab) => (
<Tab <Tab
key={tab.key} key={tab.key}
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
{tab.icon && <span>{tab.icon}</span>} {tab.icon && <span>{tab.icon}</span>}
<span>{tab.title}</span> <span
<span className="text-xs text-default-400">({tab.pluginName})</span> className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(tab.pluginId, tab.path);
}}
>
{tab.title}
</span>
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
</div> </div>
} }
> />
<div className="relative w-full h-[calc(100vh-220px)] bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden">
{iframeLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-default-100/50 z-10">
<Spinner size="lg" />
</div>
)}
<iframe
src={currentPageUrl}
className="w-full h-full border-0"
onLoad={handleIframeLoad}
title={tab.title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
</Tab>
))} ))}
</Tabs> </Tabs>
)}
</div>
{extensionPages.length === 0 && !loading ? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
) : (
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title='extension-page'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -9,6 +9,7 @@ import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io'; import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import clsx from 'clsx'; import clsx from 'clsx';
import { EventSourcePolyfill } from 'event-source-polyfill'; import { EventSourcePolyfill } from 'event-source-polyfill';
import { useLocalStorage } from '@uidotdev/usehooks';
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card'; import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
import PluginManager, { PluginItem } from '@/controllers/plugin_manager'; import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
@@ -51,10 +52,10 @@ export default function PluginStorePage () {
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null); const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined); const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
const loadPlugins = async () => { const loadPlugins = async (forceRefresh: boolean = false) => {
setLoading(true); setLoading(true);
try { try {
const data = await PluginManager.getPluginStoreList(); const data = await PluginManager.getPluginStoreList(forceRefresh);
setPlugins(data.plugins); setPlugins(data.plugins);
// 检查插件管理器是否已加载 // 检查插件管理器是否已加载
@@ -226,68 +227,70 @@ export default function PluginStorePage () {
} }
}; };
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative"> <div className="p-2 md:p-4 relative">
{/* 头部 */} {/* 固定头部区域 */}
<div className="flex mb-6 items-center justify-between flex-wrap gap-4"> <div className={clsx(
<div className="flex items-center gap-4"> 'sticky top-14 z-10 backdrop-blur-sm py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
<h1 className="text-2xl font-bold"></h1> hasBackground
<Button ? 'bg-white/20 dark:bg-black/10'
isIconOnly : 'bg-transparent'
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md" )}>
radius="full" {/* 头部 */}
onPress={loadPlugins} <div className="flex mb-4 items-center justify-between flex-wrap gap-4">
isLoading={loading} <div className="flex items-center gap-4">
> <h1 className="text-2xl font-bold"></h1>
<IoMdRefresh size={24} /> <Button
</Button> isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={() => loadPlugins(true)}
isLoading={loading}
>
<IoMdRefresh size={24} />
</Button>
</div>
{/* 商店列表源卡片 */}
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
</div>
<Tooltip content="切换列表源">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</CardBody>
</Card>
</div> </div>
{/* 商店列表源卡片 */} {/* 搜索框 */}
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm"> <div className="mb-4">
<CardBody className="py-2 px-3"> <Input
<div className="flex items-center gap-3"> placeholder="搜索插件名称、描述、作者或标签..."
<div className="flex items-center gap-2"> startContent={<IoMdSearch className="text-default-400" />}
<span className="text-xs text-default-500">:</span> value={searchQuery}
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span> onValueChange={setSearchQuery}
</div> className="max-w-md"
<Tooltip content="切换列表源"> />
<Button </div>
isIconOnly
size="sm"
variant="light"
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</CardBody>
</Card>
</div>
{/* 搜索框 */}
<div className="mb-6">
<Input
placeholder="搜索插件名称、描述、作者或标签..."
startContent={<IoMdSearch className="text-default-400" />}
value={searchQuery}
onValueChange={setSearchQuery}
className="max-w-md"
/>
</div>
{/* 标签页 */}
<div className="relative">
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
<Spinner size='lg' />
</div>
)}
{/* 标签页导航 */}
<Tabs <Tabs
aria-label="Plugin Store Categories" aria-label="Plugin Store Categories"
className="max-w-full" className="max-w-full"
@@ -296,32 +299,43 @@ export default function PluginStorePage () {
classNames={{ classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md', tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm', cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'hidden',
}} }}
> >
{tabs.map((tab) => ( {tabs.map((tab) => (
<Tab <Tab
key={tab.key} key={tab.key}
title={`${tab.title} (${tab.count})`} title={`${tab.title} (${tab.count})`}
> />
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[tab.key]?.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
/>
);
})}
</div>
</Tab>
))} ))}
</Tabs> </Tabs>
</div> </div>
{/* 插件列表区域 */}
<div className="relative">
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
<Spinner size='lg' />
</div>
)}
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[activeTab]?.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
/>
);
})}
</div>
</div>
</div> </div>
{/* 商店列表源选择弹窗 */} {/* 商店列表源选择弹窗 */}

View File

@@ -5,11 +5,13 @@ import { Tab, Tabs } from '@heroui/tabs';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import CryptoJS from 'crypto-js';
import logo from '@/assets/images/logo.png'; import logo from '@/assets/images/logo.png';
import HoverEffectCard from '@/components/effect_card'; import HoverEffectCard from '@/components/effect_card';
import { title } from '@/components/primitives'; import { title } from '@/components/primitives';
import PasswordLogin from '@/components/password_login';
import QrCodeLogin from '@/components/qr_code_login'; import QrCodeLogin from '@/components/qr_code_login';
import QuickLogin from '@/components/quick_login'; import QuickLogin from '@/components/quick_login';
import type { QQItem } from '@/components/quick_login'; import type { QQItem } from '@/components/quick_login';
@@ -51,6 +53,7 @@ export default function QQLoginPage () {
const lastErrorRef = useRef<string>(''); const lastErrorRef = useRef<string>('');
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]); const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
const [refresh, setRefresh] = useState<boolean>(false); const [refresh, setRefresh] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<string>('shortcut');
const firstLoad = useRef<boolean>(true); const firstLoad = useRef<boolean>(true);
const onSubmit = async () => { const onSubmit = async () => {
if (!uinValue) { if (!uinValue) {
@@ -72,6 +75,21 @@ export default function QQLoginPage () {
} }
}; };
const onPasswordSubmit = async (uin: string, password: string) => {
setIsLoading(true);
try {
// 计算密码的MD5值
const passwordMd5 = CryptoJS.MD5(password).toString();
await QQManager.passwordLogin(uin, passwordMd5);
toast.success('密码登录请求已发送');
} catch (error) {
const msg = (error as Error).message;
toast.error(`密码登录失败: ${msg}`);
} finally {
setIsLoading(false);
}
};
const onUpdateQrCode = async () => { const onUpdateQrCode = async () => {
if (firstLoad.current) setIsLoading(true); if (firstLoad.current) setIsLoading(true);
try { try {
@@ -91,11 +109,17 @@ export default function QQLoginPage () {
setLoginError(data.loginError); setLoginError(data.loginError);
const friendlyMsg = parseLoginError(data.loginError); const friendlyMsg = parseLoginError(data.loginError);
dialog.alert({ // 仅在扫码登录 Tab 下才弹窗,或者错误不是"二维码已过期"
title: '登录失败', // 如果是 "二维码已过期",且不在 qrcode tab则不弹窗
content: friendlyMsg, const isQrCodeExpired = friendlyMsg.includes('二维码') && (friendlyMsg.includes('过期') || friendlyMsg.includes('失效'));
confirmText: '确定',
}); if (!isQrCodeExpired || activeTab === 'qrcode') {
dialog.alert({
title: '登录失败',
content: friendlyMsg,
confirmText: '确定',
});
}
} else if (!data.loginError) { } else if (!data.loginError) {
lastErrorRef.current = ''; lastErrorRef.current = '';
setLoginError(''); setLoginError('');
@@ -197,6 +221,8 @@ export default function QQLoginPage () {
}} }}
isDisabled={isLoading} isDisabled={isLoading}
size='lg' size='lg'
selectedKey={activeTab}
onSelectionChange={(key) => key !== null && setActiveTab(key.toString())}
> >
<Tab key='shortcut' title='快速登录'> <Tab key='shortcut' title='快速登录'>
<QuickLogin <QuickLogin
@@ -209,6 +235,13 @@ export default function QQLoginPage () {
onUpdateQQList={onUpdateQQList} onUpdateQQList={onUpdateQQList}
/> />
</Tab> </Tab>
<Tab key='password' title='密码登录'>
<PasswordLogin
isLoading={isLoading}
onSubmit={onPasswordSubmit}
qqList={qqList}
/>
</Tab>
<Tab key='qrcode' title='扫码登录'> <Tab key='qrcode' title='扫码登录'>
<QrCodeLogin <QrCodeLogin
loginError={parseLoginError(loginError)} loginError={parseLoginError(loginError)}

View File

@@ -180,11 +180,14 @@ export const applyFont = (mode: string) => {
if (mode === 'aacute') { if (mode === 'aacute') {
root.style.setProperty('--font-family-base', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important'); root.style.setProperty('--font-family-base', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
root.style.setProperty('--font-family-mono', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
} else if (mode === 'custom') { } else if (mode === 'custom') {
root.style.setProperty('--font-family-base', "'CustomFont', var(--font-family-fallbacks)", 'important'); root.style.setProperty('--font-family-base', "'CustomFont', var(--font-family-fallbacks)", 'important');
root.style.setProperty('--font-family-mono', "'CustomFont', var(--font-family-fallbacks)", 'important');
} else { } else {
// system or default - restore default // system or default - restore default
root.style.setProperty('--font-family-base', 'var(--font-family-fallbacks)', 'important'); root.style.setProperty('--font-family-base', 'var(--font-family-fallbacks)', 'important');
root.style.setProperty('--font-family-mono', 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 'important');
} }
}; };

View File

@@ -30,6 +30,7 @@ export default defineConfig(({ mode }) => {
}, },
'/api': backendDebugUrl, '/api': backendDebugUrl,
'/files': backendDebugUrl, '/files': backendDebugUrl,
'/plugin': backendDebugUrl,
'/webui/fonts/CustomFont.woff': backendDebugUrl, '/webui/fonts/CustomFont.woff': backendDebugUrl,
'/webui/sw.js': backendDebugUrl, '/webui/sw.js': backendDebugUrl,
}, },

70
pnpm-lock.yaml generated
View File

@@ -232,8 +232,8 @@ importers:
packages/napcat-plugin-builtin: packages/napcat-plugin-builtin:
dependencies: dependencies:
napcat-types: napcat-types:
specifier: 0.0.14 specifier: 0.0.16
version: 0.0.14 version: 0.0.16
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.0.1 specifier: ^22.0.1
@@ -302,6 +302,12 @@ importers:
specifier: ^22.0.1 specifier: ^22.0.1
version: 22.19.1 version: 22.19.1
packages/napcat-rpc:
devDependencies:
'@types/node':
specifier: ^22.0.1
version: 22.19.1
packages/napcat-schema: packages/napcat-schema:
dependencies: dependencies:
'@sinclair/typebox': '@sinclair/typebox':
@@ -354,6 +360,12 @@ importers:
napcat-core: napcat-core:
specifier: workspace:* specifier: workspace:*
version: link:../napcat-core version: link:../napcat-core
napcat-image-size:
specifier: workspace:*
version: link:../napcat-image-size
napcat-rpc:
specifier: workspace:*
version: link:../napcat-rpc
devDependencies: devDependencies:
vitest: vitest:
specifier: ^4.0.9 specifier: ^4.0.9
@@ -634,9 +646,6 @@ importers:
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.0 version: 19.2.0
react-color:
specifier: ^2.19.3
version: 2.19.3(react@19.2.0)
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.0(react@19.2.0) version: 19.2.0(react@19.2.0)
@@ -1859,11 +1868,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@icons/material@0.2.4':
resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==}
peerDependencies:
react: '*'
'@img/colour@1.0.0': '@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5154,9 +5158,6 @@ packages:
markdown-table@3.0.4: markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5456,8 +5457,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
napcat-types@0.0.14: napcat-types@0.0.16:
resolution: {integrity: sha512-q5ke+vzzXeZkYPsr9jmj94NxgH63/xv5yS/lPEU++A3x2mOM8SYJqdFEMbHG1QIFciyH1u3qnnNiJ0mBxOBFbA==} resolution: {integrity: sha512-y3qhpdd16ATsMp4Jf88XwisFBVKqY+XSfvGX1YqMEasVFTNXeKr1MZrIzhHMkllW1QJZXAI8iNGVJO1gkHEtLQ==}
napcat.protobuf@1.1.4: napcat.protobuf@1.1.4:
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==} resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
@@ -5859,11 +5860,6 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true hasBin: true
react-color@2.19.3:
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
peerDependencies:
react: '*'
react-dom@19.2.0: react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies: peerDependencies:
@@ -5969,11 +5965,6 @@ packages:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
reactcss@1.2.3:
resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
peerDependencies:
react: '*'
read-cache@1.0.0: read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@@ -6488,9 +6479,6 @@ packages:
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
tinyexec@0.3.2: tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
@@ -8382,10 +8370,6 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@humanwhocodes/retry@0.4.3': {}
'@icons/material@0.2.4(react@19.2.0)':
dependencies:
react: 19.2.0
'@img/colour@1.0.0': {} '@img/colour@1.0.0': {}
'@img/sharp-darwin-arm64@0.34.5': '@img/sharp-darwin-arm64@0.34.5':
@@ -12298,8 +12282,6 @@ snapshots:
markdown-table@3.0.4: {} markdown-table@3.0.4: {}
material-colors@1.2.6: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mdast-util-find-and-replace@3.0.2: mdast-util-find-and-replace@3.0.2:
@@ -12801,7 +12783,7 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
napcat-types@0.0.14: napcat-types@0.0.16:
dependencies: dependencies:
'@sinclair/typebox': 0.34.41 '@sinclair/typebox': 0.34.41
'@types/node': 22.19.1 '@types/node': 22.19.1
@@ -13214,17 +13196,6 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
strip-json-comments: 2.0.1 strip-json-comments: 2.0.1
react-color@2.19.3(react@19.2.0):
dependencies:
'@icons/material': 0.2.4(react@19.2.0)
lodash: 4.17.21
lodash-es: 4.17.21
material-colors: 1.2.6
prop-types: 15.8.1
react: 19.2.0
reactcss: 1.2.3(react@19.2.0)
tinycolor2: 1.6.0
react-dom@19.2.0(react@19.2.0): react-dom@19.2.0(react@19.2.0):
dependencies: dependencies:
react: 19.2.0 react: 19.2.0
@@ -13329,11 +13300,6 @@ snapshots:
react@19.2.0: {} react@19.2.0: {}
reactcss@1.2.3(react@19.2.0):
dependencies:
lodash: 4.17.21
react: 19.2.0
read-cache@1.0.0: read-cache@1.0.0:
dependencies: dependencies:
pify: 2.3.0 pify: 2.3.0
@@ -14023,8 +13989,6 @@ snapshots:
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:

View File

@@ -6,9 +6,6 @@
"lib": [ "lib": [
"ES2021" "ES2021"
], ],
"typeRoots": [
"./node_modules/@types"
],
"esModuleInterop": true, "esModuleInterop": true,
"outDir": "dist", "outDir": "dist",
"noEmit": false, "noEmit": false,