Compare commits

...

112 Commits

Author SHA1 Message Date
时瑾
a9e569771c feat: CodeQL不认可 受不了 2025-09-09 00:35:43 +08:00
时瑾
84fa9da762 feat: 增强文件路径处理逻辑,添加安全验证以防止路径遍历攻击,并优化查询参数提取 2025-09-09 00:27:48 +08:00
时瑾
2f9bcd88ba feat: 增强路径处理逻辑,添加安全验证以防止路径遍历攻击 2025-09-08 23:50:17 +08:00
时瑾
8eacaa294f refactor: 优化网络显示卡片按钮样式和行为,调整按钮属性以提升用户体验 2025-09-08 23:40:48 +08:00
时瑾
5a2939f4ec feat: 给文件管理添加WebUI配置文件的脱敏处理和验证逻辑 2025-09-08 23:07:35 +08:00
时瑾
d3013e32e1 feat: 更新密码功能增强,添加新密码强度验证和旧密码检查 2025-09-08 22:35:36 +08:00
时瑾
5499b5fbc9 refactor: 日志路由进行脱敏,生成随机密码使用node:crypto.randomBytes 2025-09-08 22:24:37 +08:00
时瑾
ed0b8408df refactor: 将默认密码相关逻辑重构为后端处理 2025-09-08 22:13:56 +08:00
手瓜一十雪
5e032fcc6a upate: package 2025-09-08 16:16:38 +08:00
Mlikiowa
44200a2208 release: v4.8.109 2025-09-08 08:11:05 +00:00
手瓜一十雪
e39bb05f01 fix: 多层解析 2025-09-08 16:05:10 +08:00
手瓜一十雪
677731dd70 Add qq-chat-exporter to recommended tools
Included qq-chat-exporter, a NapCat-based message export tool, in the list of recommended related projects in the README.
2025-09-07 15:20:17 +08:00
Mlikiowa
fa8e6f2c59 release: v4.8.108 2025-09-07 05:57:27 +00:00
手瓜一十雪
509b23ff04 Update Telegram link in About page
Changed the Telegram href in the About page from MelodicMoonlight to napcatqq to reflect the correct contact or channel.
2025-09-07 13:54:44 +08:00
Mlikiowa
cf1765f5a4 release: v4.8.107 2025-09-07 05:48:15 +00:00
手瓜一十雪
c541c7e257 Update Telegram link in README
Changed the Telegram badge and link from MelodicMoonlight to napcatqq for accuracy.
2025-09-07 13:47:23 +08:00
手瓜一十雪
298b8b71c8 Log WebUI panel URL on server start
Adds logging of the WebUI user panel URL with localhost address when the server starts. Also adjusts QQ login callback invocation in OneBot adapter for improved login flow.
2025-09-07 13:29:37 +08:00
手瓜一十雪
5c120a8231 Add WebUI token update and callback handling
Introduces callback mechanisms for WebUI token changes and QQ login status updates. The WebUI token is now updated and communicated via a callback after login, and related runtime and type definitions are extended to support these features. Also sets a static default token value in the config schema.
2025-09-06 18:15:35 +08:00
手瓜一十雪
88ee8f89fe Comment out documentation links in README
The section containing links to documentation and Telegram has been commented out in the README.md. This may be for temporary removal or future revision.
2025-09-06 13:13:14 +08:00
Mlikiowa
12b8130372 release: v4.8.106 2025-09-06 03:53:33 +00:00
手瓜一十雪
58332dad24 feat: 支持禁用webui 速率配置 禁用外网访问 禁用webui 2025-09-06 11:53:04 +08:00
手瓜一十雪
e97f3e1283 feat: 进一步提高密码安全性 2025-09-06 11:42:12 +08:00
手瓜一十雪
e406dca7ae feat: 禁止默认密码 2025-09-06 11:41:06 +08:00
手瓜一十雪
e4c1807f76 feat: 安全性提升 2025-09-06 11:32:09 +08:00
手瓜一十雪
f4412bb086 feat: 安全性提升 2025-09-06 11:23:09 +08:00
手瓜一十雪
27af8e52ac feat: 安全性提升 2025-09-06 10:49:29 +08:00
手瓜一十雪
4c9a220300 Add README for example plugin
Introduces installation instructions for the example plugin, detailing how to place the build output in the appropriate plugins directory.
2025-09-02 22:54:44 +08:00
手瓜一十雪
1fe822cd20 Update import paths and remove plugin tsconfig
Changed import paths in example-plugin to use alias '@' instead of relative paths. Deleted the example-plugin/tsconfig.json file, likely consolidating TypeScript configuration or relying on a root config.
2025-09-02 22:48:01 +08:00
手瓜一十雪
0ab8d025bf Declare config property in OB11PluginMangerAdapter
Adds an explicit declaration for the 'config' property in the OB11PluginMangerAdapter class to improve type safety and clarity.
2025-09-02 22:42:09 +08:00
Mlikiowa
a0f3d66607 release: v4.8.105 2025-09-02 14:33:33 +00:00
手瓜一十雪
06e7c3363a Add quick_reply support to message parsing
Introduces the quick_reply boolean option to message history schema and message parsing logic. Updates relevant functions to handle quick_reply, allowing for conditional behavior during message reply and segment parsing.
2025-09-02 22:33:05 +08:00
手瓜一十雪
4d200de6b7 Refactor packet client and update message history actions
Replaced LRUCache with Map for callback and event management in packet clients, and standardized callback hash usage. Updated GetFriendMsgHistory and GetGroupMsgHistory actions to use snake_case for payload keys. Modified OneBotMsgApi to support disabling URL retrieval for ptt elements via a new parameter.
2025-09-02 22:24:53 +08:00
手瓜一十雪
6200097f7c Add resource health management and enhance message parsing
Introduces a ResourceManager for health checking and retry logic in src/common/health.ts. Updates OneBot message parsing to support disabling URL fetching and multi-message parsing via new payload options. File, image, video, and ptt URL retrievals now use resource health management for improved reliability. Also refactors packet API to allow configurable timeout for FetchRkey.
#1220
2025-09-02 21:19:49 +08:00
手瓜一十雪
c7af0384fb Add plugin manager and example plugin system
Introduces a plugin manager (OB11PluginMangerAdapter) for dynamic plugin loading, initialization, and event handling. Adds an example plugin with configuration files and updates related code to support plugin directory detection and loading. Refactors plugin adapter logic for extensibility and modularity.
2025-09-02 20:42:54 +08:00
Mlikiowa
dc87615bd6 release: v4.8.104 2025-09-02 08:32:22 +00:00
手瓜一十雪
ff2cfcee97 Add appid and offset entries for new versions
Added appid and qua mappings for versions 3.2.19-39038 and 9.9.21-39038 in appid.json. Updated offset.json with send/recv offsets for these new versions and architectures (x64, arm64).
2025-09-02 16:31:24 +08:00
手瓜一十雪
f3c07ed8fc Add timeout parameter to file and packet API methods
Introduces an optional timeout parameter (defaulting to 20000ms) to various file and packet API methods for improved control over request duration. Updates all relevant method calls and internal usages to support the new timeout argument, including OneBot message API calls with a shorter timeout for file, video, and ptt URL retrieval.
2025-09-02 09:54:42 +08:00
手瓜一十雪
7ab44dcb34 Refactor video thumbnail generation logic
Moved video thumbnail generation to occur before custom thumbnail copying, ensuring fallback to default thumbnail only if video info retrieval fails. Also reordered rkeyManager URLs for consistency.
2025-09-02 09:35:56 +08:00
Mlikiowa
aa6699d06e release: v4.8.103 2025-08-31 13:32:47 +00:00
手瓜一十雪
3cb51a17a6 Add new appid and offset entries for version 38960
Updated appid.json and offset.json to include new entries for versions 3.2.19-38960 and 9.9.21-38960, supporting both x64 and arm64 architectures.
2025-08-31 21:32:15 +08:00
Mlikiowa
994e8ced3e release: v4.8.102 2025-08-26 06:59:25 +00:00
手瓜一十雪
75d26465f1 Add group album media actions and API integration
Introduces new OneBot actions for group album media: listing, commenting, liking, and deleting. Adds supporting API methods and data structures for album media operations in NTQQWebApi and NodeIKernelAlbumService. Updates action router and index to register new actions.
2025-08-26 14:58:11 +08:00
LgCookie
f5052935bd fix: special char of token in webui url should be url encoded (#1209) 2025-08-26 08:37:39 +08:00
Mlikiowa
84b89de2a6 release: v4.8.101 2025-08-25 11:05:45 +00:00
手瓜一十雪
c4f9c4f630 fix 2025-08-25 19:05:13 +08:00
手瓜一十雪
c213cd6c3a Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-08-25 19:04:31 +08:00
手瓜一十雪
9d22d6e3a0 Add NTQQ album list API and update related logic
Introduces getAlbumListByNTQQ to NTQQWebApi for retrieving group album lists via NTQQ. Updates NodeIKernelAlbumService interface for typed getAlbumList parameters and response. Refactors GetQunAlbumList action to use the new NTQQ API and return the correct album list format. Also fixes cookie and bkn usage in album-related methods for consistency.
2025-08-25 19:04:22 +08:00
Mlikiowa
a38419e3cb release: v4.8.100 2025-08-25 07:51:18 +00:00
手瓜一十雪
a64779684e fix #1171 && Improve message recall handling and cleanup
Changed recallMsg to return the result of the event call. Added a 5-second cache cleanup for recall events in DeleteMsg. Removed an unnecessary blank line in plugin.ts.
2025-08-25 15:50:05 +08:00
手瓜一十雪
ecd7012eee Remove redundant upload success log
Eliminated a logger statement that logged successful uploads in the NTQQWebApi class. This reduces unnecessary log output during chunked uploads.
2025-08-25 15:24:30 +08:00
手瓜一十雪
74a1011fcc Refactor Qun album image upload logic
Reworked the group album image upload process to use a new slice-based upload method, replacing the previous chunked upload implementation. Updated related interfaces and removed unused chunk upload code for improved maintainability and clarity.
2025-08-25 14:45:52 +08:00
手瓜一十雪
d4b0a4acca Simplify recall check for self-operated messages 2025-08-25 13:04:22 +08:00
手瓜一十雪
ac6e593315 Add cookie header to getAlbumList API request
The getAlbumList method in NTQQWebApi now includes a 'Cookie' header with skey, pskey, and uin for authentication. Updated GetQunAlbumList action to use the correct return type from NTQQWebApi.getAlbumList.
2025-08-24 20:39:07 +08:00
手瓜一十雪
b1e77b1658 feat: Add group album upload utilities and refactor API && close #1116
Introduces src/core/data/webapi.ts with utilities for chunked group album uploads, including session creation and chunk management. Refactors NTQQWebApi in webapi.ts to use these utilities, adds getAlbumList and uploadImageToQunAlbum methods, and improves upload logic for efficiency and maintainability.
2025-08-24 20:12:35 +08:00
手瓜一十雪
722c3554e9 feat: #1121 & Add cache cleaning for specific directories
Extended the CleanCache action to remove files from Pic, Ptt, Video, File, and log directories under the nt_data path. This improves cache management by ensuring these directories are also cleaned, with logging for successful and failed deletions. 增强缓存清理
2025-08-24 16:02:05 +08:00
手瓜一十雪
1d08966571 feat: #1179 2025-08-24 15:50:08 +08:00
手瓜一十雪
fb50ae7544 chore: Update LiteLoaderWrapper.zip binary && close #1169
Replaces the existing LiteLoaderWrapper.zip file with a new version. Details of the changes within the binary are not shown in this commit.
2025-08-24 15:18:49 +08:00
手瓜一十雪
ea695fc9e9 fix: #1171 2025-08-24 14:53:58 +08:00
Mlikiowa
5c6d1e6a14 release: v4.8.99 2025-08-24 06:02:30 +00:00
手瓜一十雪
b030c40853 feat: 38711 2025-08-24 14:02:03 +08:00
子寻
d6782c35e2 fix: /get_msg interface returns group type message with group_name. (#1205) 2025-08-23 11:38:24 +08:00
Mlikiowa
120e6db119 release: v4.8.98 2025-08-17 15:35:05 +00:00
手瓜一十雪
fa10f8ce19 Fix typo in getFullQQVersion method call
Corrects the method name from getFullQQVesion to getFullQQVersion in the WebUiDataRuntime.setQQVersion call to ensure proper retrieval of QQ version information.
2025-08-17 23:34:41 +08:00
Mlikiowa
31494b4687 release: v4.8.97 2025-08-17 14:59:12 +00:00
手瓜一十雪
857ed0f343 fix: error 2025-08-17 22:58:30 +08:00
837951602
8133ff08a7 fix: offset.json (#1193)
fix win add lin x64
2025-08-17 17:31:24 +08:00
Mlikiowa
2d315c4d8e release: v4.8.96 2025-08-15 11:17:15 +00:00
手瓜一十雪
505f7b6ac9 Update group.ts 2025-08-15 19:12:13 +08:00
手瓜一十雪
2735eb14bd fix: #1191 2025-08-15 19:12:04 +08:00
dependabot[bot]
7afbc95eda build(deps-dev): bump vite from 6.3.5 to 7.1.1 (#1183)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 7.1.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 19:09:03 +08:00
dependabot[bot]
91bb83d8c1 build(deps-dev): bump @typescript-eslint/parser from 8.38.0 to 8.39.0 (#1182)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.38.0 to 8.39.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.39.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.39.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 19:08:57 +08:00
dependabot[bot]
55550790e4 build(deps-dev): bump @eslint/js from 9.32.0 to 9.33.0 (#1185)
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.32.0 to 9.33.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.33.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.33.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 19:08:40 +08:00
dependabot[bot]
40221926a9 build(deps-dev): bump @sinclair/typebox from 0.34.35 to 0.34.38 (#1187)
Bumps [@sinclair/typebox](https://github.com/sinclairzx81/typebox) from 0.34.35 to 0.34.38.
- [Commits](https://github.com/sinclairzx81/typebox/compare/0.34.35...0.34.38)

---
updated-dependencies:
- dependency-name: "@sinclair/typebox"
  dependency-version: 0.34.38
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 19:08:30 +08:00
手瓜一十雪
d069374a95 Add explicit type annotations to AST traversal paths
Updated the performance monitor Vite plugin to add explicit type annotations to AST traversal callback parameters, improving type safety and clarity. Also removed a duplicate import in src/plugin/index.ts.
2025-08-15 19:02:53 +08:00
手瓜一十雪
1183fe2057 Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-08-15 19:00:57 +08:00
手瓜一十雪
f4605d4f74 Add appid and offset entries for new versions
Added appid and qua for versions 9.9.21-38503 and 3.2.19-38503 in appid.json. Added send and recv offsets for 9.9.21-38503 in offset.json to support new application versions.
2025-08-15 18:57:43 +08:00
dependabot[bot]
3ce3fb685b build(deps-dev): bump @eslint/js from 9.31.0 to 9.32.0 (#1160)
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.31.0 to 9.32.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.32.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.32.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 20:39:28 +08:00
dependabot[bot]
07c2f7371f build(deps-dev): bump esbuild from 0.25.5 to 0.25.8 (#1159)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.25.5 to 0.25.8.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.5...v0.25.8)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.25.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 20:39:14 +08:00
囧囧JOJO
114aae98a9 feat: 添加可选参数 'count' 到 SetGroupAddRequest 以支持动态获取通知数量(#1113 补充) (#1146)
* feat: 添加可选参数 'count' 到 SetGroupAddRequest 以支持动态获取通知数量(#1113 补充)

* feat: 设置 SetGroupAddRequest 中 'count' 的默认值为 100

* Update src/onebot/action/group/SetGroupAddRequest.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-06 20:38:57 +08:00
dependabot[bot]
0bd6548f45 build(deps): bump ws from 8.18.2 to 8.18.3 (#1157)
Bumps [ws](https://github.com/websockets/ws) from 8.18.2 to 8.18.3.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.18.2...8.18.3)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.18.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 20:38:41 +08:00
dependabot[bot]
06c5b7807b build(deps-dev): bump @typescript-eslint/parser from 8.37.0 to 8.38.0 (#1158)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.37.0 to 8.38.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.38.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 20:38:15 +08:00
手瓜一十雪
e8ef08cae2 Pr enhance (#1176)
* fix

* Refactor PacketApi status checks and fix typos

Replaced all usages of PacketApi.available with PacketApi.packetStatus for more accurate status checking. Fixed a typo in getFullQQVesion to getFullQQVersion. Updated plugin_onmessage to fetch and parse messages for a user. These changes improve reliability and consistency in API status handling.

* Fix typo in getFullQQVersion method name

Corrected the method name from getFullQQVesion to getFullQQVersion in multiple locations to ensure consistency and prevent potential runtime errors.

* Remove performance CLI and demo, fix typos, update proto

Deleted the performance CLI and demo files. Fixed a typo in getFullQQVesion to getFullQQVersion across multiple files. Changed the 'time' field type from UINT32 to UINT64 in Oidb.0x9067_202 proto. Commented out performanceMonitorPlugin in vite.config.ts. Removed an unimplemented log statement in NodeIKernelBuddyListener.

* Comment out default plugin adapter registration

The default registration of OB11PluginAdapter in NapCatOneBot11Adapter was commented out, likely to prevent automatic plugin loading or to allow for more flexible plugin management. Also, removed an unnecessary blank line in the plugin_onmessage function.

* fix

* Add shell-analysis mode with performance monitoring

Introduces a new .env.shell-analysis file and a dev:shell-analysis npm script for building in shell-analysis mode. Updates vite.config.ts to support the new mode, enabling the performance monitor plugin with an updated exclude list. Also extends the plugin's exclude patterns to filter out 'packet' files.

* Delete performance-api.ts

* Add commented export for performance-monitor

Added a commented-out export statement for '@/common/performance-monitor' in napcat.ts, possibly for future use or reference. No functional changes to the file.
2025-08-06 20:37:45 +08:00
手瓜一十雪
0d251a9343 Remove performance CLI and demo, fix typos, update proto
Deleted the performance CLI and demo files. Fixed a typo in getFullQQVesion to getFullQQVersion across multiple files. Changed the 'time' field type from UINT32 to UINT64 in Oidb.0x9067_202 proto. Commented out performanceMonitorPlugin in vite.config.ts. Removed an unimplemented log statement in NodeIKernelBuddyListener.
2025-08-06 19:57:36 +08:00
手瓜一十雪
69a19b0e32 Fix typo in getFullQQVersion method name
Corrected the method name from getFullQQVesion to getFullQQVersion in multiple locations to ensure consistency and prevent potential runtime errors.
2025-08-06 19:32:14 +08:00
手瓜一十雪
1b0b5f3494 Refactor PacketApi status checks and fix typos
Replaced all usages of PacketApi.available with PacketApi.packetStatus for more accurate status checking. Fixed a typo in getFullQQVesion to getFullQQVersion. Updated plugin_onmessage to fetch and parse messages for a user. These changes improve reliability and consistency in API status handling.
2025-08-06 19:31:24 +08:00
手瓜一十雪
5abdc8c538 fix 2025-08-06 18:43:44 +08:00
Mlikiowa
9f0ba6d385 release: v4.8.95 2025-07-26 12:19:32 +00:00
手瓜一十雪
7863a0f45f Add new appid and offset entries for version 37625
Updated appid.json and offset.json to include new entries for versions 9.9.20-37625 and 3.2.18-37625, supporting both x64 and arm64 architectures.
2025-07-26 20:19:04 +08:00
Mlikiowa
ef4c2a935c release: v4.8.94 2025-07-21 11:49:43 +00:00
手瓜一十雪
40d2e948e4 feat: 添加新的appid和offset配置以支持版本9.9.20-37475和3.2.18-37475 2025-07-21 19:48:59 +08:00
dependabot[bot]
3de4e905d3 build(deps-dev): bump eslint-plugin-import from 2.31.0 to 2.32.0 (#1123)
Bumps [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import) from 2.31.0 to 2.32.0.
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-plugin-import/compare/v2.31.0...v2.32.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-import
  dependency-version: 2.32.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
2025-07-21 19:19:51 +08:00
dependabot[bot]
655f4e199c build(deps-dev): bump eslint-import-resolver-typescript (#1124)
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 4.4.3 to 4.4.4.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v4.4.3...v4.4.4)

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  dependency-version: 4.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-21 19:18:22 +08:00
dependabot[bot]
13c3d3a2fb build(deps-dev): bump @eslint/js from 9.30.1 to 9.31.0 (#1125)
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.30.1 to 9.31.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.31.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-21 19:18:10 +08:00
dependabot[bot]
db2dca45f6 build(deps-dev): bump @typescript-eslint/parser from 8.35.1 to 8.37.0 (#1140)
---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.37.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-21 19:17:45 +08:00
手瓜一十雪
7330a05c78 fix 2025-07-11 19:28:01 +08:00
Mlikiowa
a39c932868 release: v4.8.93 2025-07-09 11:21:08 +00:00
dependabot[bot]
a2c24c9197 build(deps-dev): bump @eslint/js from 9.28.0 to 9.30.1 (#1110)
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.28.0 to 9.30.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.30.1/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.30.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 19:11:09 +08:00
dependabot[bot]
5c3efc681f build(deps-dev): bump @eslint/compat from 1.3.0 to 1.3.1 (#1099)
Bumps [@eslint/compat](https://github.com/eslint/rewrite/tree/HEAD/packages/compat) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/compat/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/compat-v1.3.1/packages/compat)

---
updated-dependencies:
- dependency-name: "@eslint/compat"
  dependency-version: 1.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 19:10:54 +08:00
dependabot[bot]
e70d2bd708 build(deps-dev): bump typescript-eslint from 8.34.0 to 8.35.1 (#1112)
---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.35.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 19:10:14 +08:00
dependabot[bot]
cf75a961fb build(deps-dev): bump @rollup/plugin-typescript from 12.1.2 to 12.1.4 (#1098)
Bumps [@rollup/plugin-typescript](https://github.com/rollup/plugins/tree/HEAD/packages/typescript) from 12.1.2 to 12.1.4.
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/typescript/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/typescript-v12.1.4/packages/typescript)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-typescript"
  dependency-version: 12.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 19:10:02 +08:00
手瓜一十雪
159f317071 Add support for 3.2.18-37051 and 9.9.20-37051 versions
Updated appid.json and offset.json to include new entries for versions 3.2.18-37051 and 9.9.20-37051, including their respective app IDs, QUA values, and offset mappings for x64 and arm64 architectures.
2025-07-09 19:06:47 +08:00
Mlikiowa
713eef592a release: v4.8.92 2025-07-07 12:47:57 +00:00
囧囧JOJO
cf03ad8fd9 feat: 向 /get_system_msg 添加可选参数 'count' (#1113)
* feat: Add the optional parameter "count" to /get_system_msg

* Refactor GetGroupSystemMsg to use TypeBox schema

Introduced TypeBox for payload validation in GetGroupSystemMsg, replacing manual count handling with a schema-based approach. Updated the handler to use the new payload type and schema, improving type safety and input validation.

---------

Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
2025-07-07 20:46:26 +08:00
Mlikiowa
0c0b27901a release: v4.8.91 2025-07-07 12:41:15 +00:00
手瓜一十雪
137fe3c8f2 feat: 37012 2025-07-07 20:40:36 +08:00
Mlikiowa
d96174076a release: v4.8.90 2025-06-29 02:01:31 +00:00
手瓜一十雪
6d5662d96e feat: Add new Linux native modules for arm64 and x64
Added MoeHoo.linux.arm64.new.node and MoeHoo.linux.x64.new.node binaries to support native packet functionality on both ARM64 and x64 Linux platforms.
2025-06-29 09:58:32 +08:00
Mlikiowa
57abd47d99 release: v4.7.85 2025-06-26 10:35:59 +00:00
手瓜一十雪
5092b3d791 fix: package 2025-06-26 18:35:12 +08:00
Mlikiowa
649409d1be release: v4.7.81 2025-06-26 10:32:56 +00:00
手瓜一十雪
8f549d896a feat: package 2025-06-26 18:32:31 +08:00
Mlikiowa
a1359ddbb5 release: v4.7.80 2025-06-26 10:30:32 +00:00
102 changed files with 29227 additions and 471 deletions

2
.env.shell-analysis Normal file
View File

@@ -0,0 +1,2 @@
VITE_BUILD_TYPE = DEBUG
VITE_BUILD_PLATFORM = Shell

1
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Develop
node_modules/
package-lock.json
pnpm-lock.yaml
out/
dist/

View File

@@ -9,4 +9,9 @@
"css.customData": [
".vscode/tailwindcss.json"
],
}
"editor.formatOnPaste": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "never"
},
}

View File

@@ -44,7 +44,7 @@ _Modern protocol-side framework implemented based on NTQQ._
| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) |
|:-:|:-:|:-:|:-:|:-:|
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) |
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-napcatqq-blue)](https://t.me/napcatqq) |
|:-:|:-:|
## Thanks
@@ -54,7 +54,9 @@ _Modern protocol-side framework implemented based on NTQQ._
+ [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
+ [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
+ [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
+ 不过最最重要的 还是需要感谢屏幕前的你哦~
---

Binary file not shown.

View File

@@ -1,9 +1,9 @@
{
"name": "qq-chat",
"version": "9.9.19-34740",
"verHash": "f31348f2",
"linuxVersion": "3.2.17-34740",
"linuxVerHash": "5aa2d8d6",
"verHash": "cc326038",
"version": "9.9.21-39038",
"linuxVersion": "3.2.19-39038",
"linuxVerHash": "c773cdf7",
"private": true,
"description": "QQ",
"productName": "QQ",
@@ -17,7 +17,7 @@
"qd": "externals/devtools/cli/index.js"
},
"main": "./loadNapCat.js",
"buildVersion": "34740",
"buildVersion": "39038",
"isPureShell": true,
"isByteCodeShell": true,
"platform": "win32",

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.7.78",
"version": "4.8.109",
"icon": "./logo.png",
"authors": [
{

15995
napcat.webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -62,24 +62,39 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
<ButtonGroup
fullWidth
isDisabled={editing}
radius="full"
radius="sm"
size="sm"
variant="shadow"
variant="flat"
>
<Button color="warning" startContent={<FiEdit3 />} onPress={onEdit}>
<Button
color="warning"
startContent={<FiEdit3 size={16} />}
onPress={onEdit}
>
</Button>
<Button
color={debug ? 'success' : 'default'}
startContent={<CgDebug />}
color={debug ? 'secondary' : 'success'}
variant="flat"
startContent={
<CgDebug
style={{
width: '16px',
height: '16px',
minWidth: '16px',
minHeight: '16px'
}}
/>
}
onPress={handleEnableDebug}
>
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
color="primary"
startContent={<MdDeleteForever />}
className="bg-danger/20 text-danger hover:bg-danger/30 transition-colors"
variant="flat"
startContent={<MdDeleteForever size={16} />}
onPress={handleDelete}
>

View File

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

View File

@@ -1,4 +1,4 @@
import GenericForm from './generic_form'
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface HTTPClientFormProps {
@@ -20,7 +20,7 @@ const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
url: 'http://localhost:8080',
reportSelfMessage: false,
messagePostFormat: 'array',
token: '',
token: random_token(16),
debug: false
}

View File

@@ -1,4 +1,4 @@
import GenericForm from './generic_form'
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface HTTPServerFormProps {
@@ -17,12 +17,12 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
const defaultValues: HTTPServerFormType[0] = {
enable: false,
name: '',
host: '0.0.0.0',
host: '127.0.0.1',
port: 3000,
enableCors: true,
enableWebsocket: true,
messagePostFormat: 'array',
token: '',
token: random_token(16),
debug: false
}

View File

@@ -1,4 +1,4 @@
import GenericForm from './generic_form'
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface HTTPServerSSEFormProps {
@@ -19,12 +19,12 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
const defaultValues: HTTPServerSSEFormType[0] = {
enable: false,
name: '',
host: '0.0.0.0',
host: '127.0.0.1',
port: 3000,
enableCors: true,
enableWebsocket: true,
messagePostFormat: 'array',
token: '',
token: random_token(16),
debug: false,
reportSelfMessage: false
}

View File

@@ -1,4 +1,4 @@
import GenericForm from './generic_form'
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface WebsocketClientFormProps {
@@ -22,7 +22,7 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
url: 'ws://localhost:8082',
reportSelfMessage: false,
messagePostFormat: 'array',
token: '',
token: random_token(16),
debug: false,
heartInterval: 30000,
reconnectInterval: 30000

View File

@@ -1,4 +1,4 @@
import GenericForm from './generic_form'
import GenericForm, { random_token } from './generic_form'
import type { Field } from './generic_form'
export interface WebsocketServerFormProps {
@@ -19,12 +19,12 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
const defaultValues: WebsocketServerFormType[0] = {
enable: false,
name: '',
host: '0.0.0.0',
host: '127.0.0.1',
port: 3001,
reportSelfMessage: false,
enableForcePushEvent: true,
messagePostFormat: 'array',
token: '',
token: random_token(16),
debug: false,
heartInterval: 30000
}

View File

@@ -33,13 +33,6 @@ export default class WebUIManager {
return data.data
}
public static async checkUsingDefaultToken() {
const { data } = await serverRequest.get<ServerResponse<boolean>>(
'/auth/check_using_default_token'
)
return data.data
}
public static async proxy<T>(url = '') {
const data = await serverRequest.get<ServerResponse<string>>(
'/base/proxy?url=' + encodeURIComponent(url)
@@ -152,4 +145,55 @@ export default class WebUIManager {
return eventSource
}
// 获取WebUI基础配置
public static async getWebUIConfig() {
const { data } = await serverRequest.get<ServerResponse<WebUIConfig>>(
'/WebUIConfig/GetConfig'
)
return data.data
}
// 更新WebUI基础配置
public static async updateWebUIConfig(config: Partial<WebUIConfig>) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/WebUIConfig/UpdateConfig',
config
)
return data.data
}
// 获取是否禁用WebUI
public static async getDisableWebUI() {
const { data } = await serverRequest.get<ServerResponse<boolean>>(
'/WebUIConfig/GetDisableWebUI'
)
return data.data
}
// 更新是否禁用WebUI
public static async updateDisableWebUI(disable: boolean) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/WebUIConfig/UpdateDisableWebUI',
{ disable }
)
return data.data
}
// 获取是否禁用非局域网访问
public static async getDisableNonLANAccess() {
const { data } = await serverRequest.get<ServerResponse<boolean>>(
'/WebUIConfig/GetDisableNonLANAccess'
)
return data.data
}
// 更新是否禁用非局域网访问
public static async updateDisableNonLANAccess(disable: boolean) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/WebUIConfig/UpdateDisableNonLANAccess',
{ disable }
)
return data.data
}
}

View File

@@ -155,7 +155,7 @@ export default function AboutPage() {
shadow="sm"
isPressable
isExternal
href="https://t.me/MelodicMoonlight"
href="https://t.me/napcatqq"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50 text-primary-500">

View File

@@ -14,8 +14,9 @@ const ChangePasswordCard = () => {
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
reset
formState: { isSubmitting, errors },
reset,
watch
} = useForm<{
oldToken: string
newToken: string
@@ -29,9 +30,14 @@ const ChangePasswordCard = () => {
const navigate = useNavigate()
const [_, setToken] = useLocalStorage(key.token, '')
// 监听旧密码的值
const oldTokenValue = watch('oldToken')
const onSubmit = handleWebuiSubmit(async (data) => {
try {
// 使用正常密码更新流程
await WebUIManager.changePassword(data.oldToken, data.newToken)
toast.success('修改成功')
setToken('')
localStorage.removeItem(key.token)
@@ -45,30 +51,75 @@ const ChangePasswordCard = () => {
return (
<>
<title> - NapCat WebUI</title>
<Controller
control={control}
name="oldToken"
rules={{
required: '旧密码不能为空',
validate: (value) => {
if (!value || value.trim().length === 0) {
return '旧密码不能为空'
}
return true
}
}}
render={({ field }) => (
<Input
{...field}
label="旧密码"
placeholder="请输入旧密码"
type="password"
isRequired
isInvalid={!!errors.oldToken}
errorMessage={errors.oldToken?.message}
/>
)}
/>
<Controller
control={control}
name="newToken"
rules={{
required: '新密码不能为空',
minLength: {
value: 6,
message: '新密码至少需要6个字符'
},
validate: (value) => {
if (!value || value.trim().length === 0) {
return '新密码不能为空'
}
if (value.trim().length !== value.length) {
return '新密码不能包含前后空格'
}
if (value === oldTokenValue) {
return '新密码不能与旧密码相同'
}
// 检查是否包含字母
if (!/[a-zA-Z]/.test(value)) {
return '新密码必须包含字母'
}
// 检查是否包含数字
if (!/[0-9]/.test(value)) {
return '新密码必须包含数字'
}
return true
}
}}
render={({ field }) => (
<Input
{...field}
label="新密码"
placeholder="请输入新密码"
placeholder="至少6位包含字母和数字"
type="password"
isRequired
isInvalid={!!errors.newToken}
errorMessage={errors.newToken?.message}
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}

View File

@@ -7,6 +7,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
import ChangePasswordCard from './change_password'
import LoginConfigCard from './login'
import OneBotConfigCard from './onebot'
import ServerConfigCard from './server'
import ThemeConfigCard from './theme'
import WebUIConfigCard from './webui'
@@ -67,6 +68,11 @@ export default function ConfigPage() {
<OneBotConfigCard />
</ConfingPageItem>
</Tab>
<Tab title="服务器配置" key="server">
<ConfingPageItem>
<ServerConfigCard />
</ConfingPageItem>
</Tab>
<Tab title="WebUI配置" key="webui">
<ConfingPageItem>
<WebUIConfigCard />

View File

@@ -0,0 +1,185 @@
import { Input } from '@heroui/input'
import { Switch } from '@heroui/switch'
import { useRequest } from 'ahooks'
import { useEffect } from 'react'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import SaveButtons from '@/components/button/save_buttons'
import PageLoading from '@/components/page_loading'
import WebUIManager from '@/controllers/webui_manager'
const ServerConfigCard = () => {
const {
data: configData,
loading: configLoading,
error: configError,
refreshAsync: refreshConfig
} = useRequest(WebUIManager.getWebUIConfig)
const {
control,
handleSubmit: handleConfigSubmit,
formState: { isSubmitting },
setValue: setConfigValue
} = useForm<{
host: string
port: number
loginRate: number
disableWebUI: boolean
disableNonLANAccess: boolean
}>({
defaultValues: {
host: '0.0.0.0',
port: 6099,
loginRate: 10,
disableWebUI: false,
disableNonLANAccess: false
}
})
const reset = () => {
if (configData) {
setConfigValue('host', configData.host)
setConfigValue('port', configData.port)
setConfigValue('loginRate', configData.loginRate)
setConfigValue('disableWebUI', configData.disableWebUI)
setConfigValue('disableNonLANAccess', configData.disableNonLANAccess)
}
}
const onSubmit = handleConfigSubmit(async (data) => {
try {
await WebUIManager.updateWebUIConfig(data)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onRefresh = async () => {
try {
await refreshConfig()
toast.success('刷新成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`刷新失败: ${msg}`)
}
}
useEffect(() => {
reset()
}, [configData])
if (configLoading) return <PageLoading loading={true} />
return (
<>
<title> - NapCat WebUI</title>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full"></div>
<Controller
control={control}
name="host"
render={({ field }) => (
<Input
{...field}
label="监听地址"
placeholder="请输入监听地址"
description="服务器监听的IP地址0.0.0.0表示监听所有网卡"
isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined}
/>
)}
/>
<Controller
control={control}
name="port"
render={({ field }) => (
<Input
{...field}
type="number"
value={field.value?.toString() || ''}
label="监听端口"
placeholder="请输入监听端口"
description="服务器监听的端口号范围1-65535"
isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
)}
/>
<Controller
control={control}
name="loginRate"
render={({ field }) => (
<Input
{...field}
type="number"
value={field.value?.toString() || ''}
label="登录速率限制"
placeholder="请输入登录速率限制"
description="每小时允许的登录尝试次数"
isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
)}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full"></div>
<Controller
control={control}
name="disableWebUI"
render={({ field }) => (
<Switch
isSelected={field.value}
onValueChange={field.onChange}
isDisabled={!!configError}
>
<div className="flex flex-col">
<span>WebUI</span>
<span className="text-sm text-default-400">
WebUI服务
</span>
</div>
</Switch>
)}
/>
<Controller
control={control}
name="disableNonLANAccess"
render={({ field }) => (
<Switch
isSelected={field.value}
onValueChange={field.onChange}
isDisabled={!!configError}
>
<div className="flex flex-col">
<span>访</span>
<span className="text-sm text-default-400">
访WebUI
</span>
</div>
</Switch>
)}
/>
</div>
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting || configLoading}
refresh={onRefresh}
/>
</>
)
}
export default ServerConfigCard

View File

@@ -1,46 +1,15 @@
import { Spinner } from '@heroui/spinner'
import { AnimatePresence, motion } from 'motion/react'
import { Suspense, useEffect } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Suspense } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import useAuth from '@/hooks/auth'
import useDialog from '@/hooks/use-dialog'
import WebUIManager from '@/controllers/webui_manager'
import DefaultLayout from '@/layouts/default'
const CheckDefaultPassword = () => {
const { isAuth } = useAuth()
const dialog = useDialog()
const navigate = useNavigate()
const checkDefaultPassword = async () => {
const data = await WebUIManager.checkUsingDefaultToken()
if (data) {
dialog.confirm({
title: '修改默认密码',
content: '检测到当前密码为默认密码,请尽快修改密码。',
confirmText: '前往修改',
onConfirm: () => {
navigate('/config?tab=token')
}
})
}
}
useEffect(() => {
if (isAuth) {
checkDefaultPassword()
}
}, [isAuth])
return null
}
export default function IndexPage() {
const location = useLocation()
return (
<DefaultLayout>
<CheckDefaultPassword />
<Suspense
fallback={
<div className="flex justify-center px-10">

View File

@@ -25,7 +25,6 @@ export default function WebLoginPage() {
const [tokenValue, setTokenValue] = useState<string>(token || '')
const [isLoading, setIsLoading] = useState<boolean>(false)
const [, setLocalToken] = useLocalStorage<string>(key.token, '')
const onSubmit = async () => {
if (!tokenValue) {
toast.error('请输入token')
@@ -93,42 +92,65 @@ export default function WebLoginPage() {
</CardHeader>
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
<Input
isClearable
type="password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
>
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
<input
type="text"
name="username"
value="napcat-webui"
autoComplete="username"
className="absolute -left-[9999px] opacity-0 pointer-events-none"
readOnly
tabIndex={-1}
aria-label="Username"
/>
<Input
isClearable
type="password"
name="password"
autoComplete="current-password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
</form>
<div className="text-center text-small text-default-600 dark:text-default-400 px-2">
💡 NapCat
</div>
<Button
className="mx-10 mt-10 text-lg py-7"
color="primary"

View File

@@ -181,3 +181,11 @@ interface ThemeConfig {
dark: ThemeConfigItem
light: ThemeConfigItem
}
interface WebUIConfig {
host: string
port: number
loginRate: number
disableWebUI: boolean
disableNonLANAccess: boolean
}

8502
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.7.78",
"version": "4.8.109",
"scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
@@ -11,22 +11,28 @@
"dev:universal": "vite build --mode universal",
"dev:framework": "vite build --mode framework",
"dev:shell": "vite build --mode shell",
"dev:shell-analysis": "vite build --mode shell-analysis",
"dev:webui": "cd napcat.webui && npm run dev",
"lint": "eslint --fix src/**/*.{js,ts,vue}",
"depend": "cd dist && npm install --omit=dev",
"dev:depend": "npm i && cd napcat.webui && npm i"
},
"devDependencies": {
"@babel/core": "^7.28.0",
"@babel/generator": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/preset-typescript": "^7.24.7",
"@eslint/compat": "^1.2.2",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.2",
"@eslint/compat": "^1.3.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@eslint/js": "^9.33.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@sinclair/typebox": "^0.34.9",
"@rollup/plugin-typescript": "^12.1.4",
"@sinclair/typebox": "^0.34.38",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
@@ -37,33 +43,33 @@
"@types/type-is": "^1.6.7",
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"@typescript-eslint/parser": "^8.39.0",
"ajv": "^8.13.0",
"async-mutex": "^0.5.0",
"commander": "^13.0.0",
"compressing": "^1.10.1",
"cors": "^2.8.5",
"esbuild": "0.25.5",
"esbuild": "0.25.8",
"eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.29.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"express-rate-limit": "^7.5.0",
"fast-xml-parser": "^4.3.6",
"file-type": "^21.0.0",
"globals": "^16.0.0",
"json5": "^2.2.3",
"multer": "^2.0.1",
"napcat.protobuf": "^1.1.4",
"typescript": "^5.3.3",
"typescript-eslint": "^8.13.0",
"vite": "^6.0.1",
"typescript-eslint": "^8.35.1",
"vite": "^7.1.1",
"vite-plugin-cp": "^6.0.0",
"vite-tsconfig-paths": "^5.1.0",
"napcat.protobuf": "^1.1.4",
"winston": "^3.17.0",
"compressing": "^1.10.1"
"winston": "^3.17.0"
},
"dependencies": {
"express": "^5.0.0",
"silk-wasm": "^3.6.1",
"ws": "^8.18.0"
"ws": "^8.18.3"
}
}

View File

@@ -10,10 +10,14 @@ import { LogWrapper } from './log';
const downloadOri = "https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip"
const urls = [
"https://github.moeyy.xyz/" + downloadOri,
"https://ghp.ci/" + downloadOri,
"https://gh.api.99988866.xyz/" + downloadOri,
"https://gh.api.99988866.xyz/" + downloadOri,
"https://j.1win.ggff.net/" + downloadOri,
"https://git.yylx.win/" + downloadOri,
"https://ghfile.geekertao.top/" + downloadOri,
"https://gh-proxy.net/" + downloadOri,
"https://ghm.078465.xyz/" + downloadOri,
"https://gitproxy.127731.xyz/" + downloadOri,
"https://jiashu.1win.eu.org/" + downloadOri,
"https://github.tbedu.top/" + downloadOri,
downloadOri
];

328
src/common/health.ts Normal file
View File

@@ -0,0 +1,328 @@
export interface ResourceConfig<T extends any[], R> {
/** 资源获取函数 */
resourceFn: (...args: T) => Promise<R>;
/** 失败后禁用时间(毫秒),默认 30 秒 */
disableTime?: number;
/** 最大重试次数,默认 3 次 */
maxRetries?: number;
/** 主动测试间隔(毫秒),默认 60 秒 */
healthCheckInterval?: number;
/** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
maxHealthCheckFailures?: number;
/** 资源名称(用于日志) */
name?: string;
/** 测试参数(用于健康检查) */
testArgs?: T;
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
healthCheckFn?: (...args: T) => Promise<boolean>;
}
interface ResourceState<T extends any[], R> {
config: ResourceConfig<T, R>;
isEnabled: boolean;
disableUntil: number;
currentRetries: number;
healthCheckFailureCount: number;
isPermanentlyDisabled: boolean;
lastError?: Error;
lastHealthCheckTime: number;
registrationKey: string;
}
export class ResourceManager {
private resources = new Map<string, ResourceState<any, any>>();
private destroyed = false;
private healthCheckTimer?: NodeJS.Timeout;
private readonly HEALTH_CHECK_TASK_INTERVAL = 5000; // 5秒执行一次健康检查任务
constructor() {
this.startHealthCheckTask();
}
/**
* 注册资源(注册即调用,重复注册只实际注册一次)
*/
async register<T extends any[], R>(
key: string,
config: ResourceConfig<T, R>,
...args: T
): Promise<R> {
if (this.destroyed) {
throw new Error('ResourceManager has been destroyed');
}
const registrationKey = this.generateRegistrationKey(key, config);
// 检查是否已经注册
if (this.resources.has(key)) {
const existingState = this.resources.get(key)!;
// 如果是相同的配置,直接调用
if (existingState.registrationKey === registrationKey) {
return this.callResource<T, R>(key, ...args);
}
// 配置不同,清理旧的并重新注册
this.unregister(key);
}
// 创建新的资源状态
const state: ResourceState<T, R> = {
config: {
disableTime: 30000,
maxRetries: 3,
healthCheckInterval: 60000,
maxHealthCheckFailures: 5,
name: key,
...config
},
isEnabled: true,
disableUntil: 0,
currentRetries: 0,
healthCheckFailureCount: 0,
isPermanentlyDisabled: false,
lastHealthCheckTime: 0,
registrationKey
};
this.resources.set(key, state);
// 注册即调用
return await this.callResource<T, R>(key, ...args);
}
/**
* 调用资源
*/
async callResource<T extends any[], R>(key: string, ...args: T): Promise<R> {
const state = this.resources.get(key) as ResourceState<T, R> | undefined;
if (!state) {
throw new Error(`Resource ${key} not registered`);
}
if (state.isPermanentlyDisabled) {
throw new Error(`Resource ${key} is permanently disabled due to repeated health check failures`);
}
if (!this.isResourceAvailable(key)) {
const disableUntilDate = new Date(state.disableUntil).toISOString();
throw new Error(`Resource ${key} is currently disabled until ${disableUntilDate}`);
}
try {
const result = await state.config.resourceFn(...args);
this.onResourceSuccess(state);
return result;
} catch (error) {
this.onResourceFailure(state, error as Error);
throw error;
}
}
/**
* 检查资源是否可用
*/
isResourceAvailable(key: string): boolean {
const state = this.resources.get(key);
if (!state) {
return false;
}
if (state.isPermanentlyDisabled || !state.isEnabled) {
return false;
}
return Date.now() >= state.disableUntil;
}
/**
* 注销资源
*/
unregister(key: string): boolean {
return this.resources.delete(key);
}
/**
* 销毁管理器,清理所有资源
*/
destroy(): void {
if (this.destroyed) {
return;
}
this.stopHealthCheckTask();
this.resources.clear();
this.destroyed = true;
}
private generateRegistrationKey<T extends any[], R>(key: string, config: ResourceConfig<T, R>): string {
const configStr = JSON.stringify({
name: config.name,
disableTime: config.disableTime,
maxRetries: config.maxRetries,
healthCheckInterval: config.healthCheckInterval,
maxHealthCheckFailures: config.maxHealthCheckFailures,
functionStr: config.resourceFn.toString(),
healthCheckFnStr: config.healthCheckFn?.toString()
});
return `${key}_${this.simpleHash(configStr)}`;
}
private simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}
private onResourceSuccess<T extends any[], R>(state: ResourceState<T, R>): void {
state.currentRetries = 0;
state.disableUntil = 0;
state.healthCheckFailureCount = 0;
state.lastError = undefined;
}
private onResourceFailure<T extends any[], R>(state: ResourceState<T, R>, error: Error): void {
state.currentRetries++;
state.lastError = error;
// 如果重试次数达到上限,禁用资源
if (state.currentRetries >= state.config.maxRetries!) {
state.disableUntil = Date.now() + state.config.disableTime!;
state.currentRetries = 0;
}
}
private startHealthCheckTask(): void {
if (this.healthCheckTimer) {
return;
}
this.healthCheckTimer = setInterval(() => {
this.runHealthCheckTask();
}, this.HEALTH_CHECK_TASK_INTERVAL);
}
private stopHealthCheckTask(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = undefined;
}
}
private async runHealthCheckTask(): Promise<void> {
if (this.destroyed) {
return;
}
const now = Date.now();
for (const [key, state] of this.resources) {
// 跳过永久禁用或可用的资源
if (state.isPermanentlyDisabled || this.isResourceAvailable(key)) {
continue;
}
// 跳过还在禁用期内的资源
if (now < state.disableUntil) {
continue;
}
// 检查是否需要进行健康检查(根据间隔时间)
const lastHealthCheck = state.lastHealthCheckTime || 0;
const healthCheckInterval = state.config.healthCheckInterval!;
if (now - lastHealthCheck < healthCheckInterval) {
continue;
}
// 执行健康检查
await this.performHealthCheck(state);
}
}
private async performHealthCheck<T extends any[], R>(state: ResourceState<T, R>): Promise<void> {
state.lastHealthCheckTime = Date.now();
try {
let healthCheckResult: boolean;
// 如果有专门的健康检查函数,使用它
if (state.config.healthCheckFn) {
const testArgs = state.config.testArgs || [] as unknown as T;
healthCheckResult = await state.config.healthCheckFn(...testArgs);
} else {
// 否则使用原始函数进行检查
const testArgs = state.config.testArgs || [] as unknown as T;
await state.config.resourceFn(...testArgs);
healthCheckResult = true;
}
if (healthCheckResult) {
// 健康检查成功,重新启用
state.isEnabled = true;
state.disableUntil = 0;
state.currentRetries = 0;
state.healthCheckFailureCount = 0;
state.lastError = undefined;
} else {
throw new Error('Health check function returned false');
}
} catch (error) {
// 健康检查失败,增加失败计数
state.healthCheckFailureCount++;
state.lastError = error as Error;
// 检查是否达到最大健康检查失败次数
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures!) {
// 永久禁用资源
state.isPermanentlyDisabled = true;
state.disableUntil = 0;
} else {
// 继续禁用一段时间
state.disableUntil = Date.now() + state.config.disableTime!;
}
}
}
}
// 创建全局实例
export const resourceManager = new ResourceManager();
// 便捷函数
export async function registerResource<T extends any[], R>(
key: string,
config: ResourceConfig<T, R>,
...args: T
): Promise<R> {
return resourceManager.register(key, config, ...args);
}
// 使用示例:
/*
await registerResource(
'api-with-health-check',
{
resourceFn: async (id: string) => {
const response = await fetch(`https://api.example.com/data/${id}`);
return response.json();
},
healthCheckFn: async (id: string) => {
try {
const response = await fetch(`https://api.example.com/health`);
return response.ok;
} catch {
return false;
}
},
testArgs: ['health-check-id'],
healthCheckInterval: 30000,
maxHealthCheckFailures: 3
},
'user123'
);
*/

View File

@@ -9,6 +9,7 @@ export class NapCatPathWrapper {
configPath: string;
cachePath: string;
staticPath: string;
pluginPath: string;
constructor(mainPath: string = dirname(fileURLToPath(import.meta.url))) {
this.binaryPath = mainPath;
@@ -24,6 +25,7 @@ export class NapCatPathWrapper {
this.logsPath = path.join(writePath, 'logs');
this.configPath = path.join(writePath, 'config');
this.pluginPath = path.join(writePath, 'plugins');//dynamic load
this.cachePath = path.join(writePath, 'cache');
this.staticPath = path.join(this.binaryPath, 'static');
if (!fs.existsSync(this.logsPath)) {

View File

@@ -0,0 +1,316 @@
/**
* 性能监控器 - 用于统计函数调用次数、耗时等信息
*/
import * as fs from 'fs';
import * as path from 'path';
export interface FunctionStats {
name: string;
callCount: number;
totalTime: number;
averageTime: number;
minTime: number;
maxTime: number;
fileName?: string;
lineNumber?: number;
}
export class PerformanceMonitor {
private static instance: PerformanceMonitor;
private stats = new Map<string, FunctionStats>();
private startTimes = new Map<string, number>();
private reportInterval: NodeJS.Timeout | null = null;
static getInstance(): PerformanceMonitor {
if (!PerformanceMonitor.instance) {
PerformanceMonitor.instance = new PerformanceMonitor();
// 启动定时统计报告
PerformanceMonitor.instance.startPeriodicReport();
}
return PerformanceMonitor.instance;
}
/**
* 开始定时统计报告 (每60秒)
*/
private startPeriodicReport(): void {
if (this.reportInterval) {
clearInterval(this.reportInterval);
}
this.reportInterval = setInterval(() => {
if (this.stats.size > 0) {
this.printPeriodicReport();
this.writeDetailedLogToFile();
}
}, 60000); // 60秒
}
/**
* 停止定时统计报告
*/
stopPeriodicReport(): void {
if (this.reportInterval) {
clearInterval(this.reportInterval);
this.reportInterval = null;
}
}
/**
* 打印定时统计报告 (简化版本)
*/
private printPeriodicReport(): void {
const now = new Date().toLocaleString();
console.log(`\n=== 性能监控定时报告 [${now}] ===`);
const totalFunctions = this.stats.size;
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
console.log(`📊 总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms`);
// 显示Top 5最活跃的函数
console.log('\n🔥 最活跃函数 (Top 5):');
this.getTopByCallCount(5).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
});
// 显示Top 5最耗时的函数
console.log('\n⏱ 最耗时函数 (Top 5):');
this.getTopByTotalTime(5).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms`);
});
console.log('===============================\n');
}
/**
* 将详细统计数据写入日志文件
*/
private writeDetailedLogToFile(): void {
try {
const now = new Date();
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
const timeStr = now.toTimeString().split(' ')[0]?.replace(/:/g, '-') || 'unknown-time';
const timestamp = `${dateStr}_${timeStr}`;
const fileName = `${timestamp}.log.txt`;
const logPath = path.join(process.cwd(), 'logs', fileName);
// 确保logs目录存在
const logsDir = path.dirname(logPath);
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const totalFunctions = this.stats.size;
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
let logContent = '';
logContent += `=== 性能监控详细报告 ===\n`;
logContent += `生成时间: ${now.toLocaleString()}\n`;
logContent += `统计周期: 60秒\n`;
logContent += `总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms\n\n`;
// 详细函数统计
logContent += `=== 所有函数详细统计 ===\n`;
const allStats = this.getStats().sort((a, b) => b.totalTime - a.totalTime);
allStats.forEach((stat, index) => {
logContent += `${index + 1}. 函数: ${stat.name}\n`;
logContent += ` 文件: ${stat.fileName || 'N/A'}\n`;
logContent += ` 行号: ${stat.lineNumber || 'N/A'}\n`;
logContent += ` 调用次数: ${stat.callCount}\n`;
logContent += ` 总耗时: ${stat.totalTime.toFixed(4)}ms\n`;
logContent += ` 平均耗时: ${stat.averageTime.toFixed(4)}ms\n`;
logContent += ` 最小耗时: ${stat.minTime === Infinity ? 'N/A' : stat.minTime.toFixed(4)}ms\n`;
logContent += ` 最大耗时: ${stat.maxTime.toFixed(4)}ms\n`;
logContent += ` 性能占比: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
logContent += `\n`;
});
// 排行榜统计
logContent += `=== 总耗时排行榜 (Top 20) ===\n`;
this.getTopByTotalTime(20).forEach((stat, index) => {
logContent += `${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
});
logContent += `\n=== 调用次数排行榜 (Top 20) ===\n`;
this.getTopByCallCount(20).forEach((stat, index) => {
logContent += `${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
});
logContent += `\n=== 平均耗时排行榜 (Top 20) ===\n`;
this.getTopByAverageTime(20).forEach((stat, index) => {
logContent += `${index + 1}. ${stat.name} - 平均: ${stat.averageTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms\n`;
});
logContent += `\n=== 性能热点分析 ===\n`;
// 找出最耗时的前10个函数
const hotSpots = this.getTopByTotalTime(10);
hotSpots.forEach((stat, index) => {
const efficiency = stat.callCount / stat.totalTime; // 每毫秒的调用次数
logContent += `${index + 1}. ${stat.name}\n`;
logContent += ` 性能影响: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
logContent += ` 调用效率: ${efficiency.toFixed(4)} 调用/ms\n`;
logContent += ` 优化建议: ${stat.averageTime > 10 ? '考虑优化此函数的执行效率' :
stat.callCount > 1000 ? '考虑减少此函数的调用频率' :
'性能表现良好'}\n\n`;
});
logContent += `=== 报告结束 ===\n`;
// 写入文件
fs.writeFileSync(logPath, logContent, 'utf8');
console.log(`📄 详细性能报告已保存到: ${logPath}`);
} catch (error) {
console.error('写入性能日志文件时出错:', error);
}
}
/**
* 开始记录函数调用
*/
startFunction(functionName: string, fileName?: string, lineNumber?: number): string {
const callId = `${functionName}_${Date.now()}_${Math.random()}`;
this.startTimes.set(callId, performance.now());
// 初始化或更新统计信息
if (!this.stats.has(functionName)) {
this.stats.set(functionName, {
name: functionName,
callCount: 0,
totalTime: 0,
averageTime: 0,
minTime: Infinity,
maxTime: 0,
fileName,
lineNumber
});
}
const stat = this.stats.get(functionName)!;
stat.callCount++;
return callId;
}
/**
* 结束记录函数调用
*/
endFunction(callId: string, functionName: string): void {
const startTime = this.startTimes.get(callId);
if (!startTime) return;
const endTime = performance.now();
const duration = endTime - startTime;
this.startTimes.delete(callId);
const stat = this.stats.get(functionName);
if (!stat) return;
stat.totalTime += duration;
stat.averageTime = stat.totalTime / stat.callCount;
stat.minTime = Math.min(stat.minTime, duration);
stat.maxTime = Math.max(stat.maxTime, duration);
}
/**
* 获取所有统计信息
*/
getStats(): FunctionStats[] {
return Array.from(this.stats.values());
}
/**
* 获取排行榜 - 按总耗时排序
*/
getTopByTotalTime(limit = 20): FunctionStats[] {
return this.getStats()
.sort((a, b) => b.totalTime - a.totalTime)
.slice(0, limit);
}
/**
* 获取排行榜 - 按调用次数排序
*/
getTopByCallCount(limit = 20): FunctionStats[] {
return this.getStats()
.sort((a, b) => b.callCount - a.callCount)
.slice(0, limit);
}
/**
* 获取排行榜 - 按平均耗时排序
*/
getTopByAverageTime(limit = 20): FunctionStats[] {
return this.getStats()
.sort((a, b) => b.averageTime - a.averageTime)
.slice(0, limit);
}
/**
* 清空统计数据
*/
clear(): void {
this.stats.clear();
this.startTimes.clear();
}
/**
* 打印统计报告
*/
printReport(): void {
console.log('\n=== 函数性能监控报告 ===');
console.log('\n🔥 总耗时排行榜 (Top 10):');
this.getTopByTotalTime(10).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
});
console.log('\n📈 调用次数排行榜 (Top 10):');
this.getTopByCallCount(10).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
});
console.log('\n⏱ 平均耗时排行榜 (Top 10):');
this.getTopByAverageTime(10).forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name} - 平均耗时: ${stat.averageTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
});
console.log('\n========================\n');
}
/**
* 获取JSON格式的统计数据
*/
toJSON(): FunctionStats[] {
return this.getStats();
}
}
// 全局性能监控器实例
export const performanceMonitor = PerformanceMonitor.getInstance();
// 在进程退出时打印报告并停止定时器
if (typeof process !== 'undefined') {
process.on('exit', () => {
performanceMonitor.stopPeriodicReport();
performanceMonitor.printReport();
});
process.on('SIGINT', () => {
performanceMonitor.stopPeriodicReport();
performanceMonitor.printReport();
process.exit(0);
});
process.on('SIGTERM', () => {
performanceMonitor.stopPeriodicReport();
performanceMonitor.printReport();
process.exit(0);
});
}

View File

@@ -42,7 +42,7 @@ export class QQBasicInfoWrapper {
return this.isQuickUpdate ? this.QQVersionConfig?.buildId : this.QQPackageInfo?.buildVersion;
}
getFullQQVesion() {
getFullQQVersion() {
const version = this.isQuickUpdate ? this.QQVersionConfig?.curVersion : this.QQPackageInfo?.version;
if (!version) throw new Error('QQ版本获取失败');
return version;
@@ -57,9 +57,9 @@ export class QQBasicInfoWrapper {
//此方法不要直接使用
getQUAFallback() {
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
win32: `V1_WIN_${this.getFullQQVesion()}_${this.getQQBuildStr()}_GW_B`,
darwin: `V1_MAC_${this.getFullQQVesion()}_${this.getQQBuildStr()}_GW_B`,
linux: `V1_LNX_${this.getFullQQVesion()}_${this.getQQBuildStr()}_GW_B`,
win32: `V1_WIN_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
darwin: `V1_MAC_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
linux: `V1_LNX_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
};
return platformMapping[systemPlatform] ?? (platformMapping.win32)!;
}
@@ -76,7 +76,7 @@ export class QQBasicInfoWrapper {
getAppidV2(): { appid: string; qua: string } {
// 通过已有表 性能好
const appidTbale = AppidTable as unknown as QQAppidTableType;
const fullVersion = this.getFullQQVesion();
const fullVersion = this.getFullQQVersion();
if (fullVersion) {
const data = appidTbale[fullVersion];
if (data) {

View File

@@ -1 +1 @@
export const napCatVersion = '4.7.78';
export const napCatVersion = '4.8.109';

View File

@@ -42,8 +42,8 @@ export class NTQQFileApi {
this.context = context;
this.core = core;
this.rkeyManager = new RkeyManager([
'https://secret-service.bietiaop.com/rkeys',
'http://ss.xingzhige.com/music_card/rkey',
'https://secret-service.bietiaop.com/rkeys',
],
this.context.logger
);
@@ -64,13 +64,13 @@ export class NTQQFileApi {
}
}
async getFileUrl(chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined) {
if (this.core.apis.PacketApi.available) {
async getFileUrl(chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined,timeout: number = 20000) {
if (this.core.apis.PacketApi.packetStatus) {
try {
if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID);
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID, timeout);
} else if (file10MMd5 && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5);
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
@@ -79,8 +79,8 @@ export class NTQQFileApi {
throw new Error('fileUUID or file10MMd5 is undefined');
}
async getPttUrl(peer: string, fileUUID?: string) {
if (this.core.apis.PacketApi.available && fileUUID) {
async getPttUrl(peer: string, fileUUID?: string,timeout: number = 20000) {
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1403) {
@@ -90,7 +90,7 @@ export class NTQQFileApi {
uploadTime: 0,
ttl: 0,
subType: 0,
});
}, timeout);
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPttUrl(peer, {
fileUuid: fileUUID,
@@ -98,7 +98,7 @@ export class NTQQFileApi {
uploadTime: 0,
ttl: 0,
subType: 0,
});
}, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
@@ -107,8 +107,8 @@ export class NTQQFileApi {
throw new Error('packet cant get ptt url');
}
async getVideoUrlPacket(peer: string, fileUUID?: string) {
if (this.core.apis.PacketApi.available && fileUUID) {
async getVideoUrlPacket(peer: string, fileUUID?: string,timeout: number = 20000) {
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1415) {
@@ -118,7 +118,7 @@ export class NTQQFileApi {
uploadTime: 0,
ttl: 0,
subType: 0,
});
}, timeout);
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetVideoUrl(peer, {
fileUuid: fileUUID,
@@ -126,7 +126,7 @@ export class NTQQFileApi {
uploadTime: 0,
ttl: 0,
subType: 0,
});
}, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
@@ -260,23 +260,22 @@ export class NTQQFileApi {
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
if (!fs.existsSync(thumbPath)) {
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
throw new Error('获取视频缩略图失败');
}
} catch (e) {
this.context.logger.logError('获取视频信息失败', e);
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
if (_diyThumbPath) {
try {
await this.copyFile(_diyThumbPath, thumbPath);
} catch (e) {
this.context.logger.logError('复制自定义缩略图失败', e);
}
} else {
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
if (!fs.existsSync(thumbPath)) {
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
throw new Error('获取视频缩略图失败');
}
} catch (e) {
this.context.logger.logError('获取视频信息失败', e);
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
}
context.deleteAfterSentFiles.push(thumbPath);
const thumbSize = (await fsPromises.stat(thumbPath)).size;
@@ -502,7 +501,7 @@ export class NTQQFileApi {
};
try {
if (this.core.apis.PacketApi.available) {
if (this.core.apis.PacketApi.packetStatus) {
const rkey_expired_private = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
const rkey_expired_group = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
if (rkey_expired_private || rkey_expired_group) {

View File

@@ -142,7 +142,6 @@ export class NTQQMsgApi {
}
async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) {
console.log(peer, SendersUid);
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
@@ -192,7 +191,7 @@ export class NTQQMsgApi {
}
async recallMsg(peer: Peer, msgId: string) {
await this.core.eventWrapper.callNormalEventV2(
return await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/recallMsg',
'NodeIKernelMsgListener/onMsgInfoListUpdate',
[peer, [msgId]],

View File

@@ -21,20 +21,26 @@ export class NTQQPacketApi {
qqVersion: string | undefined;
pkt!: PacketClientSession;
errStack: string[] = [];
packetStatus: boolean = false;
constructor(context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
this.logger = core.context.logger;
}
async initApi() {
await this.InitSendPacket(this.context.basicInfoWrapper.getFullQQVesion())
.then()
this.packetStatus = (await this.InitSendPacket(this.context.basicInfoWrapper.getFullQQVersion())
.then((result) => {
return result;
})
.catch((err) => {
this.logger.logError(err);
this.errStack.push(err);
});
return false;
})) && this.pkt?.available;
}
get available(): boolean {
return this.pkt?.available ?? false;
}
@@ -61,6 +67,12 @@ export class NTQQPacketApi {
}
this.pkt = new PacketClientSession(this.core);
await this.pkt.init(process.pid, table.recv, table.send);
try {
await this.pkt.operation.FetchRkey(1500);
} catch (error) {
this.logger.logError('测试Packet状态异常', error);
return false;
}
return true;
}
}

View File

@@ -8,10 +8,11 @@ import {
WebHonorType,
} from '@/core';
import { NapCatCore } from '..';
import { readFileSync } from 'node:fs';
import { createReadStream, readFileSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { qunAlbumControl } from '../data/webapi';
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
export class NTQQWebApi {
context: InstanceContext;
core: NapCatCore;
@@ -323,64 +324,193 @@ export class NTQQWebApi {
}
return (hash & 0x7FFFFFFF).toString();
}
async createQunAlbumSession(gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string, uin: string) {
async getAlbumListByNTQQ(gc: string) {
return await this.context.session.getAlbumService().getAlbumList({
qun_id: gc,
attach_info: '',
seq: 3331,
request_time_line: {
request_invoke_time: "0"
}
})
}
async getAlbumList(gc: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const bkn = this.getBknFromSKey(skey);
const uin = this.core.selfInfo.uin || '10001';
const cookies = `p_uin=o${this.core.selfInfo.uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin} `;
const api = `https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?`;
const params = new URLSearchParams({
random: '7570',
g_tk: bkn,
format: 'json',
inCharset: 'utf-8',
outCharset: 'utf-8',
qua: 'V1_IPH_SQ_6.2.0_0_HDBM_T',
cmd: 'qunGetAlbumList',
qunId: gc,
qunid: gc,
start: '0',
num: '1000',
uin: uin,
getMemberRole: '0'
});
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string }> } }>(api + params.toString(), 'GET', '', {
'Cookie': cookies
});
return response.data.album;
}
async createQunAlbumSession(gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string, img_md5: string, uin: string) {
const img = readFileSync(path);
const img_md5 = createHash('md5').update(img).digest('hex');
const img_size = img.length;
const img_name = basename(path);
const time = Math.floor(Date.now() / 1000);
const GTK = this.getBknFromSKey(pskey);
const cookie = `p_uin=${uin}; p_skey=${pskey}; skey=${skey}; uin=${uin}`;
const body = {
control_req: [{
uin: uin,
token: {
type: 4,
data: pskey,
appid: 5
},
appid: 'qun',
checksum: img_md5,
check_type: 0,
file_len: img_size,
env: {
refer: 'qzone',
deviceInfo: 'h5'
},
model: 0,
biz_req: {
sPicTitle: img_name,
sPicDesc: '',
sAlbumName: sAlbumName,
sAlbumID: sAlbumID,
iAlbumTypeID: 0,
iBitmap: 0,
iUploadType: 0,
iUpPicType: 0,
iBatchID: time,
sPicPath: '',
iPicWidth: 0,
iPicHight: 0,
iWaterType: 0,
iDistinctUse: 0,
iNeedFeeds: 1,
iUploadTime: time,
mapExt: {
appid: 'qun',
userid: gc
}
},
session: '',
asy_upload: 0,
cmd: 'FileUpload'
}]
};
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const body = qunAlbumControl({
uin,
group_id: gc,
pskey,
pic_md5: img_md5,
img_size,
img_name,
sAlbumName: sAlbumName,
sAlbumID: sAlbumID
});
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
const post = await RequestUtil.HttpGetJson(api, 'POST', body, {
const post = await RequestUtil.HttpGetJson<{ data: { session: string }, ret: number, msg: string }>(api, 'POST', body, {
'Cookie': cookie,
'Content-Type': 'application/json'
});
return post;
}
}
async uploadQunAlbumSlice(path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number) {
const img_size = statSync(path).size;
let seq = 0;
let offset = 0;
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const stream = createReadStream(path, { highWaterMark: slice_size });
for await (const chunk of stream) {
const end = Math.min(offset + chunk.length, img_size);
const form = new FormData();
form.append('uin', uin);
form.append('appid', 'qun');
form.append('session', session);
form.append('offset', offset.toString());
form.append('data', new Blob([chunk], { type: 'application/octet-stream' }), 'blob');
form.append('checksum', '');
form.append('check_type', '0');
form.append('retry', '0');
form.append('seq', seq.toString());
form.append('end', end.toString());
form.append('cmd', 'FileUpload');
form.append('slice_size', slice_size.toString());
form.append('biz_req.iUploadType', '0');
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${seq}&retry=0&offset=${offset}&end=${end}&total=${img_size}&type=form&g_tk=${GTK}`;
const response = await fetch(api, {
method: 'POST',
headers: {
'Cookie': cookie,
},
body: form
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const post = await response.json() as { ret: number, msg: string }; if (post.ret !== 0) {
throw new Error(`分片 ${seq} 上传失败: ${post.msg}`);
}
offset += chunk.length;
seq++;
}
return { success: true, message: '上传完成' };
}
async uploadImageToQunAlbum(gc: string, sAlbumID: string, sAlbumName: string, path: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const img_md5 = createHash('md5').update(readFileSync(path)).digest('hex');
const uin = this.core.selfInfo.uin || '10001';
const session = (await this.createQunAlbumSession(gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin)).data.session;
if (!session) throw new Error('创建群相册会话失败');
await this.uploadQunAlbumSlice(path, session, skey, pskey, uin, 16384);
}
async getAlbumMediaListByNTQQ(gc: string, albumId: string, attach_info: string = '') {
return (await this.context.session.getAlbumService().getMediaList({
qun_id: gc,
attach_info: attach_info,
seq: 0,
request_time_line: {
request_invoke_time: "0"
},
album_id: albumId,
lloc: '',
batch_id: ''
})).response;
}
async doAlbumMediaPlainCommentByNTQQ(
qunId: string,
albumId: string,
lloc: string,
content: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
//16位number数字
const client_key = Date.now() * 1000
return await this.context.session.getAlbumService().doQunComment(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: []
},
qunId,
2,
createAlbumMediaFeed(uin, albumId, lloc),
createAlbumCommentRequest(uin, content, client_key)
);
}
async deleteAlbumMediaByNTQQ(
qunId: string,
albumId: string,
lloc: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
return await this.context.session.getAlbumService().deleteMedias(
random_seq,
qunId,
albumId,
[lloc],
[]
);
}
async doAlbumMediaLikeByNTQQ(
qunId: string,
albumId: string,
lloc: string,
id: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
return await this.context.session.getAlbumService().doQunLike(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: []
}, {
id: id,
status: 1
},
createAlbumFeedPublish(qunId, uin, albumId, lloc)
)
}
}

221
src/core/data/album.ts Normal file
View File

@@ -0,0 +1,221 @@
/**
* 群相册列表请求参数接口
*/
export interface AlbumListRequest {
qun_id: string;
attach_info: string;
seq: number;
request_time_line: {
request_invoke_time: string;
};
album_id: string;
lloc: string;
batch_id: string;
}
/**
* 创建群相册列表请求参数
* @param qunId 群号
* @param albumId 相册ID
* @param seq 请求序列号默认值为0
* @returns 请求参数对象
*/
export function createAlbumListRequest(
qunId: string,
albumId: string,
seq: number = 0
): AlbumListRequest {
return {
qun_id: qunId,
attach_info: "",
seq: seq,
request_time_line: {
request_invoke_time: "0"
},
album_id: albumId,
lloc: "",
batch_id: ""
};
}
/**
* 相册媒体项请求接口
*/
export interface AlbumMediaFeed {
cell_common: {
time: string;
};
cell_user_info: {
user: {
uin: string;
};
};
cell_media: {
album_id: string;
batch_id: string;
media_items: Array<{
image: {
lloc: string;
};
}>;
};
}
/**
* 创建相册媒体请求参数
* @param uin 用户QQ号
* @param albumId 相册ID
* @param lloc
* @returns 媒体请求参数对象
*/
export function createAlbumMediaFeed(
uin: string,
albumId: string,
lloc: string
): AlbumMediaFeed {
return {
cell_common: {
time: ""
},
cell_user_info: {
user: {
uin: uin
}
},
cell_media: {
album_id: albumId,
batch_id: "",
media_items: [{
image: {
lloc: lloc
}
}]
}
};
}
/**
* 相册评论内容接口
*/
export interface AlbumCommentContent {
type: number;
content: string;
who: number;
uid: string;
name: string;
url: string;
}
/**
* 相册评论请求接口
*/
export interface AlbumCommentReplyContent {
client_key: number;
content: AlbumCommentContent[];
user: {
uin: string;
};
}
export enum RichMsgType {
KRICHMSGTYPEPLAINTEXT,
KRICHMSGTYPEAT,
KRICHMSGTYPEURL,
KRICHMSGTYPEMEDIA
}
/**
* 创建相册评论请求参数
* @param uin 用户QQ号
* @param content 评论内容
* @param client_key 客户端鉴权密钥
* @returns 评论请求参数对象
*/
export function createAlbumCommentRequest(
uin: string,
content: string,
client_key: number
): AlbumCommentReplyContent {
return {
client_key: client_key,
//暂时只支持纯文本吧
content: [{
type: RichMsgType.KRICHMSGTYPEPLAINTEXT,
content: content,
who: 0,
uid: "",
name: "",
url: ""
}],
user: {
uin: uin
}
};
}
export interface AlbumFeedLikePublish {
cell_common: {
time: number;
feed_id: string;
};
cell_user_info: {
user: {
uin: string;
};
};
cell_media: {
album_id: string;
batch_id: number;
media_items: Array<{
type: number;
image: {
lloc: string;
sloc: string;
};
}>;
};
cell_qun_info: {
qun_id: string;
};
}
/**
* 创建相册动态发布请求参数
* @param qunId 群号
* @param uin 用户QQ号
* @param albumId 相册ID
* @param lloc 信息
* @param sloc 信息(可选默认与lloc相同)
* @returns 动态发布请求参数对象
*/
export function createAlbumFeedPublish(
qunId: string,
uin: string,
albumId: string,
lloc: string,
sloc?: string
): AlbumFeedLikePublish {
return {
cell_common: {
time: Date.now(),
feed_id: ""
},
cell_user_info: {
user: {
uin: uin
}
},
cell_media: {
album_id: albumId,
batch_id: 0,
media_items: [{
type: 0,
image: {
lloc: lloc,
sloc: sloc || lloc
}
}]
},
cell_qun_info: {
qun_id: qunId
}
};
}

177
src/core/data/webapi.ts Normal file
View File

@@ -0,0 +1,177 @@
export interface ControlReq {
appid?: string;
asy_upload?: number;
biz_req?: BizReq;
check_type?: number;
checksum?: string;
cmd?: string;
env?: Env;
file_len?: number;
model?: number;
session?: string;
token?: Token;
uin?: string;
}
export interface BizReq {
iAlbumTypeID: number;
iBatchID: number;
iBitmap: number;
iDistinctUse: number;
iNeedFeeds: number;
iPicHight: number;
iPicWidth: number;
iUploadTime: number;
iUploadType: number;
iUpPicType: number;
iWaterType: number;
mapExt: MapExt;
sAlbumID: string;
sAlbumName: string;
sPicDesc: string;
sPicPath: string;
sPicTitle: string;
stExtendInfo: StExtendInfo;
}
export interface MapExt {
appid: string;
userid: string;
}
export interface StExtendInfo {
mapParams: MapParams;
}
export interface MapParams {
batch_num: string;
photo_num: string;
video_num: string;
}
export interface Env {
deviceInfo: string;
refer: string;
}
export interface Token {
appid: number;
data: string;
type: number;
}
export function qunAlbumControl({
uin,
group_id,
pskey,
pic_md5,
img_size,
img_name,
sAlbumName,
sAlbumID,
photo_num = "1",
video_num = "0",
batch_num = "1"
}: {
uin: string,
group_id: string,
pskey: string,
pic_md5: string,
img_size: number,
img_name: string,
sAlbumName: string,
sAlbumID: string,
photo_num?: string,
video_num?: string,
batch_num?: string
}
): {
control_req: ControlReq[]
} {
const timestamp = Math.floor(Date.now() / 1000);
return {
control_req: [
{
uin: uin,
token: {
type: 4,
data: pskey,
appid: 5
},
appid: "qun",
checksum: pic_md5,
check_type: 0,
file_len: img_size,
env: {
refer: "qzone",
deviceInfo: "h5"
},
model: 0,
biz_req: {
sPicTitle: img_name,
sPicDesc: "",
sAlbumName: sAlbumName,
sAlbumID: sAlbumID,
iAlbumTypeID: 0,
iBitmap: 0,
iUploadType: 0,
iUpPicType: 0,
iBatchID: timestamp,
sPicPath: "",
iPicWidth: 0,
iPicHight: 0,
iWaterType: 0,
iDistinctUse: 0,
iNeedFeeds: 1,
iUploadTime: timestamp,
mapExt: {
appid: "qun",
userid: group_id
},
stExtendInfo: {
mapParams: {
photo_num: photo_num,
video_num: video_num,
batch_num: batch_num
}
}
},
session: "",
asy_upload: 0,
cmd: "FileUpload"
}]
}
}
export function createStreamUpload(
{
uin,
session,
offset,
seq,
end,
slice_size,
data
}: { uin: string, session: string, offset: number, seq: number, end: number, slice_size: number, data: string }
) {
return {
uin: uin,
appid: "qun",
session: session,
offset: offset,//分片起始位置
data: data,//base64编码数据
checksum: "",
check_type: 0,
retry: 0,//重试次数
seq: seq,//分片序号
end: end,//分片结束位置 文件总大小
cmd: "FileUpload",
slice_size: slice_size,//分片大小16KB 16384
biz_req: {
iUploadType: 3
}
};
}

View File

@@ -322,5 +322,69 @@
"9.9.20-36580": {
"appid": 537298473,
"qua": "V1_WIN_NQ_9.9.20_36580_GW_B"
},
"9.9.20-37012": {
"appid": 537304071,
"qua": "V1_WIN_NQ_9.9.20_37012_GW_B"
},
"3.2.18-37012": {
"appid": 537304107,
"qua": "V1_LNX_NQ_3.2.18_37012_GW_B"
},
"3.2.18-37051": {
"appid": 537304158,
"qua": "V1_LNX_NQ_3.2.18_37051_GW_B"
},
"9.9.20-37051": {
"appid": 537304122,
"qua": "V1_WIN_NQ_9.9.20_37051_GW_B"
},
"9.9.20-37475": {
"appid": 537304173,
"qua": "V1_WIN_NQ_9.9.20_37475_GW_B"
},
"3.2.18-37475": {
"appid": 537304210,
"qua": "V1_LNX_NQ_3.2.18_37475_GW_B"
},
"9.9.20-37625": {
"appid": 537304224,
"qua": "V1_WIN_NQ_9.9.20_37625_GW_B"
},
"3.2.18-37625": {
"appid": 537304261,
"qua": "V1_LNX_NQ_3.2.18_37625_GW_B"
},
"9.9.21-38503": {
"appid": 537307604,
"qua": "V1_WIN_NQ_9.9.21_38503_GW_B"
},
"3.2.19-38503": {
"appid": 537307640,
"qua": "V1_LNX_NQ_3.2.19_38503_GW_B"
},
"3.2.19-38626": {
"appid": 537307691,
"qua": "V1_LNX_NQ_3.2.19_38626_GW_B"
},
"9.9.21-38711": {
"appid": 537307655,
"qua": "V1_WIN_NQ_9.9.21_38626_GW_B"
},
"9.9.21-38960": {
"appid": 537313855,
"qua": "V1_WIN_NQ_9.9.21_38960_GW_B"
},
"3.2.19-38960": {
"appid": 537313891,
"qua": "V1_LNX_NQ_3.2.19_38960_GW_B"
},
"3.2.19-39038": {
"appid": 537313942,
"qua": "V1_LNX_NQ_3.2.19_39038_GW_B"
},
"9.9.21-39038": {
"appid": 537313906,
"qua": "V1_WIN_NQ_9.9.21_39038_GW_B"
}
}

View File

@@ -404,8 +404,8 @@
"recv": "AFBF520"
},
"9.9.20-36580-x64": {
"send":"30824B8",
"recv":"3085C5C"
"send": "30824B8",
"recv": "3085C5C"
},
"3.2.18-36580-x64": {
"send": "B0853E0",
@@ -414,5 +414,101 @@
"3.2.18-36580-arm64": {
"send": "793DAC8",
"recv": "7941458"
},
"3.2.18-37012-x64": {
"send": "B20F960",
"recv": "B2133E0"
},
"3.2.18-37012-arm64": {
"send": "7A19E00",
"recv": "7A1D790"
},
"9.9.20-37012-x64": {
"send": "30CC958",
"recv": "30D00FC"
},
"3.2.18-37051-x64": {
"send": "B20F960",
"recv": "B2133E0"
},
"3.2.18-37051-arm64": {
"send": "7A19E00",
"recv": "7A1D790"
},
"9.9.20-37051-x64": {
"send": "30CC958",
"recv": "30D00FC"
},
"9.9.20-37475-x64": {
"send": "30D30D8",
"recv": "30D687C"
},
"3.2.18-37475-x64": {
"send": "B238EC0",
"recv": "B23C940"
},
"3.2.18-37475-arm64": {
"send": "7A34B38",
"recv": "7A384C8"
},
"9.9.20-37625-x64": {
"send": "30D39D8",
"recv": "30D717C"
},
"3.2.18-37625-x64": {
"send": "B2397E0",
"recv": "B23D260"
},
"3.2.18-37625-arm64": {
"send": "7A350D8",
"recv": "7A38A68"
},
"9.9.21-38503-x64": {
"send": "3105F38",
"recv": "31096DC"
},
"3.2.19-38503-x64": {
"send": "B2C1A60",
"recv": "B2C54E0"
},
"3.2.19-38626-x64": {
"send": "B2C1BE0",
"recv": "B2C5660"
},
"9.9.21-38626-x64": {
"send": "310A758",
"recv": "310DEFC"
},
"3.2.19-38626-arm64": {
"send": "7A8A490",
"recv": "7A8DE20"
},
"9.9.21-38711-x64": {
"send": "310A758",
"recv": "310DEFC"
},
"3.2.19-38960-x64": {
"send": "B3740E0",
"recv": "B377B60"
},
"9.9.21-38960-x64": {
"send": "313F7D8",
"recv": "3142F7C"
},
"3.2.19-38960-arm64": {
"send": "7B01D98",
"recv": "7B05728"
},
"3.2.19-39038-x64": {
"send": "B3759E0",
"recv": "B379460"
},
"3.2.19-39038-arm64": {
"send": "7B025C8",
"recv": "7B05F58"
},
"9.9.21-39038-x64": {
"send": "313FB58",
"recv": "31432FC"
}
}

View File

@@ -40,7 +40,6 @@ export class NodeIKernelBuddyListener {
}
onDelBatchBuddyInfos(_arg: unknown): any {
console.log('onDelBatchBuddyInfos not implemented', ...arguments);
}
onDoubtBuddyReqChange(_arg:

View File

@@ -1,4 +1,3 @@
import { LRUCache } from '@/common/lru-cache';
import crypto, { createHash } from 'crypto';
import { OidbPacket, PacketHexStr } from '@/core/packet/transformer/base';
import { LogStack } from '@/core/packet/context/clientContext';
@@ -7,7 +6,6 @@ import { PacketLogger } from '@/core/packet/context/loggerContext';
export interface RecvPacket {
type: string, // 仅recv
trace_id_md5?: string,
data: RecvPacketData
}
@@ -30,7 +28,7 @@ function randText(len: number): string {
export abstract class IPacketClient {
protected readonly napcore: NapCoreContext;
protected readonly logger: PacketLogger;
protected readonly cb = new LRUCache<string, (json: RecvPacketData) => Promise<void>>(500); // trace_id-type callback
protected readonly cb = new Map<string, (json: RecvPacketData) => Promise<any> | any>(); // hash-type callback
logStack: LogStack;
available: boolean = false;
@@ -44,24 +42,21 @@ export abstract class IPacketClient {
abstract init(pid: number, recv: string, send: string): Promise<void>;
abstract sendCommandImpl(cmd: string, data: string, trace_id: string): void;
abstract sendCommandImpl(cmd: string, data: string, hash: string, timeout: number): void;
private async registerCallback(trace_id: string, type: string, callback: (json: RecvPacketData) => Promise<void>): Promise<void> {
this.cb.put(createHash('md5').update(trace_id).digest('hex') + type, callback);
}
private async sendCommand(cmd: string, data: string, trace_id: string, rsp: boolean = false, timeout: number = 20000, sendcb: (json: RecvPacketData) => void = () => {
private async sendCommand(cmd: string, data: string, trace_data: string, rsp: boolean = false, timeout: number = 20000, sendcb: (json: RecvPacketData) => void = () => {
}): Promise<RecvPacketData> {
return new Promise<RecvPacketData>((resolve, reject) => {
if (!this.available) {
reject(new Error('packetBackend 当前不可用!'));
}
let hash = createHash('md5').update(trace_data).digest('hex');
const timeoutHandle = setTimeout(() => {
reject(new Error(`sendCommand timed out after ${timeout} ms for ${cmd} with trace_id ${trace_id}`));
this.cb.delete(hash + 'send');
this.cb.delete(hash + 'recv');
reject(new Error(`sendCommand timed out after ${timeout} ms for ${cmd} with hash ${hash}`));
}, timeout);
this.registerCallback(trace_id, 'send', async (json: RecvPacketData) => {
this.cb.set(hash + 'send', async (json: RecvPacketData) => {
sendcb(json);
if (!rsp) {
clearTimeout(timeoutHandle);
@@ -70,25 +65,24 @@ export abstract class IPacketClient {
});
if (rsp) {
this.registerCallback(trace_id, 'recv', async (json: RecvPacketData) => {
this.cb.set(hash + 'recv', async (json: RecvPacketData) => {
clearTimeout(timeoutHandle);
resolve(json);
});
}
this.sendCommandImpl(cmd, data, trace_id);
this.sendCommandImpl(cmd, data, hash, timeout);
});
}
async sendPacket(cmd: string, data: PacketHexStr, rsp = false): Promise<RecvPacketData> {
async sendPacket(cmd: string, data: PacketHexStr, rsp = false, timeout = 20000): Promise<RecvPacketData> {
const md5 = crypto.createHash('md5').update(data).digest('hex');
const trace_id = (randText(4) + md5 + data).slice(0, data.length / 2);
return this.sendCommand(cmd, data, trace_id, rsp, 20000, async () => {
await this.napcore.sendSsoCmdReqByContend(cmd, trace_id);
const trace_data = (randText(4) + md5 + data).slice(0, data.length / 2);// trace_data
return this.sendCommand(cmd, data, trace_data, rsp, timeout, async () => {
await this.napcore.sendSsoCmdReqByContend(cmd, trace_data);
});
}
async sendOidbPacket(pkt: OidbPacket, rsp = false): Promise<RecvPacketData> {
return this.sendPacket(pkt.cmd, pkt.data, rsp);
async sendOidbPacket(pkt: OidbPacket, rsp = false, timeout = 20000): Promise<RecvPacketData> {
return this.sendPacket(pkt.cmd, pkt.data, rsp, timeout);
}
}

View File

@@ -4,7 +4,6 @@ import { fileURLToPath } from 'url';
import fs from 'fs';
import { IPacketClient } from '@/core/packet/client/baseClient';
import { constants } from 'node:os';
import { LRUCache } from '@/common/lru-cache';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
@@ -18,7 +17,8 @@ export interface NativePacketExportType {
export class NativePacketClient extends IPacketClient {
private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64'];
private readonly MoeHooExport: { exports: NativePacketExportType } = { exports: {} };
private readonly sendEvent = new LRUCache<number, string>(500); // seq->trace_id
private readonly sendEvent = new Map<number, string>(); // seq - hash
private readonly timeEvent = new Map<string, NodeJS.Timeout>(); // hash - timeout
constructor(napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) {
super(napCore, logger, logStack);
@@ -45,25 +45,31 @@ export class NativePacketClient extends IPacketClient {
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, _uin: string, cmd: string, seq: number, hex_data: string) => {
const trace_id = createHash('md5').update(Buffer.from(hex_data, 'hex')).digest('hex');
if (type === 0 && this.cb.get(trace_id + 'recv')) {
const hash = createHash('md5').update(Buffer.from(hex_data, 'hex')).digest('hex');
if (type === 0 && this.cb.get(hash + 'recv')) {
//此时为send 提取seq
this.sendEvent.put(seq, trace_id);
this.sendEvent.set(seq, hash);
setTimeout(() => {
this.sendEvent.delete(seq);
this.timeEvent.delete(hash);
}, +(this.timeEvent.get(hash) ?? 20000));
//正式send完成 无recv v
//均无异常 v
}
if (type === 1 && this.sendEvent.get(seq)) {
//此时为recv 调用callback
const trace_id = this.sendEvent.get(seq);
const callback = this.cb.get(trace_id + 'recv');
// console.log('callback:', callback, trace_id);
const hash = this.sendEvent.get(seq);
const callback = this.cb.get(hash + 'recv');
callback?.({ seq, cmd, hex_data });
}
}, this.napcore.config.o3HookMode == 1);
this.available = true;
}
sendCommandImpl(cmd: string, data: string, trace_id: string): void {
const trace_id_md5 = createHash('md5').update(trace_id).digest('hex');
this.MoeHooExport.exports.SendPacket?.(cmd, data, trace_id_md5);
this.cb.get(trace_id_md5 + 'send')?.({ seq: 0, cmd, hex_data: '' });
sendCommandImpl(cmd: string, data: string, hash: string, timeout: number): void {
this.timeEvent.set(hash, setTimeout(() => {
this.timeEvent.delete(hash);//考虑情况为正式send都没进
}, timeout));
this.MoeHooExport.exports.SendPacket?.(cmd, data, hash);
this.cb.get(hash + 'send')?.({ seq: 0, cmd, hex_data: '' });
}
}

View File

@@ -73,8 +73,8 @@ export class PacketClientContext {
await this._client.init(pid, recv, send);
}
async sendOidbPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
const raw = await this._client.sendOidbPacket(pkt, rsp);
async sendOidbPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T, timeout?: number): Promise<T extends true ? Buffer : void> {
const raw = await this._client.sendOidbPacket(pkt, rsp, timeout);
return (rsp ? Buffer.from(raw.hex_data, 'hex') : undefined) as T extends true ? Buffer : void;
}

View File

@@ -34,10 +34,13 @@ export class PacketOperationContext {
const req = trans.SendPoke.build(is_group, peer, target ?? peer);
await this.context.client.sendOidbPacket(req);
}
async FetchRkey() {
async SetGroupTodo(groupUin: number, msgSeq: string) {
const req = trans.SetGroupTodo.build(groupUin, msgSeq);
await this.context.client.sendOidbPacket(req, true);
}
async FetchRkey(timeout: number = 10000) {
const req = trans.FetchRkey.build();
const resp = await this.context.client.sendOidbPacket(req, true);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.FetchRkey.parse(resp);
return res.data.rkeyList;
}
@@ -119,37 +122,37 @@ export class PacketOperationContext {
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetPttUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>) {
async GetPttUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>,timeout: number = 20000) {
const req = trans.DownloadPtt.build(selfUid, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadPtt.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetVideoUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>) {
async GetVideoUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
const req = trans.DownloadVideo.build(selfUid, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadVideo.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
const req = trans.DownloadGroupImage.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadImage.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
const req = trans.DownloadGroupPtt.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadImage.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupVideoUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
async GetGroupVideoUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
const req = trans.DownloadGroupVideo.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadImage.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
@@ -240,16 +243,16 @@ export class PacketOperationContext {
return res.rename.retCode;
}
async GetGroupFileUrl(groupUin: number, fileUUID: string) {
async GetGroupFileUrl(groupUin: number, fileUUID: string,timeout: number = 20000) {
const req = trans.DownloadGroupFile.build(groupUin, fileUUID);
const resp = await this.context.client.sendOidbPacket(req, true);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadGroupFile.parse(resp);
return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`;
}
async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string) {
async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string, timeout: number = 20000) {
const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5);
const resp = await this.context.client.sendOidbPacket(req, true);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadPrivateFile.parse(resp);
return `http://${res.body?.result?.server}:${res.body?.result?.port}${res.body?.result?.url?.slice(8)}&isthumb=0`;
}

View File

@@ -0,0 +1,24 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
class SetGroupTodo extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
constructor() {
super();
}
build(peer: number, msgSeq: string): OidbPacket {
const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XF90_1).encode({
groupUin: peer,
msgSeq: BigInt(msgSeq)
});
return OidbBase.build(0xF90, 1, data);
}
parse(data: Buffer) {
return OidbBase.parse(data);
}
}
export default new SetGroupTodo();

View File

@@ -8,3 +8,4 @@ export { default as SetSpecialTitle } from './SetSpecialTitle';
export { default as ImageOCR } from './ImageOCR';
export { default as MoveGroupFile } from './MoveGroupFile';
export { default as RenameGroupFile } from './RenameGroupFile';
export { default as SetGroupTodo } from './SetGroupTodo';

View File

@@ -30,3 +30,4 @@ export * from './oidb/Oidb.0xED3_1';
export * from './oidb/Oidb.0XFE1_2';
export * from './oidb/OidbBase';
export * from './oidb/Oidb.0xE07';
export * from './oidb/Oidb.0xf90_1';

View File

@@ -0,0 +1,6 @@
import { ProtoField, ScalarType } from "@napneko/nap-proto-core";
export const OidbSvcTrpcTcp0XF90_1 = {
groupUin: ProtoField(1, ScalarType.UINT32),
msgSeq: ProtoField(2, ScalarType.UINT64),
};

View File

@@ -1,22 +1,52 @@
import { AlbumCommentReplyContent, AlbumFeedLikePublish, AlbumListRequest, AlbumMediaFeed } from "../data/album";
export interface NodeIKernelAlbumService {
setAlbumServiceInfo(...args: unknown[]): unknown;// needs 3 arguments
getMainPage(...args: unknown[]): unknown;// needs 2 arguments
getAlbumList(...args: unknown[]): unknown;// needs 1 arguments
getAlbumList(params: {
qun_id: string,
attach_info: string,
seq: number,
request_time_line: {
request_invoke_time: string
}
}): Promise<{
response: {
seq: number,
result: number,
errMs: string,//没错就是errMs不是errMsg
trace_id: string,
is_from_cache: boolean,
request_time_line: unknown,
album_list: Array<{ name: string, album_id: string }>,
attach_info: string,
has_more: boolean,
right: unknown,
banner: unknown
}
}>
getAlbumInfo(...args: unknown[]): unknown;// needs 1 arguments
deleteAlbum(...args: unknown[]): unknown;// needs 3 arguments
addAlbum(...args: unknown[]): unknown;// needs 2 arguments
deleteMedias(...args: unknown[]): unknown;// needs 4 arguments
deleteMedias(seq: number, group_code: string, album_id: string, media_ids: string[], ban_ids: unknown[]): Promise<unknown>;// needs 4 arguments
modifyAlbum(...args: unknown[]): unknown;// needs 3 arguments
getMediaList(...args: unknown[]): unknown;// needs 1 arguments
getMediaList(param: AlbumListRequest): Promise<{
response: {
seq: number,
result: number,
errMs: string,//没错就是errMs不是errMsg
trace_id: string,
request_time_line: unknown,
}
}>;// needs 1 arguments
quoteToQzone(...args: unknown[]): unknown;// needs 1 arguments
@@ -35,12 +65,36 @@ export interface NodeIKernelAlbumService {
getQunLikes(...args: unknown[]): unknown;// needs 4 arguments
deleteQunFeed(...args: unknown[]): unknown;// needs 1 arguments
doQunComment(...args: unknown[]): unknown;// needs 6 arguments
//seq random
//stCommonExt {"map_info":[],"map_bytes_info":[],"map_user_account":[]}
//qunId string
doQunComment(seq: number, ext: {
map_info: unknown[],
map_bytes_info: unknown[],
map_user_account: unknown[]
},
qunId: string,
commentType: number,
feed: AlbumMediaFeed,
content: AlbumCommentReplyContent,
): Promise<unknown>;// needs 6 arguments
doQunReply(...args: unknown[]): unknown;// needs 7 arguments
doQunLike(...args: unknown[]): unknown;// needs 5 arguments
doQunLike(
seq: number,
ext: {
map_info: unknown[],
map_bytes_info: unknown[],
map_user_account: unknown[]
},
param: {
//{"id":"421_1_0_1012959257|V61Yiali4PELg90bThrH4Bo2iI1M5Kab|V5bCgAxMDEyOTU5MjU3e*KqaLVYdic!^||^421_1_0_1012959257|V61Yiali4PELg90bThrH4Bo2iI1M5Kab|17560336594^||^1","status":1}
id: string,
status: number
},
like: AlbumFeedLikePublish
): Promise<unknown>;// needs 5 arguments
getRedPoints(...args: unknown[]): unknown;// needs 3 arguments

View File

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

View File

@@ -0,0 +1,12 @@
import { EventType } from "@/onebot/event/OneBotEvent";
import type { PluginModule } from "@/onebot/network/plugin-manger";
const plugin_init: PluginModule["plugin_init"] = async (_core, _obContext, _actions, _instance) => {
console.log(`[Plugin: example] 插件已初始化`);
}
const plugin_onmessage: PluginModule["plugin_onmessage"] = async (adapter, _core, _obCtx, event, actions, instance) => {
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
}
}
export { plugin_init, plugin_onmessage };

View File

@@ -0,0 +1,10 @@
{
"name": "advanced-plugin",
"version": "1.0.0",
"type": "module",
"main": "index.mjs",
"description": "一个高级的 NapCat 插件示例",
"scripts": {
"build": "vite build"
}
}

View File

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

View File

@@ -15,7 +15,7 @@ import { FFmpegService } from '@/common/ffmpeg';
//Framework ES入口文件
export async function getWebUiUrl() {
const WebUiConfigData = (await WebUiConfig.GetWebUIConfig());
return 'http://127.0.0.1:' + webUiRuntimePort + '/webui/?token=' + WebUiConfigData.token;
return 'http://127.0.0.1:' + webUiRuntimePort + '/webui/?token=' + encodeURIComponent(WebUiConfigData.token);
}
export async function NCoreInitFramework(
@@ -37,7 +37,7 @@ export async function NCoreInitFramework(
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion());
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
downloadFFmpegIfNotExists(logger).then(({ path, reset }) => {
if (reset && path) {

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,24 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
album_id: Type.String(),
lloc: Type.String()
});
type Payload = Static<typeof SchemaData>;
export class DelGroupAlbumMedia extends OneBotAction<Payload, unknown> {
override actionName = ActionName.DelGroupAlbumMedia;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return await this.core.apis.WebApi.deleteAlbumMediaByNTQQ(
payload.group_id,
payload.album_id,
payload.lloc
);
}
}

View File

@@ -0,0 +1,26 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
album_id: Type.String(),
lloc: Type.String(),
content: Type.String(),
});
type Payload = Static<typeof SchemaData>;
export class DoGroupAlbumComment extends OneBotAction<Payload, unknown> {
override actionName = ActionName.DoGroupAlbumComment;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return await this.core.apis.WebApi.doAlbumMediaPlainCommentByNTQQ(
payload.group_id,
payload.album_id,
payload.lloc,
payload.content
);
}
}

View File

@@ -0,0 +1,24 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
album_id: Type.String(),
attach_info: Type.String({ default: "" }),
});
type Payload = Static<typeof SchemaData>;
export class GetGroupAlbumMediaList extends OneBotAction<Payload, unknown> {
override actionName = ActionName.GetGroupAlbumMediaList;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return await this.core.apis.WebApi.getAlbumMediaListByNTQQ(
payload.group_id,
payload.album_id,
payload.attach_info
);
}
}

View File

@@ -0,0 +1,19 @@
import { NTQQWebApi } from '@/core/apis';
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String()
});
type Payload = Static<typeof SchemaData>;
export class GetQunAlbumList extends OneBotAction<Payload,Awaited<ReturnType<NTQQWebApi['getAlbumListByNTQQ']>>['response']['album_list']> {
override actionName = ActionName.GetQunAlbumList;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return (await this.core.apis.WebApi.getAlbumListByNTQQ(payload.group_id)).response.album_list;
}
}

View File

@@ -0,0 +1,27 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
album_id: Type.String(),
lloc: Type.String(),
id: Type.String(),//421_1_0_1012959257|V61Yiali4PELg90bThrH4Bo2iI1M5Kab|V5bCgAxMDEyOTU5MjU3.PyqaPndPxg!^||^421_1_0_1012959257|V61Yiali4PELg90bThrH4Bo2iI1M5Kab|17560363448^||^1
set: Type.Boolean({default: true})//true=点赞 false=取消点赞 未实现
});
type Payload = Static<typeof SchemaData>;
export class SetGroupAlbumMediaLike extends OneBotAction<Payload, unknown> {
override actionName = ActionName.SetGroupAlbumMediaLike;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return await this.core.apis.WebApi.doAlbumMediaLikeByNTQQ(
payload.group_id,
payload.album_id,
payload.lloc,
payload.id
);
}
}

View File

@@ -0,0 +1,31 @@
import { uriToLocalFile } from '@/common/file';
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
import { existsSync } from 'node:fs';
import { unlink } from 'node:fs/promises';
const SchemaData = Type.Object({
group_id: Type.String(),
album_id: Type.String(),
album_name: Type.String(),
file: Type.String()
});
type Payload = Static<typeof SchemaData>;
export class UploadImageToQunAlbum extends OneBotAction<Payload, unknown> {
override actionName = ActionName.UploadImageToQunAlbum;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const downloadResult = await uriToLocalFile(this.core.NapCatTempPath, payload.file);
try {
return await this.core.apis.WebApi.uploadImageToQunAlbum(payload.group_id, payload.album_id, payload.album_name, downloadResult.path);
} finally {
if (downloadResult.path && existsSync(downloadResult.path)) {
await unlink(downloadResult.path);
}
}
}
}

View File

@@ -130,7 +130,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
throw new Error('消息不存在或已过期');
}
// 6. 解析消息内容
const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;
const resMsg = (await this.obContext.apis.MsgApi.parseMessage(singleMsg, 'array', true));
const forwardContent = (resMsg?.message?.[0] as OB11MessageForward)?.data?.content;
if (forwardContent) {

View File

@@ -14,7 +14,10 @@ const SchemaData = Type.Object({
user_id: Type.String(),
message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }),
reverseOrder: Type.Boolean({ default: false })
reverseOrder: Type.Boolean({ default: false }),
disable_get_url: Type.Boolean({ default: false }),
parse_mult_msg: Type.Boolean({ default: true }),
quick_reply: Type.Boolean({ default: false }),
});
@@ -41,7 +44,7 @@ export default class GetFriendMsgHistory extends OneBotAction<Payload, Response>
}));
//烘焙消息
const ob11MsgList = (await Promise.all(
msgList.map(msg => this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat)))
msgList.map(msg => this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat, payload.parse_mult_msg, payload.disable_get_url)))
).filter(msg => msg !== undefined);
return { 'messages': ob11MsgList };
}

View File

@@ -14,7 +14,10 @@ const SchemaData = Type.Object({
group_id: Type.String(),
message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }),
reverseOrder: Type.Boolean({ default: false })
reverseOrder: Type.Boolean({ default: false }),
disable_get_url: Type.Boolean({ default: false }),
parse_mult_msg: Type.Boolean({ default: true }),
quick_reply: Type.Boolean({ default: false }),
});
@@ -39,7 +42,7 @@ export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction<Payload, Re
}));
//烘焙消息
const ob11MsgList = (await Promise.all(
msgList.map(msg => this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat)))
msgList.map(msg => this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat, payload.parse_mult_msg, payload.disable_get_url, payload.quick_reply)))
).filter(msg => msg !== undefined);
return { 'messages': ob11MsgList };
}

View File

@@ -1,5 +1,5 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { NTGroupRequestOperateTypes } from '@/core/types';
import { GroupNotify, NTGroupRequestOperateTypes } from '@/core/types';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
@@ -7,6 +7,7 @@ const SchemaData = Type.Object({
flag: Type.Union([Type.String(), Type.Number()]),
approve: Type.Optional(Type.Union([Type.Boolean(), Type.String()])),
reason: Type.Optional(Type.Union([Type.String({ default: ' ' }), Type.Null()])),
count: Type.Optional(Type.Number({ default: 100 })),
});
type Payload = Static<typeof SchemaData>;
@@ -19,8 +20,12 @@ export default class SetGroupAddRequest extends OneBotAction<Payload, null> {
const flag = payload.flag.toString();
const approve = payload.approve?.toString() !== 'false';
const reason = payload.reason ?? ' ';
const count = payload.count;
const invite_notify = this.obContext.apis.MsgApi.notifyGroupInvite.get(flag);
const { doubt, notify } = invite_notify ? { doubt: false, notify: invite_notify } : await this.findNotify(flag);
const { doubt, notify } = invite_notify ? {
doubt: false,
notify: invite_notify,
} : await this.findNotify(flag, count);
if (!notify) {
throw new Error('No such request');
}
@@ -33,12 +38,15 @@ export default class SetGroupAddRequest extends OneBotAction<Payload, null> {
return null;
}
private async findNotify(flag: string) {
let notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(false, 100)).find(e => e.seq == flag);
private async findNotify(flag: string, count: number = 100): Promise<{
doubt: boolean,
notify: GroupNotify | undefined
}> {
let notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(false, count)).find(e => e.seq == flag);
if (!notify) {
notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(true, 100)).find(e => e.seq == flag);
notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(true, count)).find(e => e.seq == flag);
return { doubt: true, notify };
}
return { doubt: false, notify };
}
}
}

View File

@@ -123,10 +123,24 @@ import SetGroupKickMembers from './extends/SetGroupKickMembers';
import { GetGroupDetailInfo } from './group/GetGroupDetailInfo';
import GetGroupAddRequest from './extends/GetGroupAddRequest';
import { GetCollectionList } from './extends/GetCollectionList';
import { SetGroupTodo } from './packet/SetGroupTodo';
import { GetQunAlbumList } from './extends/GetQunAlbumList';
import { UploadImageToQunAlbum } from './extends/UploadImageToQunAlbum';
import { DoGroupAlbumComment } from './extends/DoGroupAlbumComment';
import { GetGroupAlbumMediaList } from './extends/GetGroupAlbumMediaList';
import { SetGroupAlbumMediaLike } from './extends/SetGroupAlbumMediaLike';
import { DelGroupAlbumMedia } from './extends/DelGroupAlbumMedia';
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
const actionHandlers = [
new DelGroupAlbumMedia(obContext, core),
new SetGroupAlbumMediaLike(obContext, core),
new DoGroupAlbumComment(obContext, core),
new GetGroupAlbumMediaList(obContext, core),
new GetQunAlbumList(obContext, core),
new UploadImageToQunAlbum(obContext, core),
new SetGroupTodo(obContext, core),
new GetGroupDetailInfo(obContext, core),
new SetGroupKickMembers(obContext, core),
new SetGroupAddOption(obContext, core),

View File

@@ -16,6 +16,9 @@ class DeleteMsg extends OneBotAction<Payload, void> {
async _handle(payload: Payload) {
const msg = MessageUnique.getMsgIdAndPeerByShortId(Number(payload.message_id));
if (msg) {
this.obContext.recallEventCache.set(msg.MsgId, setTimeout(() => {
this.obContext.recallEventCache.delete(msg.MsgId);
}, 5000));
await this.core.apis.MsgApi.recallMsg(msg.Peer, msg.MsgId);
} else {
throw new Error('Recall failed');

View File

@@ -28,13 +28,13 @@ class GetMsg extends OneBotAction<Payload, OB11Message> {
throw new Error('消息不存在');
}
const peer = { guildId: '', peerUid: msgIdWithPeer?.Peer.peerUid, chatType: msgIdWithPeer.Peer.chatType };
const orimsg = this.obContext.recallMsgCache.get(msgIdWithPeer.MsgId);
//const orimsg = this.obContext.recallMsgCache.get(msgIdWithPeer.MsgId);
let msg: RawMessage|undefined;
if (orimsg) {
msg = orimsg;
} else {
// if (orimsg) {
// msg = orimsg;
// } else {
msg = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgIdWithPeer?.MsgId || payload.message_id.toString()])).msgList[0];
}
//}
if (!msg) throw Error('消息不存在');
const retMsg = await this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat);
if (!retMsg) throw Error('消息为空');

View File

@@ -130,7 +130,7 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
);
if (getSpecialMsgNum(payload, OB11MessageDataType.node)) {
const packetMode = this.core.apis.PacketApi.available;
const packetMode = this.core.apis.PacketApi.packetStatus;
let returnMsgAndResId: { message: RawMessage | null, res_id?: string } | null;
try {
returnMsgAndResId = packetMode

View File

@@ -4,7 +4,7 @@ import { ActionName, BaseCheckResult } from '@/onebot/action/router';
export abstract class GetPacketStatusDepends<PT, RT> extends OneBotAction<PT, RT> {
protected override async check(payload: PT): Promise<BaseCheckResult>{
if (!this.core.apis.PacketApi.available) {
if (!this.core.apis.PacketApi.packetStatus) {
return {
valid: false,
message: 'packetBackend不可用请参照文档 https://napneko.github.io/config/advanced 和启动日志检查packetBackend状态或进行配置' +

View File

@@ -0,0 +1,31 @@
import { MessageUnique } from '@/common/message-unique';
import { ChatType, Peer } from '@/core';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
import { ActionName } from '../router';
const SchemaData = Type.Object({
group_id: Type.String(),
message_id: Type.String(),
message_seq: Type.Optional(Type.String())
});
type Payload = Static<typeof SchemaData>;
export class SetGroupTodo extends GetPacketStatusDepends<Payload, void> {
override payloadSchema = SchemaData;
override actionName = ActionName.SetGroupTodo;
async _handle(payload: Payload) {
if (payload.message_seq) {
return await this.core.apis.PacketApi.pkt.operation.SetGroupTodo(+payload.group_id, payload.message_seq);
}
const peer: Peer = {
chatType: ChatType.KCHATTYPEGROUP,
peerUid: payload.group_id
};
const { MsgId, Peer } = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id) ?? { Peer: peer, MsgId: payload.message_id };
let msg = (await this.core.apis.MsgApi.getMsgsByMsgId(Peer, [MsgId])).msgList[0];
if (!msg) throw new Error('消息不存在');
await this.core.apis.PacketApi.pkt.operation.SetGroupTodo(+payload.group_id, msg.msgSeq);
}
}

View File

@@ -10,6 +10,13 @@ export interface InvalidCheckResult {
}
export const ActionName = {
DelGroupAlbumMedia: 'del_group_album_media',
SetGroupAlbumMediaLike: 'set_group_album_media_like',
DoGroupAlbumComment: 'do_group_album_comment',
GetGroupAlbumMediaList: 'get_group_album_media_list',
UploadImageToQunAlbum: 'upload_image_to_qun_album',
GetQunAlbumList: 'get_qun_album_list',
SetGroupTodo: 'set_group_todo',
SetGroupKickMembers: 'set_group_kick_members',
SetGroupRobotAddOption: 'set_group_robot_add_option',
SetGroupAddOption: 'set_group_add_option',

View File

@@ -1,7 +1,7 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { unlink, readdir } from 'fs/promises';
import { join } from 'path';
import path, { join } from 'path';
export class CleanCache extends OneBotAction<void, void> {
override actionName = ActionName.CleanCache;
@@ -28,6 +28,37 @@ export class CleanCache extends OneBotAction<void, void> {
// 等待所有删除操作完成
await Promise.all(deletePromises);
let basic_path = path.join(this.core.dataPath, this.core.selfInfo.uin || '10001', 'nt_qq', 'nt_data');
// 需要清理的目录列表
const dirsToClean = ['Pic', 'Ptt', 'Video', 'File', 'log'];
// 清理每个指定目录
for (const dir of dirsToClean) {
const dirPath = path.join(basic_path, dir);
try {
// 检查目录是否存在
const files = await readdir(dirPath).catch(() => null);
if (files) {
// 删除目录下的所有文件
const dirDeletePromises = files.map(async (file) => {
const filePath = path.join(dirPath, file);
try {
await unlink(filePath);
this.core.context.logger.log(`已删除文件: ${filePath}`);
} catch (err: unknown) {
this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`);
}
});
await Promise.all(dirDeletePromises);
this.core.context.logger.log(`目录清理完成: ${dirPath}`);
}
} catch (err: unknown) {
this.core.context.logger.log(`清理目录 ${dirPath} 失败: ${(err as Error).message}`);
}
}
this.core.context.logger.log(`临时文件夹清理完成: ${tempPath}`);
} catch (err: unknown) {

View File

@@ -2,6 +2,7 @@ import { GroupNotifyMsgStatus } from '@/core';
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Notify } from '@/onebot/types';
import { Static, Type } from '@sinclair/typebox';
interface RetData {
invited_requests: Notify[];
@@ -9,11 +10,18 @@ interface RetData {
join_requests: Notify[];
}
export class GetGroupSystemMsg extends OneBotAction<void, RetData> {
override actionName = ActionName.GetGroupSystemMsg;
const SchemaData = Type.Object({
count: Type.Union([Type.Number(), Type.String()], { default: 50 }),
});
async _handle(): Promise<RetData> {
const SingleScreenNotifies = await this.core.apis.GroupApi.getSingleScreenNotifies(false, 50);
type Payload = Static<typeof SchemaData>;
export class GetGroupSystemMsg extends OneBotAction<Payload, RetData> {
override actionName = ActionName.GetGroupSystemMsg;
override payloadSchema = SchemaData;
async _handle(params: Payload): Promise<RetData> {
const SingleScreenNotifies = await this.core.apis.GroupApi.getSingleScreenNotifies(false, +params.count);
const retData: RetData = { invited_requests: [], InvitedRequest: [], join_requests: [] };
const notifyPromises = SingleScreenNotifies.map(async (SSNotify) => {
@@ -43,4 +51,4 @@ export class GetGroupSystemMsg extends OneBotAction<void, RetData> {
retData.invited_requests = retData.InvitedRequest;
return retData;
}
}
}

View File

@@ -189,7 +189,7 @@ export class OneBotGroupApi {
this.core,
parseInt(msg.peerUid),
MessageUnique.getShortIdByMsgId(msgData.msgList[0].msgId)!,
parseInt(msgData.msgList[0].senderUin),
parseInt(msgData.msgList[0].senderUin ?? await this.core.apis.UserApi.getUinByUidV2(msgData.msgList[0].senderUid)),
parseInt(realMsg?.add_digest_uin ?? '0'),
);
}

View File

@@ -46,6 +46,7 @@ import { GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody } from '@/core/p
import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest';
import { LRUCache } from '@/common/lru-cache';
import { cleanTaskQueue } from '@/common/clean-task';
import { registerResource } from '@/common/health';
type RawToOb11Converters = {
[Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: (
@@ -69,7 +70,9 @@ export type SendMessageContext = {
}
export type RecvMessageContext = {
parseMultMsg: boolean
parseMultMsg: boolean,
disableGetUrl: boolean,
quick_reply: boolean
}
function keyCanBeParsed(key: string, parser: RawToOb11Converters): key is keyof RawToOb11Converters {
@@ -109,7 +112,7 @@ export class OneBotMsgApi {
}
},
picElement: async (element, msg, elementWrapper) => {
picElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
try {
const peer = {
chatType: msg.chatType,
@@ -129,7 +132,7 @@ export class OneBotMsgApi {
summary: element.summary,
file: element.fileName,
sub_type: element.picSubType,
url: await this.core.apis.FileApi.getImageUrl(element),
url: disableGetUrl ? (element.filePath ?? '') : await this.core.apis.FileApi.getImageUrl(element),
file_size: element.fileSize,
},
};
@@ -139,7 +142,7 @@ export class OneBotMsgApi {
}
},
fileElement: async (element, msg, elementWrapper) => {
fileElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
const peer = {
chatType: msg.chatType,
peerUid: msg.peerUid,
@@ -147,10 +150,24 @@ export class OneBotMsgApi {
};
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileUuid);
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
if (this.core.apis.PacketApi.available) {
if (this.core.apis.PacketApi.packetStatus && !disableGetUrl) {
let url;
try {
url = await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5)
//url = await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5, 1500)
url = await registerResource(
'file-url-get',
{
resourceFn: async () => {
return await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5, 1500);
},
healthCheckFn: async () => {
return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false);
},
testArgs: [],
healthCheckInterval: 30000,
maxHealthCheckFailures: 3
},
);
} catch (error) {
url = '';
}
@@ -240,7 +257,7 @@ export class OneBotMsgApi {
};
},
replyElement: async (element, msg) => {
replyElement: async (element, msg, _, quick_reply) => {
const peer = {
chatType: msg.chatType,
peerUid: msg.peerUid,
@@ -277,7 +294,10 @@ export class OneBotMsgApi {
: replyMsgList.find(msg => msg.msgSeq === msgSeq);
if (replyMsg) return replyMsg;
if (quick_reply) {
this.core.context.logger.logWarn(`快速回复跳过方法1查询序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
return undefined;
}
this.core.context.logger.logWarn(`方法1查询失败序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
}
@@ -345,7 +365,7 @@ export class OneBotMsgApi {
return null;
},
videoElement: async (element, msg, elementWrapper) => {
videoElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
const peer = {
chatType: msg.chatType,
peerUid: msg.peerUid,
@@ -390,10 +410,24 @@ export class OneBotMsgApi {
}
//开始兜底
if (!videoDownUrl) {
if (this.core.apis.PacketApi.available) {
if (!videoDownUrl && !disableGetUrl) {
if (this.core.apis.PacketApi.packetStatus) {
try {
videoDownUrl = await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid);
//videoDownUrl = await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid, 1500);
videoDownUrl = await registerResource(
'video-url-get',
{
resourceFn: async () => {
return await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid, 1500);
},
healthCheckFn: async () => {
return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false);
},
testArgs: [],
healthCheckInterval: 30000,
maxHealthCheckFailures: 3
},
);
} catch (e) {
this.core.context.logger.logError('获取视频url失败', (e as Error).stack);
videoDownUrl = element.filePath;
@@ -414,7 +448,7 @@ export class OneBotMsgApi {
};
},
pttElement: async (element, msg, elementWrapper) => {
pttElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
const peer = {
chatType: msg.chatType,
peerUid: msg.peerUid,
@@ -422,9 +456,23 @@ export class OneBotMsgApi {
};
const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, '', element.fileName);
let pttUrl = '';
if (this.core.apis.PacketApi.available) {
if (this.core.apis.PacketApi.packetStatus && !disableGetUrl) {
try {
pttUrl = await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid);
pttUrl = await registerResource(
'ptt-url-get',
{
resourceFn: async () => {
return await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid, 1500);
},
healthCheckFn: async () => {
return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false);
},
testArgs: [],
healthCheckInterval: 30000,
maxHealthCheckFailures: 3
},
);
//pttUrl = await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid, 1500);
} catch (e) {
this.core.context.logger.logError('获取语音url失败', (e as Error).stack);
pttUrl = element.filePath;
@@ -908,17 +956,21 @@ export class OneBotMsgApi {
async parseMessage(
msg: RawMessage,
messagePostFormat: string,
parseMultMsg: boolean = true
parseMultMsg: boolean = true,
disableGetUrl: boolean = false,
quick_reply: boolean = false
) {
if (messagePostFormat === 'string') {
return (await this.parseMessageV2(msg, parseMultMsg))?.stringMsg;
return (await this.parseMessageV2(msg, parseMultMsg, disableGetUrl, quick_reply))?.stringMsg;
}
return (await this.parseMessageV2(msg, parseMultMsg))?.arrayMsg;
return (await this.parseMessageV2(msg, parseMultMsg, disableGetUrl, quick_reply))?.arrayMsg;
}
async parseMessageV2(
msg: RawMessage,
parseMultMsg: boolean = true
parseMultMsg: boolean = true,
disableGetUrl: boolean = false,
quick_reply: boolean = false
) {
if (msg.senderUin == '0' || msg.senderUin == '') return;
if (msg.peerUin == '0' || msg.peerUin == '') return;
@@ -939,7 +991,7 @@ export class OneBotMsgApi {
return undefined;
}
const validSegments = await this.parseMessageSegments(msg, parseMultMsg);
const validSegments = await this.parseMessageSegments(msg, parseMultMsg, disableGetUrl, quick_reply);
resMsg.message = validSegments;
resMsg.raw_message = validSegments.map(msg => encodeCQCode(msg)).join('').trim();
@@ -974,6 +1026,7 @@ export class OneBotMsgApi {
private async handleGroupMessage(resMsg: OB11Message, msg: RawMessage) {
resMsg.sub_type = 'normal';
resMsg.group_id = parseInt(msg.peerUin);
resMsg.group_name = msg.peerName;
let member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
if (!member) member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
if (member) {
@@ -1009,7 +1062,7 @@ export class OneBotMsgApi {
}
}
private async parseMessageSegments(msg: RawMessage, parseMultMsg: boolean): Promise<OB11MessageData[]> {
private async parseMessageSegments(msg: RawMessage, parseMultMsg: boolean, disableGetUrl: boolean = false, quick_reply: boolean = false): Promise<OB11MessageData[]> {
const msgSegments = await Promise.allSettled(msg.elements.map(
async (element) => {
for (const key in element) {
@@ -1024,7 +1077,7 @@ export class OneBotMsgApi {
element[key],
msg,
element,
{ parseMultMsg }
{ parseMultMsg, disableGetUrl, quick_reply }
);
if (key === 'faceElement' && !parsedElement) {
return null;
@@ -1178,12 +1231,12 @@ export class OneBotMsgApi {
let url = '';
if (mixElement?.picElement && rawMessage) {
const tempData =
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageImage | undefined;
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: false }) as OB11MessageImage | undefined;
url = tempData?.data.url ?? '';
}
if (mixElement?.videoElement && rawMessage) {
const tempData =
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageVideo | undefined;
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: false }) as OB11MessageVideo | undefined;
url = tempData?.data.url ?? '';
}
return url !== '' ? url : await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', '');

View File

@@ -1,11 +1,10 @@
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
const HttpServerConfigSchema = Type.Object({
name: Type.String({ default: 'http-server' }),
enable: Type.Boolean({ default: false }),
port: Type.Number({ default: 3000 }),
host: Type.String({ default: '0.0.0.0' }),
host: Type.String({ default: '127.0.0.1' }),
enableCors: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: true }),
messagePostFormat: Type.String({ default: 'array' }),
@@ -17,7 +16,7 @@ const HttpSseServerConfigSchema = Type.Object({
name: Type.String({ default: 'http-sse-server' }),
enable: Type.Boolean({ default: false }),
port: Type.Number({ default: 3000 }),
host: Type.String({ default: '0.0.0.0' }),
host: Type.String({ default: '127.0.0.1' }),
enableCors: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: true }),
messagePostFormat: Type.String({ default: 'array' }),
@@ -39,7 +38,7 @@ const HttpClientConfigSchema = Type.Object({
const WebsocketServerConfigSchema = Type.Object({
name: Type.String({ default: 'websocket-server' }),
enable: Type.Boolean({ default: false }),
host: Type.String({ default: '0.0.0.0' }),
host: Type.String({ default: '127.0.0.1' }),
port: Type.Number({ default: 3001 }),
messagePostFormat: Type.String({ default: 'array' }),
reportSelfMessage: Type.Boolean({ default: false }),

View File

@@ -13,8 +13,11 @@ import {
SendStatusType,
NTMsgType,
MessageElement,
ElementType,
NTMsgAtType,
} from '@/core';
import { OB11ConfigLoader } from '@/onebot/config';
import { pendingTokenToSend } from '@/webui/index';
import {
OB11HttpClientAdapter,
OB11WebSocketClientAdapter,
@@ -40,7 +43,6 @@ import { OB11FriendRequestEvent } from '@/onebot/event/request/OB11FriendRequest
import { OB11GroupRequestEvent } from '@/onebot/event/request/OB11GroupRequest';
import { OB11FriendRecallNoticeEvent } from '@/onebot/event/notice/OB11FriendRecallNoticeEvent';
import { OB11GroupRecallNoticeEvent } from '@/onebot/event/notice/OB11GroupRecallNoticeEvent';
import { LRUCache } from '@/common/lru-cache';
import { BotOfflineEvent } from './event/notice/BotOfflineEvent';
import {
NetworkAdapterConfig,
@@ -50,6 +52,8 @@ import {
import { OB11Message } from './types';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
import { OB11PluginMangerAdapter } from './network/plugin-manger';
import { existsSync } from 'node:fs';
//OneBot实现类
export class NapCatOneBot11Adapter {
@@ -61,9 +65,8 @@ export class NapCatOneBot11Adapter {
networkManager: OB11NetworkManager;
actions: ActionMap;
private readonly bootTime = Date.now() / 1000;
recallMsgCache = new LRUCache<string, RawMessage>(100);
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
recallEventCache = new Map<string, NodeJS.Timeout>();
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
@@ -77,7 +80,7 @@ export class NapCatOneBot11Adapter {
this.actions = createActionMap(this, core);
this.networkManager = new OB11NetworkManager();
}
async creatOneBotLog(ob11Config: OneBotConfig) {
async creatOneBotLog (ob11Config: OneBotConfig) {
let log = '[network] 配置加载\n';
for (const key of ob11Config.network.httpServers) {
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
@@ -96,14 +99,44 @@ export class NapCatOneBot11Adapter {
}
return log;
}
async InitOneBot() {
async InitOneBot () {
const selfInfo = this.core.selfInfo;
const ob11Config = this.configLoader.configData;
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false)
.then((user) => {
.then(async (user) => {
selfInfo.nick = user.nick;
this.context.logger.setLogSelfInfo(selfInfo);
// 检查是否有待发送的token
if (pendingTokenToSend) {
this.context.logger.log('[NapCat] [OneBot] 🔐 检测到待发送的WebUI Token开始发送');
try {
await this.core.apis.MsgApi.sendMsg(
{ chatType: ChatType.KCHATTYPEC2C, peerUid: selfInfo.uid, guildId: '' },
[{
elementType: ElementType.TEXT,
elementId: '',
textElement: {
content:
'[NapCat] 温馨提示:\n'+
'WebUI密码为默认密码已进行强制修改\n'+
'新密码: ' +pendingTokenToSend,
atType: NTMsgAtType.ATTYPEUNKNOWN,
atUid: '',
atTinyId: '',
atNtUid: '',
}
}],
5000
);
this.context.logger.log('[NapCat] [OneBot] ✅ WebUI Token 消息发送成功');
} catch (error) {
this.context.logger.logError('[NapCat] [OneBot] ❌ WebUI Token 消息发送失败:', error);
}
}
WebUiDataRuntime.getQQLoginCallback()(true);
})
.catch(e => this.context.logger.logError(e));
@@ -116,6 +149,12 @@ export class NapCatOneBot11Adapter {
// this.networkManager.registerAdapter(
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
// );
if (existsSync(this.context.pathWrapper.pluginPath)) {
this.context.logger.log('[Plugins] 插件目录存在,开始加载插件');
this.networkManager.registerAdapter(
new OB11PluginMangerAdapter('plugin_manager', this.core, this, this.actions)
);
}
for (const key of ob11Config.network.httpServers) {
if (key.enable) {
this.networkManager.registerAdapter(
@@ -169,7 +208,7 @@ export class NapCatOneBot11Adapter {
this.initBuddyListener();
this.initGroupListener();
WebUiDataRuntime.setQQVersion(this.core.context.basicInfoWrapper.getFullQQVesion());
WebUiDataRuntime.setQQVersion(this.core.context.basicInfoWrapper.getFullQQVersion());
WebUiDataRuntime.setQQLoginInfo(selfInfo);
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
@@ -181,7 +220,7 @@ export class NapCatOneBot11Adapter {
}
private async reloadNetwork(prev: OneBotConfig, now: OneBotConfig): Promise<void> {
private async reloadNetwork (prev: OneBotConfig, now: OneBotConfig): Promise<void> {
const prevLog = await this.creatOneBotLog(prev);
const newLog = await this.creatOneBotLog(now);
this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`);
@@ -194,7 +233,7 @@ export class NapCatOneBot11Adapter {
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
}
private async handleConfigChange<CT extends NetworkAdapterConfig>(
private async handleConfigChange<CT extends NetworkAdapterConfig> (
prevConfig: NetworkAdapterConfig[],
nowConfig: NetworkAdapterConfig[],
adapterClass: new (
@@ -226,7 +265,7 @@ export class NapCatOneBot11Adapter {
}
}
private initMsgListener() {
private initMsgListener () {
const msgListener = new NodeIKernelMsgListener();
msgListener.onRecvSysMsg = (msg) => {
this.apis.MsgApi.parseSysMessage(msg)
@@ -306,15 +345,18 @@ export class NapCatOneBot11Adapter {
};
let msg = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeq(peer, msgSeq)).msgList.find(e => e.msgType == NTMsgType.KMSGTYPEGRAYTIPS);
const element = msg?.elements.find(e => !!e.grayTipElement?.revokeElement);
if (element?.grayTipElement?.revokeElement.isSelfOperate && msg) {
await this.core.eventWrapper.registerListen('NodeIKernelMsgListener/onMsgRecall',
(chatType: ChatType, uid: string, msgSeq: string) => {
return chatType === msg?.chatType && uid === msg?.peerUid && msgSeq === msg?.msgSeq;
}
).catch(() => {
msg = undefined;
this.context.logger.logDebug('自操作消息撤回事件');
});
if (msg && element?.grayTipElement?.revokeElement.isSelfOperate) {
const isSelfDevice = this.recallEventCache.has(msg.msgId);
if (isSelfDevice) {
await this.core.eventWrapper.registerListen('NodeIKernelMsgListener/onMsgRecall',
(chatType: ChatType, uid: string, msgSeq: string) => {
return chatType === msg?.chatType && uid === msg?.peerUid && msgSeq === msg?.msgSeq;
}
).catch(() => {
msg = undefined;
this.context.logger.logDebug('自操作消息撤回事件');
});
}
}
if (msg && element) {
const recallEvent = await this.emitRecallMsg(msg, element);
@@ -337,7 +379,7 @@ export class NapCatOneBot11Adapter {
this.context.session.getMsgService().addKernelMsgListener(proxiedListenerOf(msgListener, this.context.logger));
}
private initBuddyListener() {
private initBuddyListener () {
const buddyListener = new NodeIKernelBuddyListener();
buddyListener.onBuddyReqChange = async (reqs) => {
@@ -368,7 +410,7 @@ export class NapCatOneBot11Adapter {
.addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger));
}
private initGroupListener() {
private initGroupListener () {
const groupListener = new NodeIKernelGroupListener();
groupListener.onGroupNotifiesUpdated = async (_, notifies) => {
@@ -461,7 +503,7 @@ export class NapCatOneBot11Adapter {
.addKernelGroupListener(proxiedListenerOf(groupListener, this.context.logger));
}
private async emitMsg(message: RawMessage) {
private async emitMsg (message: RawMessage) {
const network = await this.networkManager.getAllConfig();
this.context.logger.logDebug('收到新消息 RawMessage', message);
await Promise.allSettled([
@@ -470,7 +512,7 @@ export class NapCatOneBot11Adapter {
]);
}
private async handleMsg(message: RawMessage, network: Array<NetworkAdapterConfig>) {
private async handleMsg (message: RawMessage, network: Array<NetworkAdapterConfig>) {
// 过滤无效消息
if (message.msgType === NTMsgType.KMSGTYPENULL) {
return;
@@ -491,17 +533,17 @@ export class NapCatOneBot11Adapter {
}
}
private isSelfMessage(ob11Msg: {
stringMsg: OB11Message;
arrayMsg: OB11Message;
private isSelfMessage (ob11Msg: {
stringMsg: OB11Message
arrayMsg: OB11Message
}): boolean {
return ob11Msg.stringMsg.user_id.toString() == this.core.selfInfo.uin ||
ob11Msg.arrayMsg.user_id.toString() == this.core.selfInfo.uin;
}
private createMsgMap(network: Array<NetworkAdapterConfig>, ob11Msg: {
stringMsg: OB11Message;
arrayMsg: OB11Message;
private createMsgMap (network: Array<NetworkAdapterConfig>, ob11Msg: {
stringMsg: OB11Message
arrayMsg: OB11Message
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
const msgMap: Map<string, OB11Message> = new Map();
network.filter(e => e.enable).forEach(e => {
@@ -519,7 +561,7 @@ export class NapCatOneBot11Adapter {
return msgMap;
}
private handleDebugNetwork(network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
private handleDebugNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
const debugNetwork = network.filter(e => e.enable && e.debug);
if (debugNetwork.length > 0) {
debugNetwork.forEach(adapter => {
@@ -533,7 +575,7 @@ export class NapCatOneBot11Adapter {
}
}
private handleNotReportSelfNetwork(network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
private handleNotReportSelfNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
if (isSelfMsg) {
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
notReportSelfNetwork.forEach(adapter => {
@@ -542,7 +584,7 @@ export class NapCatOneBot11Adapter {
}
}
private async handleGroupEvent(message: RawMessage) {
private async handleGroupEvent (message: RawMessage) {
try {
// 群名片修改事件解析 任何都该判断
if (message.senderUin && message.senderUin !== '0') {
@@ -575,7 +617,7 @@ export class NapCatOneBot11Adapter {
}
}
private async handlePrivateMsgEvent(message: RawMessage) {
private async handlePrivateMsgEvent (message: RawMessage) {
try {
if (message.msgType === NTMsgType.KMSGTYPEGRAYTIPS) {
// 灰条为单元素消息
@@ -593,7 +635,7 @@ export class NapCatOneBot11Adapter {
}
}
private async emitRecallMsg(message: RawMessage, element: MessageElement) {
private async emitRecallMsg (message: RawMessage, element: MessageElement) {
const peer: Peer = { chatType: message.chatType, peerUid: message.peerUid, guildId: '' };
const oriMessageId = MessageUnique.getShortIdByMsgId(message.msgId) ?? MessageUnique.createUniqueMsgId(peer, message.msgId);
if (message.chatType == ChatType.KCHATTYPEC2C) {
@@ -604,7 +646,7 @@ export class NapCatOneBot11Adapter {
return;
}
private async emitFriendRecallMsg(message: RawMessage, oriMessageId: number, element: MessageElement) {
private async emitFriendRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) {
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
if (!operatorUid) return undefined;
return new OB11FriendRecallNoticeEvent(
@@ -614,7 +656,7 @@ export class NapCatOneBot11Adapter {
);
}
private async emitGroupRecallMsg(message: RawMessage, oriMessageId: number, element: MessageElement) {
private async emitGroupRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) {
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
if (!operatorUid) return undefined;
const operatorId = await this.core.apis.UserApi.getUinByUidV2(operatorUid);

View File

@@ -0,0 +1,373 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import { NapCatOneBot11Adapter, OB11Message } from '@/onebot';
import { NapCatCore } from '@/core';
import { PluginConfig } from '../config/config';
import { ActionMap } from '../action';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import fs from 'fs';
import path from 'path';
export interface PluginPackageJson {
name?: string;
version?: string;
main?: string;
}
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
}
export interface LoadedPlugin {
name: string;
version?: string;
pluginPath: string;
entryPath: string;
packageJson?: PluginPackageJson;
module: PluginModule;
}
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig;
constructor(
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) {
const config = {
name: name,
messagePostFormat: 'array',
reportSelfMessage: true,
enable: true,
debug: true,
};
super(name, config, core, obContext, actions);
this.pluginPath = this.core.context.pathWrapper.pluginPath;
}
/**
* 扫描并加载插件
*/
private async loadPlugins(): Promise<void> {
try {
// 确保插件目录存在
if (!fs.existsSync(this.pluginPath)) {
this.logger.logWarn(`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`);
fs.mkdirSync(this.pluginPath, { recursive: true });
return;
}
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
// 扫描文件和目录
for (const item of items) {
if (item.isFile()) {
// 处理单文件插件
await this.loadFilePlugin(item.name);
} else if (item.isDirectory()) {
// 处理目录插件
await this.loadDirectoryPlugin(item.name);
}
}
this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading plugins:`, error);
}
}
/**
* 加载单文件插件 (.mjs, .js)
*/
private async loadFilePlugin(filename: string): Promise<void> {
// 只处理支持的文件类型
if (!this.isSupportedFile(filename)) {
return;
}
const filePath = path.join(this.pluginPath, filename);
const pluginName = path.parse(filename).name;
try {
const module = await this.importModule(filePath);
if (!this.isValidPluginModule(module)) {
this.logger.logWarn(`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`);
return;
}
const plugin: LoadedPlugin = {
name: pluginName,
pluginPath: this.pluginPath,
entryPath: filePath,
module: module
};
await this.registerPlugin(plugin);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading file plugin ${filename}:`, error);
}
}
/**
* 加载目录插件
*/
private async loadDirectoryPlugin(dirname: string): Promise<void> {
const pluginDir = path.join(this.pluginPath, dirname);
try {
// 尝试读取 package.json
let packageJson: PluginPackageJson | undefined;
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
packageJson = JSON.parse(packageContent);
} catch (error) {
this.logger.logWarn(`[Plugin Adapter] Invalid package.json in ${dirname}:`, error);
}
}
// 确定入口文件
const entryFile = this.findEntryFile(pluginDir, packageJson);
if (!entryFile) {
this.logger.logWarn(`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`);
return;
}
const entryPath = path.join(pluginDir, entryFile);
const module = await this.importModule(entryPath);
if (!this.isValidPluginModule(module)) {
this.logger.logWarn(`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`);
return;
}
const plugin: LoadedPlugin = {
name: packageJson?.name || dirname,
version: packageJson?.version,
pluginPath: pluginDir,
entryPath: entryPath,
packageJson: packageJson,
module: module
};
await this.registerPlugin(plugin);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading directory plugin ${dirname}:`, error);
}
}
/**
* 查找插件目录的入口文件
*/
private findEntryFile(pluginDir: string, packageJson?: PluginPackageJson): string | null {
// 优先级package.json main > 默认文件名
const possibleEntries = [
packageJson?.main,
'index.mjs',
'index.js',
'main.mjs',
'main.js'
].filter(Boolean) as string[];
for (const entry of possibleEntries) {
const entryPath = path.join(pluginDir, entry);
if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) {
return entry;
}
}
return null;
}
/**
* 检查是否为支持的文件类型
*/
private isSupportedFile(filename: string): boolean {
const ext = path.extname(filename).toLowerCase();
return ['.mjs', '.js'].includes(ext);
}
/**
* 动态导入模块
*/
private async importModule(filePath: string): Promise<any> {
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
return await import(fileUrl);
}
/**
* 检查模块是否为有效的插件模块
*/
private isValidPluginModule(module: any): module is PluginModule {
return module && typeof module.plugin_init === 'function';
}
/**
* 注册插件
*/
private async registerPlugin(plugin: LoadedPlugin): Promise<void> {
// 检查名称冲突
if (this.loadedPlugins.has(plugin.name)) {
this.logger.logWarn(`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`);
return;
}
this.loadedPlugins.set(plugin.name, plugin);
this.logger.log(`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''}`);
// 调用插件初始化方法(必须存在)
try {
await plugin.module.plugin_init(this.core, this.obContext, this.actions, this);
this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error initializing plugin ${plugin.name}:`, error);
}
}
/**
* 卸载插件
*/
private async unloadPlugin(pluginName: string): Promise<void> {
const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) {
return;
}
// 调用插件清理方法
if (typeof plugin.module.plugin_cleanup === 'function') {
try {
await plugin.module.plugin_cleanup(this.core, this.obContext, this.actions, this);
this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`, error);
}
}
this.loadedPlugins.delete(pluginName);
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
onEvent<T extends OB11EmitEventContent>(event: T) {
if (!this.isEnable) {
return;
}
// 遍历所有已加载的插件,调用它们的事件处理方法
for (const [, plugin] of this.loadedPlugins) {
this.callPluginEventHandler(plugin, event);
}
}
/**
* 调用插件的事件处理方法
*/
private async callPluginEventHandler(plugin: LoadedPlugin, event: OB11EmitEventContent): Promise<void> {
try {
// 优先使用 plugin_onevent 方法
if (typeof plugin.module.plugin_onevent === 'function') {
await plugin.module.plugin_onevent(this.name, this.core, this.obContext, event, this.actions, this);
}
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
if ((event as any).message_type && typeof plugin.module.plugin_onmessage === 'function') {
await plugin.module.plugin_onmessage(this.name, this.core, this.obContext, event as OB11Message, this.actions, this);
}
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`, error);
}
}
async open() {
if (this.isEnable) {
return;
}
this.logger.log('[Plugin Adapter] Opening plugin adapter...');
this.isEnable = true;
// 加载所有插件
await this.loadPlugins();
this.logger.log(`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`);
}
async close() {
if (!this.isEnable) {
return;
}
this.logger.log('[Plugin Adapter] Closing plugin adapter...');
this.isEnable = false;
// 卸载所有插件
const pluginNames = Array.from(this.loadedPlugins.keys());
for (const pluginName of pluginNames) {
await this.unloadPlugin(pluginName);
}
this.logger.log('[Plugin Adapter] Plugin adapter closed');
}
async reload() {
this.logger.log('[Plugin Adapter] Reloading plugin adapter...');
// 先关闭然后重新打开
await this.close();
await this.open();
this.logger.log('[Plugin Adapter] Plugin adapter reloaded');
return OB11NetworkReloadType.Normal;
}
/**
* 获取已加载的插件列表
*/
public getLoadedPlugins(): LoadedPlugin[] {
return Array.from(this.loadedPlugins.values());
}
/**
* 获取插件信息
*/
public getPluginInfo(pluginName: string): LoadedPlugin | undefined {
return this.loadedPlugins.get(pluginName);
}
/**
* 重载指定插件
*/
public async reloadPlugin(pluginName: string): Promise<boolean> {
const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) {
this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginName} not found`);
return false;
}
try {
// 卸载插件
await this.unloadPlugin(pluginName);
// 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath;
if (isDirectory) {
const dirname = path.basename(plugin.pluginPath);
await this.loadDirectoryPlugin(dirname);
} else {
const filename = path.basename(plugin.entryPath);
await this.loadFilePlugin(filename);
}
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`);
return true;
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error);
return false;
}
}
}

View File

@@ -2,39 +2,372 @@ import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import { NapCatOneBot11Adapter, OB11Message } from '@/onebot';
import { NapCatCore } from '@/core';
import { PluginConfig } from '../config/config';
import { plugin_onmessage } from '@/plugin';
import { ActionMap } from '../action';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import fs from 'fs';
import path from 'path';
export interface PluginPackageJson {
name?: string;
version?: string;
main?: string;
}
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginAdapter) => void | Promise<void>;
plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginAdapter) => void | Promise<void>;
plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginAdapter) => void | Promise<void>;
plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginAdapter) => void | Promise<void>;
}
export interface LoadedPlugin {
name: string;
version?: string;
pluginPath: string;
entryPath: string;
packageJson?: PluginPackageJson;
module: PluginModule;
}
export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
constructor(
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) {
const config = {
name: name,
messagePostFormat: 'array',
reportSelfMessage: false,
reportSelfMessage: true,
enable: true,
debug: false,
debug: true,
};
super(name, config, core, obContext, actions);
this.pluginPath = this.core.context.pathWrapper.pluginPath;
}
onEvent<T extends OB11EmitEventContent>(event: T) {
if (event.post_type === 'message') {
plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch();
/**
* 扫描并加载插件
*/
private async loadPlugins(): Promise<void> {
try {
// 确保插件目录存在
if (!fs.existsSync(this.pluginPath)) {
this.logger.logWarn(`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`);
fs.mkdirSync(this.pluginPath, { recursive: true });
return;
}
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
// 扫描文件和目录
for (const item of items) {
if (item.isFile()) {
// 处理单文件插件
await this.loadFilePlugin(item.name);
} else if (item.isDirectory()) {
// 处理目录插件
await this.loadDirectoryPlugin(item.name);
}
}
this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading plugins:`, error);
}
}
open() {
/**
* 加载单文件插件 (.mjs, .js)
*/
private async loadFilePlugin(filename: string): Promise<void> {
// 只处理支持的文件类型
if (!this.isSupportedFile(filename)) {
return;
}
const filePath = path.join(this.pluginPath, filename);
const pluginName = path.parse(filename).name;
try {
const module = await this.importModule(filePath);
if (!this.isValidPluginModule(module)) {
this.logger.logWarn(`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`);
return;
}
const plugin: LoadedPlugin = {
name: pluginName,
pluginPath: this.pluginPath,
entryPath: filePath,
module: module
};
await this.registerPlugin(plugin);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading file plugin ${filename}:`, error);
}
}
/**
* 加载目录插件
*/
private async loadDirectoryPlugin(dirname: string): Promise<void> {
const pluginDir = path.join(this.pluginPath, dirname);
try {
// 尝试读取 package.json
let packageJson: PluginPackageJson | undefined;
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
packageJson = JSON.parse(packageContent);
} catch (error) {
this.logger.logWarn(`[Plugin Adapter] Invalid package.json in ${dirname}:`, error);
}
}
// 确定入口文件
const entryFile = this.findEntryFile(pluginDir, packageJson);
if (!entryFile) {
this.logger.logWarn(`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`);
return;
}
const entryPath = path.join(pluginDir, entryFile);
const module = await this.importModule(entryPath);
if (!this.isValidPluginModule(module)) {
this.logger.logWarn(`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`);
return;
}
const plugin: LoadedPlugin = {
name: packageJson?.name || dirname,
version: packageJson?.version,
pluginPath: pluginDir,
entryPath: entryPath,
packageJson: packageJson,
module: module
};
await this.registerPlugin(plugin);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading directory plugin ${dirname}:`, error);
}
}
/**
* 查找插件目录的入口文件
*/
private findEntryFile(pluginDir: string, packageJson?: PluginPackageJson): string | null {
// 优先级package.json main > 默认文件名
const possibleEntries = [
packageJson?.main,
'index.mjs',
'index.js',
'main.mjs',
'main.js'
].filter(Boolean) as string[];
for (const entry of possibleEntries) {
const entryPath = path.join(pluginDir, entry);
if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) {
return entry;
}
}
return null;
}
/**
* 检查是否为支持的文件类型
*/
private isSupportedFile(filename: string): boolean {
const ext = path.extname(filename).toLowerCase();
return ['.mjs', '.js'].includes(ext);
}
/**
* 动态导入模块
*/
private async importModule(filePath: string): Promise<any> {
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
return await import(fileUrl);
}
/**
* 检查模块是否为有效的插件模块
*/
private isValidPluginModule(module: any): module is PluginModule {
return module && typeof module.plugin_init === 'function';
}
/**
* 注册插件
*/
private async registerPlugin(plugin: LoadedPlugin): Promise<void> {
// 检查名称冲突
if (this.loadedPlugins.has(plugin.name)) {
this.logger.logWarn(`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`);
return;
}
this.loadedPlugins.set(plugin.name, plugin);
this.logger.log(`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''}`);
// 调用插件初始化方法(必须存在)
try {
await plugin.module.plugin_init(this.core, this.obContext, this.actions, this);
this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error initializing plugin ${plugin.name}:`, error);
}
}
/**
* 卸载插件
*/
private async unloadPlugin(pluginName: string): Promise<void> {
const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) {
return;
}
// 调用插件清理方法
if (typeof plugin.module.plugin_cleanup === 'function') {
try {
await plugin.module.plugin_cleanup(this.core, this.obContext, this.actions, this);
this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`, error);
}
}
this.loadedPlugins.delete(pluginName);
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
onEvent<T extends OB11EmitEventContent>(event: T) {
if (!this.isEnable) {
return;
}
// 遍历所有已加载的插件,调用它们的事件处理方法
for (const [, plugin] of this.loadedPlugins) {
this.callPluginEventHandler(plugin, event);
}
}
/**
* 调用插件的事件处理方法
*/
private async callPluginEventHandler(plugin: LoadedPlugin, event: OB11EmitEventContent): Promise<void> {
try {
// 优先使用 plugin_onevent 方法
if (typeof plugin.module.plugin_onevent === 'function') {
await plugin.module.plugin_onevent(this.name, this.core, this.obContext, event, this.actions, this);
}
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
if ((event as any).message_type && typeof plugin.module.plugin_onmessage === 'function') {
await plugin.module.plugin_onmessage(this.name, this.core, this.obContext, event as OB11Message, this.actions, this);
}
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`, error);
}
}
async open() {
if (this.isEnable) {
return;
}
this.logger.log('[Plugin Adapter] Opening plugin adapter...');
this.isEnable = true;
// 加载所有插件
await this.loadPlugins();
this.logger.log(`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`);
}
async close() {
if (!this.isEnable) {
return;
}
this.logger.log('[Plugin Adapter] Closing plugin adapter...');
this.isEnable = false;
// 卸载所有插件
const pluginNames = Array.from(this.loadedPlugins.keys());
for (const pluginName of pluginNames) {
await this.unloadPlugin(pluginName);
}
this.logger.log('[Plugin Adapter] Plugin adapter closed');
}
async reload() {
this.logger.log('[Plugin Adapter] Reloading plugin adapter...');
// 先关闭然后重新打开
await this.close();
await this.open();
this.logger.log('[Plugin Adapter] Plugin adapter reloaded');
return OB11NetworkReloadType.Normal;
}
/**
* 获取已加载的插件列表
*/
public getLoadedPlugins(): LoadedPlugin[] {
return Array.from(this.loadedPlugins.values());
}
/**
* 获取插件信息
*/
public getPluginInfo(pluginName: string): LoadedPlugin | undefined {
return this.loadedPlugins.get(pluginName);
}
/**
* 重载指定插件
*/
public async reloadPlugin(pluginName: string): Promise<boolean> {
const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) {
this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginName} not found`);
return false;
}
try {
// 卸载插件
await this.unloadPlugin(pluginName);
// 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath;
if (isDirectory) {
const dirname = path.basename(plugin.pluginPath);
await this.loadDirectoryPlugin(dirname);
} else {
const filename = path.basename(plugin.entryPath);
await this.loadFilePlugin(filename);
}
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`);
return true;
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error);
return false;
}
}
}

View File

@@ -21,6 +21,7 @@ export interface OB11Message {
real_id: number;
user_id: number | string; // number
group_id?: number | string; // number
group_name?: string; // string
message_type: 'private' | 'group';
sub_type?: 'friend' | 'group' | 'normal';
sender: OB11Sender;

View File

@@ -1,9 +1,9 @@
import { NapCatOneBot11Adapter, OB11Message } from '@/onebot';
import { NapCatCore } from '@/core';
import { ActionMap } from '@/onebot/action';
import { OB11PluginAdapter } from '@/onebot/network/plugin';
import { OB11PluginMangerAdapter } from '@/onebot/network/plugin-manger';
export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCtx: NapCatOneBot11Adapter, message: OB11Message, action: ActionMap, instance: OB11PluginAdapter) => {
export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCtx: NapCatOneBot11Adapter, message: OB11Message, action: ActionMap, instance: OB11PluginMangerAdapter) => {
if (message.raw_message === 'ping') {
const ret = await action.get('send_group_msg')?.handle({ group_id: String(message.group_id), message: 'pong' }, adapter, instance.config);
console.log(ret);

View File

@@ -80,7 +80,7 @@ async function initializeEngine(
base_path_prefix: '',
platform_type: systemPlatform,
app_type: 4,
app_version: basicInfoWrapper.getFullQQVesion(),
app_version: basicInfoWrapper.getFullQQVersion(),
os_version: systemVersion,
use_xlog: false,
qua: basicInfoWrapper.QQVersionQua ?? '',
@@ -105,7 +105,7 @@ async function initializeLoginService(
appid: basicInfoWrapper.QQVersionAppid ?? '',
platVer: systemVersion,
commonPath: dataPathGlobal,
clientVer: basicInfoWrapper.getFullQQVesion(),
clientVer: basicInfoWrapper.getFullQQVersion(),
hostName: hostname,
});
}
@@ -324,7 +324,7 @@ export async function NCoreInitShell() {
});
}
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion());
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
const o3Service = wrapper.NodeIO3MiscService.get();
o3Service.addO3MiscListener(new NodeIO3MiscListener());
@@ -363,7 +363,7 @@ export async function NCoreInitShell() {
const sessionConfig = await genSessionConfig(
guid,
basicInfoWrapper.QQVersionAppid,
basicInfoWrapper.getFullQQVesion(),
basicInfoWrapper.getFullQQVersion(),
selfInfo.uin,
selfInfo.uid,
dataPath,

View File

@@ -4,6 +4,7 @@
import express from 'express';
import { createServer } from 'http';
import { randomUUID, randomBytes } from 'node:crypto'
import { createServer as createHttpsServer } from 'https';
import { LogWrapper } from '@/common/log';
import { NapCatPathWrapper } from '@/common/path';
@@ -30,17 +31,42 @@ const MAX_PORT_TRY = 100;
import * as net from 'node:net';
import { WebUiDataRuntime } from './src/helper/Data';
import { existsSync, readFileSync } from 'node:fs';
export let webUiRuntimePort = 6099;
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
// 全局变量存储需要在QQ登录成功后发送的新token
export let pendingTokenToSend: string | null = null;
/**
* 存储WebUI启动时的初始token用于鉴权
* - 无论是否在运行时修改密码都应该使用此token进行鉴权
* - 运行时手动修改的密码将会在下次napcat重启后生效
* - 如果需要在运行时修改密码并立即生效,则需要在前端调用路由进行修改
*/
let initialWebUiToken: string = '';
export function setInitialWebUiToken(token: string) {
initialWebUiToken = token;
}
export function getInitialWebUiToken(): string {
return initialWebUiToken;
}
export function setPendingTokenToSend(token: string | null) {
pendingTokenToSend = token;
}
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number,string]> {
try {
await tryUseHost(parsedConfig.host);
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
return [parsedConfig.host, port, parsedConfig.token];
} catch (error) {
console.log('host或port不可用', error);
return ['', 0, ''];
return ['', 0, randomUUID()];
}
}
async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cert: string } | null> {
try {
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
@@ -61,7 +87,35 @@ async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cer
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
webUiPathWrapper = pathWrapper;
WebUiConfig = new WebUiConfigWrapper();
const [host, port, token] = await InitPort(await WebUiConfig.GetWebUIConfig());
let config = await WebUiConfig.GetWebUIConfig();
// 检查并更新默认密码 - 最高优先级
if (config.defaultToken || config.token === 'napcat' || !config.token) {
const randomToken = randomBytes(6).toString('hex');
await WebUiConfig.UpdateWebUIConfig({ token: randomToken, defaultToken: false });
logger.log(`[NapCat] [WebUi] 🔐 检测到默认密码,已自动更新为安全密码`);
// 存储token到全局变量等待QQ登录成功后发送
setPendingTokenToSend(randomToken);
logger.log(`[NapCat] [WebUi] 📤 新密码将在QQ登录成功后发送给用户`);
// 重新获取更新后的配置
config = await WebUiConfig.GetWebUIConfig();
} else {
logger.log(`[NapCat] [WebUi] ✅ 当前使用安全密码`);
}
// 存储启动时的初始token用于鉴权
setInitialWebUiToken(config.token);
logger.log(`[NapCat] [WebUi] 🔑 已缓存启动时的token用于鉴权运行时手动修改配置文件密码将不会生效`);
// 检查是否禁用WebUI
if (config.disableWebUI) {
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
return;
}
const [host, port, token] = await InitPort(config);
webUiRuntimePort = port;
if (port == 0) {
logger.log('[NapCat] [WebUi] Current WebUi is not run.');
@@ -161,17 +215,18 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------启动服务------------
server.listen(port, host, async () => {
// 启动后打印出相关地址
let searchParams = { token: token };
if (host !== '' && host !== '0.0.0.0') {
logger.log(`[NapCat] [WebUi] 🔑 token=${token}`);
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);
if (host !== '') {
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
);
}
logger.log(
`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);
});
// ------------Over------------
}

View File

@@ -1,21 +1,12 @@
import { RequestHandler } from 'express';
import { WebUiConfig } from '@/webui';
import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendSuccess, sendError } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
// 检查是否使用默认Token
export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => {
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.token === 'napcat') {
return sendSuccess(res, true);
}
return sendSuccess(res, false);
};
// 登录
export const LoginHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
@@ -33,8 +24,13 @@ export const LoginHandler: RequestHandler = async (req, res) => {
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit');
}
//验证config.token hash是否等于token hash
if (!AuthHelper.comparePasswordHash(WebUiConfigData.token, hash)) {
// 使用启动时缓存的token进行验证而不是动态读取配置文件
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
//验证初始token hash是否等于提交的token hash
if (!AuthHelper.comparePasswordHash(initialToken, hash)) {
return sendError(res, 'token is invalid');
}
@@ -63,8 +59,6 @@ export const LogoutHandler: RequestHandler = async (req, res) => {
// 检查登录状态
export const checkHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求头中的Authorization
const authorization = req.headers.authorization;
// 检查凭证
@@ -79,8 +73,13 @@ export const checkHandler: RequestHandler = async (req, res) => {
return sendError(res, 'Token has been revoked');
}
// 使用启动时缓存的token进行验证
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
// 验证凭证是否在一小时内有效
const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
const valid = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
// 返回成功信息
if (valid) return sendSuccess(res, null);
// 返回错误信息
@@ -96,8 +95,33 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
const { oldToken, newToken } = req.body;
const authorization = req.headers.authorization;
if (isEmpty(oldToken) || isEmpty(newToken)) {
return sendError(res, 'oldToken or newToken is empty');
if (isEmpty(newToken)) {
return sendError(res, 'newToken is empty');
}
// 强制要求旧密码
if (isEmpty(oldToken)) {
return sendError(res, 'oldToken is required');
}
// 检查新旧密码是否相同
if (oldToken === newToken) {
return sendError(res, '新密码不能与旧密码相同');
}
// 检查新密码强度
if (newToken.length < 6) {
return sendError(res, '新密码至少需要6个字符');
}
// 检查是否包含字母
if (!/[a-zA-Z]/.test(newToken)) {
return sendError(res, '新密码必须包含字母');
}
// 检查是否包含数字
if (!/[0-9]/.test(newToken)) {
return sendError(res, '新密码必须包含数字');
}
try {
@@ -108,7 +132,19 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
AuthHelper.revokeCredential(Credential);
}
await WebUiConfig.UpdateToken(oldToken, newToken);
// 使用启动时缓存的token进行验证
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
if (initialToken !== oldToken) {
return sendError(res, '旧 token 不匹配');
}
// 直接更新配置文件中的token不需要通过WebUiConfig.UpdateToken方法
await WebUiConfig.UpdateWebUIConfig({ token: newToken, defaultToken: false });
// 更新内存中的缓存token使新密码立即生效
setInitialWebUiToken(newToken);
return sendSuccess(res, 'Token updated successfully');
} catch (e: any) {
return sendError(res, `Failed to update token: ${e.message}`);

View File

@@ -9,10 +9,18 @@ import { PassThrough } from 'stream';
import multer from 'multer';
import webUIFontUploader from '../uploader/webui_font';
import diskUploader from '../uploader/disk';
import { WebUiConfig } from '@/webui';
import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/webui';
const isWindows = os.platform() === 'win32';
// 安全地从查询参数中提取字符串值,防止类型混淆
const getQueryStringParam = (param: any): string => {
if (Array.isArray(param)) {
return String(param[0] || '');
}
return String(param || '');
};
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/'];
@@ -32,14 +40,68 @@ const getRootDirs = async (): Promise<string[]> => {
return drives.length > 0 ? drives : ['C:'];
};
// 规范化路径
// 规范化路径并进行安全验证
const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/';
// 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\';
if (!inputPath) {
// 对于空路径,Windows返回用户主目录,其他系统返回根目录
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
}
return path.normalize(inputPath);
// 对输入路径进行清理,移除潜在的危险字符
const cleanedPath = inputPath.replace(/[\x00-\x1f\x7f]/g, ''); // 移除控制字符
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(cleanedPath)) {
return cleanedPath.slice(0, 2) + '\\';
}
// 安全验证:检查是否包含危险的路径遍历模式(在规范化之前)
if (containsPathTraversal(cleanedPath)) {
throw new Error('Invalid path: path traversal detected');
}
// 进行路径规范化
const normalized = path.resolve(cleanedPath);
// 再次检查规范化后的路径,确保没有绕过安全检查
if (containsPathTraversal(normalized)) {
throw new Error('Invalid path: path traversal detected after normalization');
}
// 额外安全检查:确保规范化后的路径不包含连续的路径分隔符
const finalPath = normalized.replace(/[\\\/]+/g, path.sep);
return finalPath;
};
// 检查路径是否包含路径遍历攻击模式
const containsPathTraversal = (inputPath: string): boolean => {
// 对输入进行URL解码防止编码绕过
let decodedPath = inputPath;
try {
decodedPath = decodeURIComponent(inputPath);
} catch {
// 如果解码失败,使用原始路径
}
// 将路径统一为正斜杠格式进行检查
const normalizedForCheck = decodedPath.replace(/\\/g, '/');
// 检查危险模式 - 更全面的路径遍历检测
const dangerousPatterns = [
/\.\.\//, // ../ 模式
/\/\.\./, // /.. 模式
/^\.\./, // 以.. 开头
/\.\.$/, // 以.. 结尾
/\.\.\\/, // ..\ 模式Windows
/\\\.\./, // \.. 模式Windows
/%2e%2e/i, // URL编码的..
/%252e%252e/i, // 双重URL编码的..
/\.\.\x00/, // null字节攻击
/\0/, // null字节
];
return dangerousPatterns.some(pattern => pattern.test(normalizedForCheck));
};
interface FileInfo {
@@ -52,6 +114,35 @@ interface FileInfo {
// 添加系统文件黑名单
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
// 检查是否为WebUI配置文件
const isWebUIConfigFile = (filePath: string): boolean => {
// 先用字符串快速筛选
if (!filePath.includes('webui.json')) {
return false;
}
// 进入更严格的路径判断 - 统一路径分隔符为 /
const webUIConfigPath = path.resolve(webUiPathWrapper.configPath, 'webui.json').replace(/\\/g, '/');
const targetPath = path.resolve(filePath).replace(/\\/g, '/');
// 统一分隔符后进行路径比较
return targetPath === webUIConfigPath;
};
// WebUI配置文件脱敏处理
const sanitizeWebUIConfig = (content: string): string => {
try {
const config = JSON.parse(content);
if (config.token) {
config.token = '******';
}
return JSON.stringify(config, null, 4);
} catch {
// 如果解析失败,返回原内容
return content;
}
};
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try {
@@ -65,9 +156,20 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.defaultToken) {
return sendError(res, '默认密码禁止使用');
}
try {
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
let normalizedPath: string;
try {
normalizedPath = normalizePath(requestPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上返回盘符列表
@@ -135,7 +237,18 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
export const CreateDirHandler: RequestHandler = async (req, res) => {
try {
const { path: dirPath } = req.body;
const normalizedPath = normalizePath(dirPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(dirPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
// 检查是否已存在同类型(目录)
if (await checkSameTypeExists(normalizedPath, true)) {
@@ -153,7 +266,19 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
@@ -171,7 +296,18 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
@@ -188,8 +324,25 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
const content = await fsProm.readFile(filePath, 'utf-8');
let filePath: string;
try {
filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(filePath)) {
return sendError(res, '路径必须是绝对路径');
}
let content = await fsProm.readFile(filePath, 'utf-8');
// 如果是WebUI配置文件对token进行脱敏处理
if (isWebUIConfigFile(filePath)) {
content = sanitizeWebUIConfig(content);
}
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
@@ -200,8 +353,40 @@ export const ReadFileHandler: RequestHandler = async (req, res) => {
export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fsProm.writeFile(normalizedPath, content, 'utf-8');
// 安全的路径规范化,如果检测到路径遍历攻击会抛出异常
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
let finalContent = content;
// 检查是否为WebUI配置文件
if (isWebUIConfigFile(normalizedPath)) {
try {
// 解析要写入的配置
const configToWrite = JSON.parse(content);
// 获取内存中的token覆盖前端传来的token
const memoryToken = getInitialWebUiToken();
if (memoryToken) {
configToWrite.token = memoryToken;
finalContent = JSON.stringify(configToWrite, null, 4);
}
} catch (e) {
// 如果解析失败 说明不符合json格式 不允许写入
return sendError(res, '写入的WebUI配置文件内容格式错误必须是合法的JSON');
}
}
await fsProm.writeFile(normalizedPath, finalContent, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
@@ -212,7 +397,18 @@ export const WriteFileHandler: RequestHandler = async (req, res) => {
export const CreateFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath } = req.body;
const normalizedPath = normalizePath(filePath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
// 检查是否已存在同类型(文件)
if (await checkSameTypeExists(normalizedPath, false)) {
@@ -230,8 +426,21 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
export const RenameHandler: RequestHandler = async (req, res) => {
try {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
let normalizedOldPath: string;
let normalizedNewPath: string;
try {
normalizedOldPath = normalizePath(oldPath);
normalizedNewPath = normalizePath(newPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedOldPath) || !path.isAbsolute(normalizedNewPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
@@ -243,8 +452,21 @@ export const RenameHandler: RequestHandler = async (req, res) => {
export const MoveHandler: RequestHandler = async (req, res) => {
try {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
let normalizedSourcePath: string;
let normalizedTargetPath: string;
try {
normalizedSourcePath = normalizePath(sourcePath);
normalizedTargetPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
@@ -257,8 +479,20 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
try {
const { items } = req.body;
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
let normalizedSourcePath: string;
let normalizedTargetPath: string;
try {
normalizedSourcePath = normalizePath(sourcePath);
normalizedTargetPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
@@ -270,10 +504,21 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
let filePath: string;
try {
filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
if (!filePath) {
return sendError(res, '参数错误');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(filePath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(filePath);
@@ -312,12 +557,25 @@ export const BatchDownloadHandler: RequestHandler = async (req, res) => {
const zipStream = new compressing.zip.Stream();
// 修改:根据文件类型设置 relativePath
for (const filePath of paths) {
const normalizedPath = normalizePath(filePath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保规范化后的路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
zipStream.addEntry(normalizedPath, { relativePath: '' });
} else {
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
// 确保相对路径只使用文件名,防止路径遍历
const safeName = path.basename(normalizedPath);
zipStream.addEntry(normalizedPath, { relativePath: safeName });
}
}
zipStream.pipe(res);

View File

@@ -5,6 +5,12 @@ import { terminalManager } from '../terminal/terminal_manager';
import { WebUiConfig } from '@/webui';
// 判断是否是 macos
const isMacOS = process.platform === 'darwin';
// 日志脱敏函数
const sanitizeLog = (log: string): string => {
// 脱敏 token 参数,将 token=xxx 替换为 token=***
return log.replace(/token=[\w\d]+/gi, 'token=***');
};
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
const filename = req.query['id'];
@@ -16,7 +22,8 @@ export const LogHandler: RequestHandler = async (req, res) => {
return sendError(res, 'ID不合法');
}
const logContent = await WebUiConfig.GetLogContent(filename);
return sendSuccess(res, logContent);
const sanitizedLogContent = sanitizeLog(logContent);
return sendSuccess(res, sanitizedLogContent);
};
// 日志列表
@@ -31,7 +38,8 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
res.setHeader('Connection', 'keep-alive');
const listener = (log: string) => {
try {
res.write(`data: ${log}\n\n`);
const sanitizedLog = sanitizeLog(log);
res.write(`data: ${sanitizedLog}\n\n`);
} catch (error) {
console.error('向客户端写入日志数据时出错:', error);
}
@@ -47,8 +55,8 @@ export const CreateTerminalHandler: RequestHandler = async (req, res) => {
if (isMacOS) {
return sendError(res, 'MacOS不支持终端');
}
if ((await WebUiConfig.GetWebUIConfig()).token === 'napcat') {
return sendError(res, '默认密码禁止创建终端');
if ((await WebUiConfig.GetWebUIConfig()).defaultToken) {
return sendError(res, '密码禁止创建终端');
}
try {
const { cols, rows } = req.body;

View File

@@ -2,7 +2,7 @@ import { RequestHandler } from 'express';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadConfig, OneBotConfig } from '@/onebot/config/config';
import { webUiPathWrapper } from '@/webui';
import { WebUiConfig, webUiPathWrapper } from '@/webui';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendError, sendSuccess } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
@@ -47,6 +47,10 @@ export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
if (isEmpty(req.body.config)) {
return sendError(res, 'config is empty');
}
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.defaultToken) {
return sendError(res, '默认密码禁止写入配置');
}
// 写入配置
try {
// 解析并加载配置

View File

@@ -0,0 +1,127 @@
import { RequestHandler } from 'express';
import { WebUiConfig } from '@/webui';
import { sendError, sendSuccess } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
// 获取WebUI基础配置
export const GetWebUIConfigHandler: RequestHandler = async (_, res) => {
try {
const config = await WebUiConfig.GetWebUIConfig();
return sendSuccess(res, {
host: config.host,
port: config.port,
loginRate: config.loginRate,
disableWebUI: config.disableWebUI,
disableNonLANAccess: config.disableNonLANAccess
});
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `获取WebUI配置失败: ${msg}`);
}
};
// 获取是否禁用WebUI
export const GetDisableWebUIHandler: RequestHandler = async (_, res) => {
try {
const disable = await WebUiConfig.GetDisableWebUI();
return sendSuccess(res, disable);
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `获取WebUI禁用状态失败: ${msg}`);
}
};
// 更新是否禁用WebUI
export const UpdateDisableWebUIHandler: RequestHandler = async (req, res) => {
try {
const { disable } = req.body;
if (typeof disable !== 'boolean') {
return sendError(res, 'disable参数必须是布尔值');
}
await WebUiConfig.UpdateDisableWebUI(disable);
return sendSuccess(res, null);
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `更新WebUI禁用状态失败: ${msg}`);
}
};
// 获取是否禁用非局域网访问
export const GetDisableNonLANAccessHandler: RequestHandler = async (_, res) => {
try {
const disable = await WebUiConfig.GetDisableNonLANAccess();
return sendSuccess(res, disable);
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `获取非局域网访问禁用状态失败: ${msg}`);
}
};
// 更新是否禁用非局域网访问
export const UpdateDisableNonLANAccessHandler: RequestHandler = async (req, res) => {
try {
const { disable } = req.body;
if (typeof disable !== 'boolean') {
return sendError(res, 'disable参数必须是布尔值');
}
await WebUiConfig.UpdateDisableNonLANAccess(disable);
return sendSuccess(res, null);
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `更新非局域网访问禁用状态失败: ${msg}`);
}
};
// 更新WebUI基础配置
export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
try {
const { host, port, loginRate, disableWebUI, disableNonLANAccess } = req.body;
const updateConfig: any = {};
if (host !== undefined) {
if (isEmpty(host)) {
return sendError(res, 'host不能为空');
}
updateConfig.host = host;
}
if (port !== undefined) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return sendError(res, 'port必须是1-65535之间的整数');
}
updateConfig.port = port;
}
if (loginRate !== undefined) {
if (!Number.isInteger(loginRate) || loginRate < 1) {
return sendError(res, 'loginRate必须是大于0的整数');
}
updateConfig.loginRate = loginRate;
}
if (disableWebUI !== undefined) {
if (typeof disableWebUI !== 'boolean') {
return sendError(res, 'disableWebUI必须是布尔值');
}
updateConfig.disableWebUI = disableWebUI;
}
if (disableNonLANAccess !== undefined) {
if (typeof disableNonLANAccess !== 'boolean') {
return sendError(res, 'disableNonLANAccess必须是布尔值');
}
updateConfig.disableNonLANAccess = disableNonLANAccess;
}
await WebUiConfig.UpdateWebUIConfig(updateConfig);
return sendSuccess(res, null);
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `更新WebUI配置失败: ${msg}`);
}
};

View File

@@ -14,6 +14,12 @@ const LoginRuntime: LoginRuntimeType = {
nick: '',
},
QQVersion: 'unknown',
onQQLoginStatusChange: async (status: boolean) => {
LoginRuntime.QQLoginStatus = status;
},
onWebUiTokenChange: async (_token: string) => {
return;
},
NapCatHelper: {
onOB11ConfigChanged: async () => {
return;
@@ -31,6 +37,12 @@ const LoginRuntime: LoginRuntimeType = {
};
export const WebUiDataRuntime = {
setWebUiTokenChangeCallback(func: (token: string) => Promise<void>): void {
LoginRuntime.onWebUiTokenChange = func;
},
getWebUiTokenChangeCallback(): (token: string) => Promise<void> {
return LoginRuntime.onWebUiTokenChange;
},
checkLoginRate(ip: string, RateLimit: number): boolean {
const key = `login_rate:${ip}`;
const count = store.get<number>(key) || 0;
@@ -53,6 +65,14 @@ export const WebUiDataRuntime = {
return LoginRuntime.QQLoginStatus;
},
setQQLoginCallback(func: (status: boolean) => Promise<void>): void {
LoginRuntime.onQQLoginStatusChange = func;
},
getQQLoginCallback(): (status: boolean) => Promise<void> {
return LoginRuntime.onQQLoginStatusChange;
},
setQQLoginStatus(status: LoginRuntimeType['QQLoginStatus']): void {
LoginRuntime.QQLoginStatus = status;
},

View File

@@ -1,4 +1,4 @@
import { webUiPathWrapper } from '@/webui';
import { webUiPathWrapper, getInitialWebUiToken } from '@/webui';
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
import fs, { constants } from 'node:fs/promises';
@@ -9,7 +9,6 @@ import { deepMerge } from '../utils/object';
import { themeType } from '../types/theme';
// 限制尝试端口的次数,避免死循环
// 定义配置的类型
const WebUiConfigSchema = Type.Object({
host: Type.String({ default: '0.0.0.0' }),
@@ -18,6 +17,11 @@ const WebUiConfigSchema = Type.Object({
loginRate: Type.Number({ default: 10 }),
autoLoginAccount: Type.String({ default: '' }),
theme: themeType,
defaultToken: Type.Boolean({ default: true }),
// 是否关闭WebUI
disableWebUI: Type.Boolean({ default: false }),
// 是否关闭非局域网访问
disableNonLANAccess: Type.Boolean({ default: false }),
});
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
@@ -59,6 +63,46 @@ export class WebUiConfigWrapper {
}
async GetWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData
}
try {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
await this.ensureConfigFileExists(configPath);
const parsedConfig = await this.readAndValidateConfig(configPath);
// 使用内存中缓存的token进行覆盖确保强兼容性
this.WebUiConfigData = {
...parsedConfig,
// 首次读取内存中是没有token的需要进行一层兜底
token: getInitialWebUiToken() || parsedConfig.token
};
return this.WebUiConfigData;
} catch (e) {
console.log('读取配置文件失败', e);
const defaultConfig = this.validateAndApplyDefaults({});
return {
...defaultConfig,
token: defaultConfig.token
};
}
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
// 使用原始配置进行合并避免内存token覆盖影响配置更新
const currentConfig = await this.GetRawWebUIConfig();
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
/**
* 获取配置文件中实际存储的配置不被内存token覆盖
* 主要用于配置更新和特殊场景
*/
async GetRawWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData;
}
@@ -74,21 +118,15 @@ export class WebUiConfigWrapper {
}
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
const currentConfig = await this.GetWebUIConfig();
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
const currentConfig = await this.GetWebUIConfig();
if (currentConfig.token !== oldToken) {
// 使用内存中缓存的token进行验证确保强兼容性
const cachedToken = getInitialWebUiToken();
const tokenToCheck = cachedToken || (await this.GetWebUIConfig()).token;
if (tokenToCheck !== oldToken) {
throw new Error('旧 token 不匹配');
}
await this.UpdateWebUIConfig({ token: newToken });
await this.UpdateWebUIConfig({ token: newToken, defaultToken: false });
}
// 获取日志文件夹路径
@@ -176,4 +214,26 @@ export class WebUiConfigWrapper {
async UpdateTheme(theme: WebUiConfigType['theme']): Promise<void> {
await this.UpdateWebUIConfig({ theme: theme });
}
// 获取是否禁用WebUI
async GetDisableWebUI(): Promise<boolean> {
const config = await this.GetWebUIConfig();
return config.disableWebUI;
}
// 更新是否禁用WebUI
async UpdateDisableWebUI(disable: boolean): Promise<void> {
await this.UpdateWebUIConfig({ disableWebUI: disable });
}
// 获取是否禁用非局域网访问
async GetDisableNonLANAccess(): Promise<boolean> {
const config = await this.GetWebUIConfig();
return config.disableNonLANAccess;
}
// 更新是否禁用非局域网访问
async UpdateDisableNonLANAccess(disable: boolean): Promise<void> {
await this.UpdateWebUIConfig({ disableNonLANAccess: disable });
}
}

View File

@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from 'express';
import { WebUiConfig } from '@/webui';
import { getInitialWebUiToken } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { sendError } from '@webapi/utils/response';
@@ -30,10 +30,13 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
} catch (e) {
return sendError(res, 'Unauthorized');
}
// 获取配置
const config = await WebUiConfig.GetWebUIConfig();
// 使用启动时缓存的token进行验证而不是动态读取配置文件 因为有可能运行时手动修改了密码
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
// 验证凭证在1小时内有效
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
const credentialJson = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
if (credentialJson) {
// 通过验证
return next();

View File

@@ -1,7 +1,53 @@
import type { RequestHandler } from 'express';
import { WebUiConfig } from '@/webui';
// 检查是否为局域网IP地址
function isLANIP(ip: string): boolean {
if (!ip) return false;
// 移除IPv6的前缀如果存在
const cleanIP = ip.replace(/^::ffff:/, '');
// 本地回环地址
if (cleanIP === '127.0.0.1' || cleanIP === 'localhost' || cleanIP === '::1') {
return true;
}
// 检查IPv4私有网络地址
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const match = cleanIP.match(ipv4Regex);
if (match) {
const [, a, b] = match.map(Number);
// 10.0.0.0/8
if (a === 10) return true;
// 172.16.0.0/12
if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true;
// 192.168.0.0/16
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 (链路本地地址)
if (a === 169 && b === 254) return true;
}
return false;
}
// CORS 中间件,跨域用
export const cors: RequestHandler = (req, res, next) => {
export const cors: RequestHandler = async (req, res, next) => {
// 检查是否禁用非局域网访问
const config = await WebUiConfig.GetWebUIConfig();
if (config.disableNonLANAccess) {
const clientIP = req.ip || req.socket.remoteAddress || '';
if (!isLANIP(clientIP)) {
res.status(403).json({ error: '非局域网访问被禁止' });
return;
}
}
const origin = req.headers.origin || '*';
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');

View File

@@ -0,0 +1,405 @@
/**
* 性能监控API - 提供HTTP接口查看性能统计
*/
import { Router, Request, Response } from 'express';
import { performanceMonitor } from '@/common/performance-monitor';
export function createPerformanceRouter(): Router {
const router = Router();
// 获取所有统计数据
router.get('/stats', (_req: Request, res: Response) => {
try {
const stats = performanceMonitor.getStats();
res.json({
success: true,
data: stats,
count: stats.length
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// 获取总耗时排行榜
router.get('/stats/total-time', (req: Request, res: Response) => {
try {
const limit = parseInt(req.query['limit'] as string) || 20;
const stats = performanceMonitor.getTopByTotalTime(limit);
res.json({
success: true,
data: stats,
count: stats.length
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// 获取调用次数排行榜
router.get('/stats/call-count', (req: Request, res: Response) => {
try {
const limit = parseInt(req.query['limit'] as string) || 20;
const stats = performanceMonitor.getTopByCallCount(limit);
res.json({
success: true,
data: stats,
count: stats.length
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// 获取平均耗时排行榜
router.get('/stats/average-time', (req: Request, res: Response) => {
try {
const limit = parseInt(req.query['limit'] as string) || 20;
const stats = performanceMonitor.getTopByAverageTime(limit);
res.json({
success: true,
data: stats,
count: stats.length
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// 清空统计数据
router.post('/clear', (_req: Request, res: Response) => {
try {
performanceMonitor.clear();
res.json({
success: true,
message: 'Performance statistics cleared'
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// 获取性能报告页面
router.get('/report', (_req: Request, res: Response) => {
try {
const totalTimeStats = performanceMonitor.getTopByTotalTime(10);
const callCountStats = performanceMonitor.getTopByCallCount(10);
const averageTimeStats = performanceMonitor.getTopByAverageTime(10);
const html = generateReportHTML(totalTimeStats, callCountStats, averageTimeStats);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
return router;
}
function generateReportHTML(totalTimeStats: any[], callCountStats: any[], averageTimeStats: any[]): string {
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NapCat 性能监控报告</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 2.5em;
font-weight: 300;
}
.header p {
margin: 10px 0 0;
opacity: 0.9;
}
.content {
padding: 30px;
}
.section {
margin-bottom: 40px;
}
.section h2 {
color: #667eea;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
margin-bottom: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 30px;
}
.stats-card {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border-left: 4px solid #667eea;
}
.stats-card h3 {
margin: 0 0 15px;
color: #667eea;
display: flex;
align-items: center;
}
.stats-card h3::before {
content: '';
width: 20px;
height: 20px;
margin-right: 10px;
border-radius: 50%;
background: #667eea;
}
.stats-list {
list-style: none;
padding: 0;
margin: 0;
}
.stats-item {
padding: 12px 0;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.stats-item:last-child {
border-bottom: none;
}
.function-name {
font-weight: 600;
color: #495057;
flex: 1;
margin-right: 15px;
word-break: break-all;
}
.stats-values {
display: flex;
gap: 15px;
font-size: 0.9em;
}
.stat-value {
text-align: center;
}
.stat-label {
font-size: 0.8em;
color: #6c757d;
display: block;
}
.stat-number {
font-weight: bold;
color: #28a745;
}
.rank {
background: #667eea;
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9em;
font-weight: bold;
margin-right: 15px;
}
.refresh-btn {
position: fixed;
bottom: 30px;
right: 30px;
background: #667eea;
color: white;
border: none;
border-radius: 50px;
padding: 15px 20px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
}
.refresh-btn:hover {
background: #5a6fd8;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.6);
}
.clear-btn {
background: #dc3545;
margin-left: 10px;
}
.clear-btn:hover {
background: #c82333;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.stats-values {
flex-direction: column;
gap: 5px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 NapCat 性能监控报告</h1>
<p>实时函数调用性能统计 - ${new Date().toLocaleString()}</p>
</div>
<div class="content">
<div class="stats-grid">
<div class="stats-card">
<h3>🔥 总耗时排行榜 (Top 10)</h3>
<ul class="stats-list">
${totalTimeStats.map((stat, index) => `
<li class="stats-item">
<span class="rank">${index + 1}</span>
<span class="function-name">${stat.name}</span>
<div class="stats-values">
<div class="stat-value">
<span class="stat-number">${stat.totalTime.toFixed(2)}ms</span>
<span class="stat-label">总耗时</span>
</div>
<div class="stat-value">
<span class="stat-number">${stat.callCount}</span>
<span class="stat-label">调用次数</span>
</div>
<div class="stat-value">
<span class="stat-number">${stat.averageTime.toFixed(2)}ms</span>
<span class="stat-label">平均耗时</span>
</div>
</div>
</li>
`).join('')}
</ul>
</div>
<div class="stats-card">
<h3>📈 调用次数排行榜 (Top 10)</h3>
<ul class="stats-list">
${callCountStats.map((stat, index) => `
<li class="stats-item">
<span class="rank">${index + 1}</span>
<span class="function-name">${stat.name}</span>
<div class="stats-values">
<div class="stat-value">
<span class="stat-number">${stat.callCount}</span>
<span class="stat-label">调用次数</span>
</div>
<div class="stat-value">
<span class="stat-number">${stat.totalTime.toFixed(2)}ms</span>
<span class="stat-label">总耗时</span>
</div>
<div class="stat-value">
<span class="stat-number">${stat.averageTime.toFixed(2)}ms</span>
<span class="stat-label">平均耗时</span>
</div>
</div>
</li>
`).join('')}
</ul>
</div>
<div class="stats-card">
<h3>⏱️ 平均耗时排行榜 (Top 10)</h3>
<ul class="stats-list">
${averageTimeStats.map((stat, index) => `
<li class="stats-item">
<span class="rank">${index + 1}</span>
<span class="function-name">${stat.name}</span>
<div class="stats-values">
<div class="stat-value">
<span class="stat-number">${stat.averageTime.toFixed(2)}ms</span>
<span class="stat-label">平均耗时</span>
</div>
<div class="stat-value">
<span class="stat-number">${stat.callCount}</span>
<span class="stat-label">调用次数</span>
</div>
<div class="stat-value">
<span class="stat-number">${stat.totalTime.toFixed(2)}ms</span>
<span class="stat-label">总耗时</span>
</div>
</div>
</li>
`).join('')}
</ul>
</div>
</div>
</div>
</div>
<button class="refresh-btn" onclick="window.location.reload()">
🔄 刷新数据
</button>
<button class="refresh-btn clear-btn" onclick="clearStats()">
🗑️ 清空统计
</button>
<script>
function clearStats() {
if (confirm('确定要清空所有性能统计数据吗?')) {
fetch('/performance/clear', {
method: 'POST'
}).then(response => {
if (response.ok) {
alert('统计数据已清空');
window.location.reload();
} else {
alert('清空失败');
}
}).catch(error => {
alert('清空失败: ' + error.message);
});
}
}
// 自动刷新
setInterval(() => {
window.location.reload();
}, 30000); // 30秒自动刷新
</script>
</body>
</html>
`;
}

View File

@@ -0,0 +1,31 @@
import { Router } from 'express';
import {
GetWebUIConfigHandler,
GetDisableWebUIHandler,
UpdateDisableWebUIHandler,
GetDisableNonLANAccessHandler,
UpdateDisableNonLANAccessHandler,
UpdateWebUIConfigHandler
} from '@webapi/api/WebUIConfig';
const router = Router();
// 获取WebUI基础配置
router.get('/GetConfig', GetWebUIConfigHandler);
// 更新WebUI基础配置
router.post('/UpdateConfig', UpdateWebUIConfigHandler);
// 获取是否禁用WebUI
router.get('/GetDisableWebUI', GetDisableWebUIHandler);
// 更新是否禁用WebUI
router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler);
// 获取是否禁用非局域网访问
router.get('/GetDisableNonLANAccess', GetDisableNonLANAccessHandler);
// 更新是否禁用非局域网访问
router.post('/UpdateDisableNonLANAccess', UpdateDisableNonLANAccessHandler);
export { router as WebUIConfigRouter };

View File

@@ -1,7 +1,6 @@
import { Router } from 'express';
import {
CheckDefaultTokenHandler,
checkHandler,
LoginHandler,
LogoutHandler,
@@ -17,7 +16,5 @@ router.post('/check', checkHandler);
router.post('/logout', LogoutHandler);
// router:更新token
router.post('/update_token', UpdateTokenHandler);
// router:检查默认token
router.get('/check_using_default_token', CheckDefaultTokenHandler);
export { router as AuthRouter };

View File

@@ -13,6 +13,7 @@ import { AuthRouter } from '@webapi/router/auth';
import { LogRouter } from '@webapi/router/Log';
import { BaseRouter } from '@webapi/router/Base';
import { FileRouter } from './File';
import { WebUIConfigRouter } from './WebUIConfig';
const router = Router();
@@ -35,5 +36,7 @@ router.use('/OB11Config', OB11ConfigRouter);
router.use('/Log', LogRouter);
// file:文件相关路由
router.use('/File', FileRouter);
// router:WebUI配置相关路由
router.use('/WebUIConfig', WebUIConfigRouter);
export { router as ALLRouter };

View File

@@ -9,6 +9,8 @@ interface LoginRuntimeType {
QQLoginUin: string;
QQLoginInfo: SelfInfo;
QQVersion: string;
onQQLoginStatusChange: (status: boolean) => Promise<void>;
onWebUiTokenChange: (token: string) => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>;
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>;

View File

@@ -13,7 +13,6 @@ import { isIP } from 'node:net';
* @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]'
*/
export const normalizeHost = (host: string) => {
if (host === '0.0.0.0') return '127.0.0.1';
if (isIP(host) === 6) return `[${host}]`;
return host;
};

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