Compare commits

...

45 Commits

Author SHA1 Message Date
手瓜一十雪
323dc71d4e Improve Satori WebSocket logging and event handling
Enhanced debug logging in the Satori WebSocket server for better traceability of client events and authentication. Improved handling of client identification, including more robust checks and detailed logs for token validation. Removed unused MessageUnique logic from NapCatSatoriAdapter and added additional debug logs for event emission and message processing. Added a new onNtMsgSyncContactUnread method stub in NodeIKernelMsgListener.
2026-01-14 18:47:10 +08:00
手瓜一十雪
b0d88d3705 Refactor Satori actions with schema validation and router
Refactored all Satori action classes to use TypeBox schemas for payload validation and unified action naming via a new router. Added schema-based parameter checking to the SatoriAction base class. Introduced new actions for guild and member approval, and login retrieval. Centralized action name constants and types in a new router module. Enhanced event and message APIs with more structured event types and parsing logic. Added helper utilities for XML parsing. Updated exports and registration logic to support the new structure.
2026-01-14 17:52:38 +08:00
手瓜一十雪
32c0c93f3b Remove redundant private modifiers from constructor params
Eliminated unnecessary 'private' access modifiers from constructor parameters in OneBotProtocolAdapter and SatoriProtocolAdapter. This change clarifies parameter usage and avoids creating unused private fields.
2026-01-14 17:23:02 +08:00
手瓜一十雪
ea399c8017 Add protocol enable/disable and config management APIs
Introduces persistent protocol enable/disable state and related API endpoints in the backend, and adds frontend support for toggling protocols and managing protocol configs. Refactors protocol config storage to use per-protocol files, updates ProtocolManager to handle config and status, and enhances the Satori protocol UI with unified card components and improved state refresh. Removes the obsolete PROTOCOL_REFACTOR.md documentation.
2026-01-14 17:04:13 +08:00
手瓜一十雪
26d38bebe7 Refactor imports and add generic protocol config API
Replaced all '@/napcat-satori/...' imports with relative paths for consistency and compatibility. Added generic protocol config get/set handlers and routes in the web UI backend to support extensible protocol configuration management. Improved error handling and default value logic for Satori protocol configuration.
2026-01-14 16:01:29 +08:00
手瓜一十雪
506358e01a Refactor protocol management with napcat-protocol package
Introduced the new napcat-protocol package to unify protocol adapter management for OneBot and Satori. Updated napcat-framework and napcat-shell to use ProtocolManager instead of direct adapter instantiation. Added protocol info definitions to napcat-common, and integrated protocol configuration and management APIs into the web UI backend and frontend. This refactor improves maintainability, extensibility, and encapsulation of protocol logic, while maintaining backward compatibility.
2026-01-14 15:41:47 +08:00
手瓜一十雪
7cd0e5b2a4 Add isActive property to plugin adapters
Introduces an isActive getter to OB11PluginAdapter and OB11PluginMangerAdapter, which returns true only if the adapter is enabled and has loaded plugins. Updates event emission logic to use isActive instead of isEnable, ensuring events are only sent to active adapters.
2026-01-14 13:18:37 +08:00
手瓜一十雪
76447a385f Add onLoginRecordUpdate method to listener
Introduces the onLoginRecordUpdate method to NodeIKernelLoginListener, preparing for future handling of login record updates.
2026-01-14 13:13:18 +08:00
手瓜一十雪
5047b03303 Refactor network adapter activation and message handling
Introduces isActive property to network adapters for more accurate activation checks, refactors message dispatch logic to use only active adapters, and improves heartbeat management for WebSocket adapters. Also sets default enableWebsocket to false in config and frontend forms, and adds a security dialog for missing tokens in the web UI.
2026-01-14 13:11:17 +08:00
手瓜一十雪
fbccf8be24 Make emoji_likes_list optional in OB11Message
Changed the OB11Message interface to make emoji_likes_list optional and updated GetMsg to initialize emoji_likes_list as an empty array before populating it. This prevents errors when the field is missing and improves type safety.
2026-01-13 17:08:31 +08:00
手瓜一十雪
37ae17b53f Remove unused imports and update method params
Removed the unused 'readFileSync' import from ffmpeg-addon-adapter.ts. Updated parameter names in convertToNTSilkTct method of ffmpeg-exec-adapter.ts to use underscores, indicating unused variables.
2026-01-13 17:01:00 +08:00
手瓜一十雪
35566970fd Update pnpm-lock.yaml 2026-01-13 16:57:00 +08:00
手瓜一十雪
e70cd1eff7 Update QQ download links to version 44343
Updated Windows and Linux QQ download links in default.md and release_note_prompt.txt to point to version 9.9.26-44343 and 3.2.23-44343, replacing previous 40990 links.
2026-01-13 16:54:57 +08:00
手瓜一十雪
fbd3241845 Improve version info UI and update model config
Refined the system info version comparison layout for better responsiveness and readability, especially on smaller screens. Updated the OpenRouter model name in the release workflow and improved dark mode text color handling in sidebar menu items.
2026-01-13 16:50:46 +08:00
手瓜一十雪
cf69ccdbc9 Add emoji likes list support to message types
Introduces the emojiLikesList property to RawMessage and maps it to the new emoji_likes_list field in OB11Message, which is populated in the GetMsg action. Also updates type definitions for stricter typing and consistency.
2026-01-13 16:43:00 +08:00
Makoto
f3de4d48d3 feat: add settings field to group notice API response (#1505)
* feat: add settings field to group notice API response

- Add settings field to GroupNotice interface
- Include announcement configuration options (is_show_edit_card, remind_ts, tip_window_type, confirm_required)
- Fixes #1503

* refactor: make settings field optional for backward compatibility

- Mark settings as optional in GroupNotice interface
- Mark settings as optional in WebApiGroupNoticeFeed type
- Prevents runtime errors when processing older or malformed notices
- Addresses code review feedback on PR #1505

* Update GetGroupNotice.ts

---------

Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
2026-01-13 16:32:03 +08:00
手瓜一十雪
17d5110069 Add convertToNTSilkTct to FFmpeg adapters and update usage (#1517)
Introduces the convertToNTSilkTct method to FFmpeg adapter interfaces and implementations, updating audio conversion logic to use this new method for Silk format conversion. Refactors FFmpegService to rename convertFile to convertAudioFmt and updates related usages. Removes 'audio-worker' entry from vite configs in napcat-framework and napcat-shell. Also fixes a typo in appid.json.

Remove silk-wasm dependency and refactor audio handling

Eliminated the silk-wasm package and related code, including audio-worker and direct Silk encoding/decoding logic. Audio format conversion and Silk detection are now handled via FFmpeg adapters. Updated related OneBot actions and configuration files to remove all references to silk-wasm and streamline audio processing.
2026-01-13 16:18:32 +08:00
手瓜一十雪
c5de5e00fc Add mappings for version 3.2.23-44343 (arm64 and x64)
Updated napi2native.json to include send and recv addresses for 3.2.23-44343 on both arm64 and x64 architectures.
2026-01-09 15:33:15 +08:00
手瓜一十雪
ea7cd7f7e1 Add new version mappings to external JSON files
Updated appid.json, napi2native.json, and packet.json to include mappings for versions 9.9.26-44343 and 3.2.23-44343, including x64 and arm64 variants. This ensures compatibility with the latest application versions and platforms.
2026-01-09 13:35:15 +08:00
手瓜一十雪
cc23599776 Enhance HTTP debug UI with command palette and UI improvements
Added a new CommandPalette component for quick API selection and execution (Ctrl/Cmd+K). Refactored the HTTP debug page to use the command palette, improved tab and panel UI, and enhanced the code editor's appearance and theme integration. Updated OneBotApiDebug to support imperative methods for request body and sending, improved response panel resizing, and made various UI/UX refinements across related components.
2026-01-04 20:38:08 +08:00
手瓜一十雪
c6ec2126e0 Refactor theme font handling and preview logic
Moved font configuration to be managed via theme.css, eliminating the need for separate font initialization and caching. Updated backend to generate @font-face rules and font variables in theme.css. Frontend now uses a dedicated style tag for real-time font preview in the theme config page, and removes legacy font cache logic for improved consistency.
2026-01-04 18:48:16 +08:00
手瓜一十雪
f1756c4d1c Optimize version fetching and update logic
Introduces lazy loading for release and action artifact versions, adds support for nightly.link mirrors, and improves artifact retrieval reliability. Removes unused loginService references, refactors update logic to handle action artifacts, and streamlines frontend/backend API parameters for version selection.
2026-01-04 12:41:21 +08:00
手瓜一十雪
4940d72867 Update release workflow
Updates the release workflow to download and include NapCat.Shell.Windows.OneKey.zip in the release artifacts.
2026-01-03 18:37:17 +08:00
手瓜一十雪
91e0839ed5 Add upload_file option for file upload actions
Introduces an 'upload_file' boolean option to group and private file upload actions, allowing control over whether files are uploaded to group storage or sent directly. Updates the NTQQFileApi and OneBotFileApi to support this option and adjusts file handling logic accordingly.
2026-01-03 16:25:38 +08:00
手瓜一十雪
334c4233e6 Update message retrieval and parsing logic
Changed the protocol fallback logic to pass an additional argument to parseMessageV2 and updated message retrieval to use getMsgHistory instead of getMsgsByMsgId. This improves compatibility and ensures correct message fetching.
2026-01-03 16:05:03 +08:00
手瓜一十雪
71bb4f68f3 Improve senderUin handling in sendMsg method
If senderUin is missing or '0', attempt to retrieve it using senderUid before returning. This ensures messages are not dropped when senderUid is available but senderUin is not.
2026-01-03 16:01:24 +08:00
手瓜一十雪
47983e2915 Add PTT element type to message element filters
Updated the filtering logic in SendMsgBase to include ElementType.PTT alongside FILE, VIDEO, and ARK types. This ensures PTT elements are handled consistently with other single-element message types.
2026-01-03 15:38:13 +08:00
手瓜一十雪
ae42eed6e2 Fix font reset on unmount with unsaved changes
Added a ref to track unsaved changes and updated the cleanup logic to only restore the saved font settings if there are unsaved changes. This prevents the font from being unintentionally reset when the page is refreshed or the component is unmounted without changes.
2026-01-03 15:36:42 +08:00
手瓜一十雪
cb061890d3 Enhance artifact handling and display for action builds
Extended artifact metadata to include workflow run ID and head SHA. Updated backend to filter artifacts by environment and provide additional metadata. Improved frontend to display new artifact details and adjusted UI for better clarity.
2026-01-03 15:28:18 +08:00
手瓜一十雪
31feec26b5 Update release.yml 2026-01-03 15:11:58 +08:00
手瓜一十雪
e93cd3529f Update pr-build.yml 2026-01-03 15:10:03 +08:00
手瓜一十雪
1ad700b935 Update release workflow and documentation prompts
Refactored the release workflow to add semantic version validation, improved commit and file diff collection, and enhanced release note generation with more context and formatting. Updated release note and default documentation prompts for clarity, conciseness, and better user guidance. Fixed owner typo in workflow and improved error handling for missing tags.
2026-01-03 15:01:10 +08:00
手瓜一十雪
68c8b984ad Refactor update logging to use logger interface
Replaces all console logging in the update process with the ILogWrapper logger interface for consistent logging. Updates applyPendingUpdates to require a logger parameter and propagates this change to all relevant initialization code. Also removes duplicate and unnecessary lines in workflow YAML files.
2026-01-03 14:51:56 +08:00
手瓜一十雪
8eb1aa2fb4 Refactor GitHub tag fetching and mirror management
Replaces legacy tag fetching logic in napcat-common with a new mirror.ts module that centralizes GitHub mirror configuration, selection, and tag retrieval. Updates helper.ts to use the new mirror system and semver comparison, and exports compareSemVer for broader use. Updates workflows and scripts to generate and propagate build version information, and improves build status comment formatting for PRs. Also updates release workflow to use a new OpenAI key and model.
2026-01-03 14:42:24 +08:00
Makoto
2d3f4e696b feat: Add OB11GroupGrayTipEvent for detecting forged gray tip attacks (#1492)
* feat: Add OB11GroupGrayTipEvent for detecting forged gray tip attacks

- Add new OB11GroupGrayTipEvent class to report unknown gray tip messages
- Modify parseOtherJsonEvent to detect forged gray tips by checking senderUin
- Real system gray tips have senderUin='0', forged ones expose attacker's QQ
- Include message_id in event for downstream recall capability
- Add try/catch for JSON.parse to handle malformed content
- Use Number() for consistent type conversion

* fix: simplify logWarn to match upstream style

* fix: remove extra closing brace that broke class structure

* fix: add validation for malformed title gray tip events
2026-01-02 20:55:24 +08:00
时瑾
b241881c74 fix: 修复用户ID类型转换错误并移除不必要的标签渲染 2026-01-02 20:50:13 +08:00
时瑾
aecf33f4dc fix: close #1488 2026-01-02 17:07:39 +08:00
时瑾
dd4374389b fix: close #1435 (#1485)
* fix: close #1435

* fix: 优化视频缩略图生成和清理逻辑,处理文件不存在的情况
2026-01-01 21:41:01 +08:00
时瑾
100efb03ab fix: close #1477 (#1484) 2026-01-01 21:40:49 +08:00
时瑾
ce9482f19d feat: 优化webui界面和文件管理器 (#1472) 2026-01-01 21:40:39 +08:00
手瓜一十雪
4e37b002f9 Add support for version 9.9.26-44175 and fix import type
Added entries for version 9.9.26-44175 in appid.json, napi2native.json, and packet.json to support the new version. Also updated the import of createActionMap in napcat-plugin/index.ts to use a type-only import.
2026-01-01 10:32:59 +08:00
Nepenthe
7e7262415b 更新插件示例,修复插件打包问题 (#1486)
* fix: 修复打包错误

* fix: 完善插件模板

* Update index.ts
2025-12-31 13:58:55 +08:00
时瑾
3365211507 ci: 添加构建结果评论中的下载链接和获取 artifacts 列表功能 2025-12-29 03:14:17 +08:00
时瑾
05b38825c0 ci: 使用 type 导入 PullRequest 和 BuildStatus 类型 2025-12-29 03:01:21 +08:00
时瑾
95f4a4d37e ci: pr build 2025-12-29 02:55:11 +08:00
182 changed files with 12514 additions and 1483 deletions

View File

@@ -1,4 +1,4 @@
# V?.?.?
# {VERSION}
[使用文档](https://napneko.github.io/)
## Windows 一键包
@@ -15,13 +15,29 @@ NapCat.Shell.Windows.OneKey.zip (无头)
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看**
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
**[9.9.26-44343 X64 Win](https://dldir1.qq.com/qqfile/qq/QQNT/40d6045a/QQ9.9.26.44343_x64.exe)**
[LinuxX64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_amd64.deb)
[LinuxX64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.rpm)
[LinuxArm64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.deb)
[LinuxArm64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_aarch64.rpm)
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新
## 更新
### 🐛 修复
1. 修复 WebUI 主题配置在有未保存更改时卸载组件导致字体重置的问题 (ae42eed6)
### ✨ 新增
1. 文件上传相关接口UploadGroupFile/UploadPrivateFile新增 `upload_file` 参数支持 (91e0839e)
2. 消息发送逻辑支持 PTT语音元素过滤确保语音消息正确独立发送 (47983e29)
### 🔧 优化
1. 优化合并转发消息GetForwardMsg的获取与解析逻辑提高兼容性 (334c4233)
2. 改进消息发送方法中发送者 UIN 的处理逻辑 (71bb4f68)
3. 增强 WebUI 系统信息界面中对构建产物的处理与展示 (cb061890)
---
**完整更新日志**: [v4.10.6...v4.10.7](https://github.com/NapNeko/NapCatQQ/compare/v4.10.6...v4.10.7)

View File

@@ -1,34 +1,33 @@
注意:输出必须严格使用 NapCat 的发布说明格式,严格保证示例格式,并用简体中文。
# NapCat Release Note Generator
格式规则:
1. 第一行:# V{TAG}
2. 第二行:[使用文档](https://napneko.github.io/)
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。
## Windows 一键包
- 简短一句话介绍一键包用途
- 列出可下载的文件名(只列文件名,不写下载链接)
## 核心规则
## 警告
- 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号,如果版本号是小写 v 开头(如 v4.10.2),必须转换为大写 V如 V4.10.2
2. **语言**:全部使用简体中文
3. **格式**:严格按照下方模板输出,不要添加额外的 markdown 格式
## 如果WinX64缺少运行库或者xxx.dll
- 常见运行库建议
## Commit 分析规则
## 更新
按数字序列列出主要变更项,每条尽量一句话
- 前缀短 commit id例如1. 修复 get_essence_msg_list 崩溃 (a1b2c3d)
- 保持 4-18 条要点
将 commit 分类为以下类型:
- 🐛 **修复**bug fix、修复、fix 相关
- ✨ **新增**新功能、feat、add 相关
- 🔧 **优化**优化、重构、refactor、improve、perf 相关
- 📦 **依赖**deps、依赖更新通常可以忽略或合并
- 🔨 **构建**ci、build、workflow 相关(通常可以忽略)
## 开发者注意
- 列出迁移/接口断裂/配置变更;若无则省略
## 合并和筛选
额外约束:
- 语言简体中文,面向最终用户
- **合并相似项**:同一功能的多个 commit 合并为一条
- **忽略琐碎项**合并冲突、格式化、typo 等可忽略
- **控制数量**:最终保持 5-15 条更新要点
- **保留 commit hash**:每条末尾附上短 hash格式 `(a1b2c3d)`
下面为真实示例,请完全参考(第一行版本号必须使用用户提供的版本号,例如 v4.9.5
## 输出模板 - 必须严格遵守以下格式
# V4.9.0
```
# {VERSION}
[使用文档](https://napneko.github.io/)
## Windows 一键包
@@ -37,7 +36,7 @@
你可以下载
NapCat.Shell.Windows.OneKey.zip (无头)
NapCat.Shell.Windows.OneKey.zip (无头)
启动后可自动化部署一键包,教程参考使用文档安装部分
@@ -45,16 +44,68 @@ NapCat.Shell.Windows.OneKey.zip (无头)
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看**
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
**[9.9.26-44343 X64 Win](https://dldir1.qq.com/qqfile/qq/QQNT/40d6045a/QQ9.9.26.44343_x64.exe)**
[LinuxX64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_amd64.deb)
[LinuxX64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.rpm)
[LinuxArm64 DEB 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.deb)
[LinuxArm64 RPM 44343 ](https://dldir1.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_aarch64.rpm)
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新
1. 修改了XXXXX
2. 新增了XXXX
3. 重构了XXXX
### 🐛 修复
1. 修复 xxx 问题 (a1b2c3d)
2. 修复 yyy 崩溃 (b2c3d4e)
### ✨ 新增
1. 新增 xxx 功能 (c3d4e5f)
2. 支持 yyy 特性 (d4e5f6g)
### 🔧 优化
1. 优化 xxx 性能 (e5f6g7h)
2. 重构 yyy 模块 (f6g7h8i)
---
**完整更新日志**: [{PREV_VERSION}...{VERSION}](https://github.com/NapNeko/NapCatQQ/compare/{PREV_VERSION}...{VERSION})
```
**格式要求 - 务必严格遵守:**
- "Windows 一键包"部分的文本必须完全一致,不要修改任何措辞
- "警告"部分必须包含所有 QQ 版本下载链接,保持原有格式
- "如果WinX64缺少运行库或者xxx.dll"这一行必须保持原样
- QQ 版本号和下载链接保持不变40990 版本)
- 只有"## 更新"部分下面的内容需要根据实际 commit 生成
## 重要约束
1. 如果某个分类没有内容,则完全省略该分类
2. 不要编造不存在的更新
3. 保持简洁,每条更新控制在一行内
4. 使用用户友好的语言,避免过于技术化的描述
5. 重大变更Breaking Changes需要在注意事项中加粗提示
## 文件变化分析
用户会提供文件变化统计和具体代码diff帮助你理解变更内容
### 目录含义
- `packages/napcat-core/` → 核心功能、消息处理、QQ接口
- `packages/napcat-onebot/` → OneBot 协议实现、API、事件
- `packages/napcat-webui-backend/` → WebUI 后端接口
- `packages/napcat-webui-frontend/` → WebUI 前端界面
- `packages/napcat-shell/` → Shell 启动器
### 代码diff阅读指南
- `+` 开头的行是新增代码
- `-` 开头的行是删除代码
- 关注函数名、类名的变化来理解功能变更
- 关注 `fix`、`bug`、`error` 等关键词识别修复项
- 关注 `add`、`new`、`feature` 等关键词识别新功能
- 忽略纯重构(代码移动但功能不变)和格式化变更
### 截断说明
- 如果看到 `[... 已截断 ...]`,表示内容过长被截断
- 根据已有信息推断完整变更意图即可

231
.github/scripts/lib/comment.ts vendored Normal file
View File

@@ -0,0 +1,231 @@
/**
* 构建状态评论模板
*/
export const COMMENT_MARKER = '<!-- napcat-pr-build -->';
export type BuildStatus = 'success' | 'failure' | 'cancelled' | 'pending' | 'unknown';
export interface BuildTarget {
name: string;
status: BuildStatus;
error?: string;
downloadUrl?: string; // Artifact 直接下载链接
}
// ============== 辅助函数 ==============
function formatSha (sha: string): string {
return sha && sha.length >= 7 ? sha.substring(0, 7) : sha || 'unknown';
}
function escapeCodeBlock (text: string): string {
// 替换 ``` 为转义形式,避免破坏 Markdown 代码块
return text.replace(/```/g, '\\`\\`\\`');
}
function getTimeString (): string {
return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
// ============== 状态图标 ==============
export function getStatusIcon (status: BuildStatus): string {
switch (status) {
case 'success':
return '✅ 成功';
case 'pending':
return '⏳ 构建中...';
case 'cancelled':
return '⚪ 已取消';
case 'failure':
return '❌ 失败';
default:
return '❓ 未知';
}
}
function getStatusEmoji (status: BuildStatus): string {
switch (status) {
case 'success': return '✅';
case 'pending': return '⏳';
case 'cancelled': return '⚪';
case 'failure': return '❌';
default: return '❓';
}
}
// ============== 构建中评论 ==============
export function generateBuildingComment (prSha: string, targets: string[]): string {
const time = getTimeString();
const shortSha = formatSha(prSha);
const lines: string[] = [
COMMENT_MARKER,
'',
'<div align="center">',
'',
'# 🔨 NapCat 构建中',
'',
'![Building](https://img.shields.io/badge/状态-构建中-yellow?style=for-the-badge&logo=github-actions&logoColor=white)',
'',
'</div>',
'',
'---',
'',
'## 📦 构建目标',
'',
'| 包名 | 状态 | 说明 |',
'| :--- | :---: | :--- |',
...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
'',
'---',
'',
'## 📋 构建信息',
'',
`| 项目 | 值 |`,
`| :--- | :--- |`,
`| 📝 提交 | \`${shortSha}\` |`,
`| 🕐 开始时间 | ${time} |`,
'',
'---',
'',
'<div align="center">',
'',
'> ⏳ **构建进行中,请稍候...**',
'>',
'> 构建完成后将自动更新此评论',
'',
'</div>',
];
return lines.join('\n');
}
// ============== 构建结果评论 ==============
export function generateResultComment (
targets: BuildTarget[],
prSha: string,
runId: string,
repository: string,
version?: string
): string {
const runUrl = `https://github.com/${repository}/actions/runs/${runId}`;
const shortSha = formatSha(prSha);
const time = getTimeString();
const allSuccess = targets.every(t => t.status === 'success');
const anyCancelled = targets.some(t => t.status === 'cancelled');
const anyFailure = targets.some(t => t.status === 'failure');
// 状态徽章
let statusBadge: string;
let headerTitle: string;
if (allSuccess) {
statusBadge = '![Success](https://img.shields.io/badge/状态-构建成功-success?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ✅ NapCat 构建成功';
} else if (anyCancelled && !anyFailure) {
statusBadge = '![Cancelled](https://img.shields.io/badge/状态-已取消-lightgrey?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ⚪ NapCat 构建已取消';
} else {
statusBadge = '![Failed](https://img.shields.io/badge/状态-构建失败-critical?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ❌ NapCat 构建失败';
}
const downloadLink = (target: BuildTarget) => {
if (target.status !== 'success') return '—';
if (target.downloadUrl) {
return `[📥 下载](${target.downloadUrl})`;
}
return `[📥 下载](${runUrl}#artifacts)`;
};
const lines: string[] = [
COMMENT_MARKER,
'',
'<div align="center">',
'',
headerTitle,
'',
statusBadge,
'',
'</div>',
'',
'---',
'',
'## 📦 构建产物',
'',
'| 包名 | 状态 | 下载 |',
'| :--- | :---: | :---: |',
...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`),
'',
'---',
'',
'## 📋 构建信息',
'',
`| 项目 | 值 |`,
`| :--- | :--- |`,
...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []),
`| 📝 提交 | \`${shortSha}\` |`,
`| 🔗 构建日志 | [查看详情](${runUrl}) |`,
`| 🕐 完成时间 | ${time} |`,
];
// 添加错误详情
const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
if (failedTargets.length > 0) {
lines.push('', '---', '', '## ⚠️ 错误详情', '');
for (const target of failedTargets) {
lines.push(
`<details>`,
`<summary>🔴 <b>${target.name}</b> 构建错误</summary>`,
'',
'```',
escapeCodeBlock(target.error!),
'```',
'',
'</details>',
''
);
}
}
// 添加底部提示
lines.push('---', '');
if (allSuccess) {
lines.push(
'<div align="center">',
'',
'> 🎉 **所有构建均已成功完成!**',
'>',
'> 点击上方下载链接获取构建产物进行测试',
'',
'</div>'
);
} else if (anyCancelled && !anyFailure) {
lines.push(
'<div align="center">',
'',
'> ⚪ **构建已被取消**',
'>',
'> 可能是由于新的提交触发了新的构建',
'',
'</div>'
);
} else {
lines.push(
'<div align="center">',
'',
'> ⚠️ **部分构建失败**',
'>',
'> 请查看上方错误详情或点击构建日志查看完整输出',
'',
'</div>'
);
}
return lines.join('\n');
}

189
.github/scripts/lib/github.ts vendored Normal file
View File

@@ -0,0 +1,189 @@
/**
* GitHub API 工具库
*/
import { appendFileSync } from 'node:fs';
// ============== 类型定义 ==============
export interface PullRequest {
number: number;
state: string;
head: {
sha: string;
ref: string;
repo: {
full_name: string;
};
};
}
export interface Repository {
owner: {
type: string;
};
}
export interface Artifact {
id: number;
name: string;
size_in_bytes: number;
archive_download_url: string;
}
// ============== GitHub API Client ==========================
export class GitHubAPI {
private token: string;
private baseUrl = 'https://api.github.com';
constructor (token: string) {
this.token = token;
}
private async request<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
}
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
const data = await this.request<{ permission: string; }>(
`/repos/${owner}/${repo}/collaborators/${username}/permission`
);
return data.permission;
}
async getRepository (owner: string, repo: string): Promise<Repository> {
return this.request(`/repos/${owner}/${repo}`);
}
async checkOrgMembership (org: string, username: string): Promise<boolean> {
try {
await this.request(`/orgs/${org}/members/${username}`);
return true;
} catch {
return false;
}
}
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
const data = await this.request<{ artifacts: Artifact[]; }>(
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
);
return data.artifacts;
}
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
method: 'POST',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise<number | null> {
let page = 1;
const perPage = 100;
while (page <= 10) { // 最多检查 1000 条评论
const comments = await this.request<Array<{ id: number, body: string; }>>(
`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`
);
if (comments.length === 0) {
return null;
}
const found = comments.find(c => c.body.includes(marker));
if (found) {
return found.id;
}
if (comments.length < perPage) {
return null;
}
page++;
}
return null;
}
async updateComment (owner: string, repo: string, commentId: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
method: 'PATCH',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async createOrUpdateComment (
owner: string,
repo: string,
issueNumber: number,
body: string,
marker: string
): Promise<void> {
const existingId = await this.findComment(owner, repo, issueNumber, marker);
if (existingId) {
await this.updateComment(owner, repo, existingId, body);
console.log(`✓ Updated comment #${existingId}`);
} else {
await this.createComment(owner, repo, issueNumber, body);
console.log('✓ Created new comment');
}
}
}
// ============== Output 工具 ==============
export function setOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
appendFileSync(outputFile, `${name}=${value}\n`);
}
console.log(` ${name}=${value}`);
}
export function setMultilineOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `EOF_${Date.now()}`;
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
// ============== 环境变量工具 ==============
export function getEnv (name: string, required: true): string;
export function getEnv (name: string, required?: false): string | undefined;
export function getEnv (name: string, required = false): string | undefined {
const value = process.env[name];
if (required && !value) {
throw new Error(`Environment variable ${name} is required`);
}
return value;
}
export function getRepository (): { owner: string, repo: string; } {
const repository = getEnv('GITHUB_REPOSITORY', true);
const [owner, repo] = repository.split('/');
return { owner, repo };
}

36
.github/scripts/pr-build-building.ts vendored Normal file
View File

@@ -0,0 +1,36 @@
/**
* PR Build - 更新构建中状态评论
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR 编号
* - PR_SHA: PR 提交 SHA
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
import { generateBuildingComment, COMMENT_MARKER } from './lib/comment.ts';
const BUILD_TARGETS = ['NapCat.Framework', 'NapCat.Shell'];
async function main (): Promise<void> {
console.log('🔨 Updating building status comment\n');
const token = getEnv('GITHUB_TOKEN', true);
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA', true);
const { owner, repo } = getRepository();
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Repo: ${owner}/${repo}\n`);
const github = new GitHubAPI(token);
const comment = generateBuildingComment(prSha, BUILD_TARGETS);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

206
.github/scripts/pr-build-check.ts vendored Normal file
View File

@@ -0,0 +1,206 @@
/**
* PR Build Check Script
* 检查 PR 构建触发条件和用户权限
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - GITHUB_EVENT_NAME: 事件名称
* - GITHUB_EVENT_PATH: 事件 payload 文件路径
* - GITHUB_REPOSITORY: 仓库名称 (owner/repo)
* - GITHUB_OUTPUT: 输出文件路径
*/
import { readFileSync } from 'node:fs';
import { GitHubAPI, getEnv, getRepository, setOutput } from './lib/github.ts';
import type { PullRequest } from './lib/github.ts';
// ============== 类型定义 ==============
interface GitHubPayload {
pull_request?: PullRequest;
issue?: {
number: number;
pull_request?: object;
};
comment?: {
body: string;
user: { login: string; };
};
}
interface CheckResult {
should_build: boolean;
pr_number?: number;
pr_sha?: string;
pr_head_repo?: string;
pr_head_ref?: string;
}
// ============== 权限检查 ==============
async function checkUserPermission (
github: GitHubAPI,
owner: string,
repo: string,
username: string
): Promise<boolean> {
// 方法1检查仓库协作者权限
try {
const permission = await github.getCollaboratorPermission(owner, repo, username);
if (['admin', 'write', 'maintain'].includes(permission)) {
console.log(`✓ User ${username} has ${permission} permission`);
return true;
}
console.log(`✗ User ${username} has ${permission} permission (insufficient)`);
} catch (e) {
console.log(`✗ Failed to get collaborator permission: ${(e as Error).message}`);
}
// 方法2检查组织成员身份
try {
const repoInfo = await github.getRepository(owner, repo);
if (repoInfo.owner.type === 'Organization') {
const isMember = await github.checkOrgMembership(owner, username);
if (isMember) {
console.log(`✓ User ${username} is organization member`);
return true;
}
console.log(`✗ User ${username} is not organization member`);
}
} catch (e) {
console.log(`✗ Failed to check org membership: ${(e as Error).message}`);
}
return false;
}
// ============== 事件处理 ==============
function handlePullRequestTarget (payload: GitHubPayload): CheckResult {
const pr = payload.pull_request;
if (!pr) {
console.log('✗ No pull_request in payload');
return { should_build: false };
}
if (pr.state !== 'open') {
console.log(`✗ PR is not open (state: ${pr.state})`);
return { should_build: false };
}
console.log(`✓ PR #${pr.number} is open, triggering build`);
return {
should_build: true,
pr_number: pr.number,
pr_sha: pr.head.sha,
pr_head_repo: pr.head.repo.full_name,
pr_head_ref: pr.head.ref,
};
}
async function handleIssueComment (
payload: GitHubPayload,
github: GitHubAPI,
owner: string,
repo: string
): Promise<CheckResult> {
const { issue, comment } = payload;
if (!issue || !comment) {
console.log('✗ No issue or comment in payload');
return { should_build: false };
}
// 检查是否是 PR 的评论
if (!issue.pull_request) {
console.log('✗ Comment is not on a PR');
return { should_build: false };
}
// 检查是否是 /build 命令
if (!comment.body.trim().startsWith('/build')) {
console.log('✗ Comment is not a /build command');
return { should_build: false };
}
console.log(`→ /build command from @${comment.user.login}`);
// 获取 PR 详情
const pr = await github.getPullRequest(owner, repo, issue.number);
// 检查 PR 状态
if (pr.state !== 'open') {
console.log(`✗ PR is not open (state: ${pr.state})`);
await github.createComment(owner, repo, issue.number, '⚠️ 此 PR 已关闭,无法触发构建。');
return { should_build: false };
}
// 检查用户权限
const username = comment.user.login;
const hasPermission = await checkUserPermission(github, owner, repo, username);
if (!hasPermission) {
console.log(`✗ User ${username} has no permission`);
await github.createComment(
owner,
repo,
issue.number,
`⚠️ @${username} 您没有权限使用 \`/build\` 命令,仅仓库协作者或组织成员可使用。`
);
return { should_build: false };
}
console.log(`✓ Build triggered by @${username}`);
return {
should_build: true,
pr_number: issue.number,
pr_sha: pr.head.sha,
pr_head_repo: pr.head.repo.full_name,
pr_head_ref: pr.head.ref,
};
}
// ============== 主函数 ==============
async function main (): Promise<void> {
console.log('🔍 PR Build Check\n');
const token = getEnv('GITHUB_TOKEN', true);
const eventName = getEnv('GITHUB_EVENT_NAME', true);
const eventPath = getEnv('GITHUB_EVENT_PATH', true);
const { owner, repo } = getRepository();
console.log(`Event: ${eventName}`);
console.log(`Repository: ${owner}/${repo}\n`);
const payload = JSON.parse(readFileSync(eventPath, 'utf-8')) as GitHubPayload;
const github = new GitHubAPI(token);
let result: CheckResult;
switch (eventName) {
case 'pull_request_target':
result = handlePullRequestTarget(payload);
break;
case 'issue_comment':
result = await handleIssueComment(payload, github, owner, repo);
break;
default:
console.log(`✗ Unsupported event: ${eventName}`);
result = { should_build: false };
}
// 输出结果
console.log('\n=== Outputs ===');
setOutput('should_build', String(result.should_build));
setOutput('pr_number', String(result.pr_number ?? ''));
setOutput('pr_sha', result.pr_sha ?? '');
setOutput('pr_head_repo', result.pr_head_repo ?? '');
setOutput('pr_head_ref', result.pr_head_ref ?? '');
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

90
.github/scripts/pr-build-result.ts vendored Normal file
View File

@@ -0,0 +1,90 @@
/**
* PR Build - 更新构建结果评论
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR 编号
* - PR_SHA: PR 提交 SHA
* - RUN_ID: GitHub Actions Run ID
* - NAPCAT_VERSION: 构建版本号
* - FRAMEWORK_STATUS: Framework 构建状态
* - FRAMEWORK_ERROR: Framework 构建错误信息
* - SHELL_STATUS: Shell 构建状态
* - SHELL_ERROR: Shell 构建错误信息
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
import { generateResultComment, COMMENT_MARKER } from './lib/comment.ts';
import type { BuildTarget, BuildStatus } from './lib/comment.ts';
function parseStatus (value: string | undefined): BuildStatus {
if (value === 'success' || value === 'failure' || value === 'cancelled') {
return value;
}
return 'unknown';
}
async function main (): Promise<void> {
console.log('📝 Updating build result comment\n');
const token = getEnv('GITHUB_TOKEN', true);
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA') || 'unknown';
const runId = getEnv('RUN_ID', true);
const version = getEnv('NAPCAT_VERSION') || '';
const { owner, repo } = getRepository();
const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS'));
const frameworkError = getEnv('FRAMEWORK_ERROR');
const shellStatus = parseStatus(getEnv('SHELL_STATUS'));
const shellError = getEnv('SHELL_ERROR');
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Version: ${version}`);
console.log(`Run: ${runId}`);
console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`);
console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`);
const github = new GitHubAPI(token);
const repository = `${owner}/${repo}`;
// 获取 artifacts 列表,生成直接下载链接
const artifactMap: Record<string, string> = {};
try {
const artifacts = await github.getRunArtifacts(owner, repo, runId);
console.log(`Found ${artifacts.length} artifacts`);
for (const artifact of artifacts) {
// 生成直接下载链接https://github.com/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_id}
const downloadUrl = `https://github.com/${repository}/actions/runs/${runId}/artifacts/${artifact.id}`;
artifactMap[artifact.name] = downloadUrl;
console.log(` - ${artifact.name}: ${downloadUrl}`);
}
} catch (e) {
console.log(`Warning: Failed to get artifacts: ${(e as Error).message}`);
}
const targets: BuildTarget[] = [
{
name: 'NapCat.Framework',
status: frameworkStatus,
error: frameworkError,
downloadUrl: artifactMap['NapCat.Framework'],
},
{
name: 'NapCat.Shell',
status: shellStatus,
error: shellError,
downloadUrl: artifactMap['NapCat.Shell'],
},
];
const comment = generateResultComment(targets, prSha, runId, repository, version);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

149
.github/scripts/pr-build-run.ts vendored Normal file
View File

@@ -0,0 +1,149 @@
/**
* PR Build Runner
* 执行构建步骤
*
* 用法: node pr-build-run.ts <target>
* target: framework | shell
*/
import { execSync } from 'node:child_process';
import { existsSync, renameSync, unlinkSync } from 'node:fs';
import { setOutput } from './lib/github.ts';
type BuildTarget = 'framework' | 'shell';
interface BuildStep {
name: string;
command: string;
errorMessage: string;
}
// ============== 构建步骤 ==============
function getCommonSteps (): BuildStep[] {
return [
{
name: 'Install pnpm',
command: 'npm i -g pnpm',
errorMessage: 'Failed to install pnpm',
},
{
name: 'Install dependencies',
command: 'pnpm i',
errorMessage: 'Failed to install dependencies',
},
{
name: 'Type check',
command: 'pnpm run typecheck',
errorMessage: 'Type check failed',
},
{
name: 'Test',
command: 'pnpm test',
errorMessage: 'Tests failed',
},
{
name: 'Build WebUI',
command: 'pnpm --filter napcat-webui-frontend run build',
errorMessage: 'WebUI build failed',
},
];
}
function getTargetSteps (target: BuildTarget): BuildStep[] {
if (target === 'framework') {
return [
{
name: 'Build Framework',
command: 'pnpm run build:framework',
errorMessage: 'Framework build failed',
},
];
}
return [
{
name: 'Build Shell',
command: 'pnpm run build:shell',
errorMessage: 'Shell build failed',
},
];
}
// ============== 执行器 ==============
function runStep (step: BuildStep): boolean {
console.log(`\n::group::${step.name}`);
console.log(`> ${step.command}\n`);
try {
execSync(step.command, {
stdio: 'inherit',
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
});
console.log('::endgroup::');
console.log(`${step.name}`);
return true;
} catch (_error) {
console.log('::endgroup::');
console.log(`${step.name}`);
setOutput('error', step.errorMessage);
return false;
}
}
function postBuild (target: BuildTarget): void {
const srcDir = target === 'framework'
? 'packages/napcat-framework/dist'
: 'packages/napcat-shell/dist';
const destDir = target === 'framework' ? 'framework-dist' : 'shell-dist';
console.log(`\n→ Moving ${srcDir} to ${destDir}`);
if (!existsSync(srcDir)) {
throw new Error(`Build output not found: ${srcDir}`);
}
renameSync(srcDir, destDir);
// Install production dependencies
console.log('→ Installing production dependencies');
execSync('npm install --omit=dev', {
cwd: destDir,
stdio: 'inherit',
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
});
// Remove package-lock.json
const lockFile = `${destDir}/package-lock.json`;
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
console.log(`✓ Build output ready at ${destDir}`);
}
// ============== 主函数 ==============
function main (): void {
const target = process.argv[2] as BuildTarget;
if (!target || !['framework', 'shell'].includes(target)) {
console.error('Usage: node pr-build-run.ts <framework|shell>');
process.exit(1);
}
console.log(`🔨 Building NapCat.${target === 'framework' ? 'Framework' : 'Shell'}\n`);
const steps = [...getCommonSteps(), ...getTargetSteps(target)];
for (const step of steps) {
if (!runStep(step)) {
process.exit(1);
}
}
postBuild(target);
console.log('\n✅ Build completed successfully!');
}
main();

View File

@@ -46,8 +46,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.AppImage' # 写死 QQ 版本
run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_version_x86_64=${QQ_VERSION_X86_64}, qq_version_arm64=${QQ_VERSION_ARM64}"
curl -X POST \
@@ -72,8 +72,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/94704804/linuxqq_3.2.23-44343_arm64.AppImage' # 写死 QQ 版本
run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}"
curl -X POST \

View File

@@ -13,11 +13,27 @@ jobs:
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Generate Version
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i
@@ -39,11 +55,27 @@ jobs:
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Generate Version
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
- name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i

303
.github/workflows/pr-build.yml vendored Normal file
View File

@@ -0,0 +1,303 @@
# =============================================================================
# PR 构建工作流
# =============================================================================
# 功能:
# 1. 在 PR 提交时自动构建 Framework 和 Shell 包
# 2. 支持通过 /build 命令手动触发构建(仅协作者/组织成员)
# 3. 在 PR 中发布构建状态评论,并持续更新(不会重复创建)
# 4. 支持 Fork PR 的构建(使用 pull_request_target 获取写权限)
#
# 安全说明:
# - 使用 pull_request_target 事件,在 base 分支上下文运行
# - 构建脚本始终从 base 分支 checkout避免恶意 PR 篡改脚本
# - PR 代码单独 checkout 到 workspace 目录
# =============================================================================
name: PR Build
# =============================================================================
# 触发条件
# =============================================================================
on:
# PR 事件:打开、同步(新推送)、重新打开时触发
# 注意:使用 pull_request_target 而非 pull_request以便对 Fork PR 有写权限
pull_request_target:
types: [opened, synchronize, reopened]
# Issue 评论事件:用于响应 /build 命令
issue_comment:
types: [created]
# =============================================================================
# 权限配置
# =============================================================================
permissions:
contents: read # 读取仓库内容
pull-requests: write # 写入 PR 评论
issues: write # 写入 Issue 评论(/build 命令响应)
actions: read # 读取 Actions 信息(获取构建日志链接)
# =============================================================================
# 并发控制
# =============================================================================
# 同一 PR 的多次构建会取消之前未完成的构建,避免资源浪费
# 注意:只有在 should_build=true 时才会进入实际构建流程,
# issue_comment 事件如果不是 /build 命令,会在 check-build 阶段快速退出,
# 不会取消正在进行的构建(因为 cancel-in-progress 只影响同 group 的后续任务)
concurrency:
# 使用不同的 group 策略:
# - pull_request_target: 使用 PR 号
# - issue_comment: 只有确认是 /build 命令时才使用 PR 号,否则使用 run_id不冲突
group: pr-build-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/build') && github.event.issue.number || github.run_id }}
cancel-in-progress: true
# =============================================================================
# 任务定义
# =============================================================================
jobs:
# ---------------------------------------------------------------------------
# Job 1: 检查构建条件
# ---------------------------------------------------------------------------
# 判断是否应该触发构建:
# - pull_request_target 事件:总是触发
# - issue_comment 事件:检查是否为 /build 命令,且用户有权限
# ---------------------------------------------------------------------------
check-build:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }} # 是否应该构建
pr_number: ${{ steps.check.outputs.pr_number }} # PR 编号
pr_sha: ${{ steps.check.outputs.pr_sha }} # PR 最新提交 SHA
pr_head_repo: ${{ steps.check.outputs.pr_head_repo }} # PR 源仓库(用于 Fork
pr_head_ref: ${{ steps.check.outputs.pr_head_ref }} # PR 源分支
steps:
# 仅 checkout 脚本目录,加快速度
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
# 使用 Node.js 24 以支持原生 TypeScript 执行
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 执行检查脚本,判断是否触发构建
- name: Check trigger condition
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node --experimental-strip-types .github/scripts/pr-build-check.ts
# ---------------------------------------------------------------------------
# Job 2: 更新评论为"构建中"状态
# ---------------------------------------------------------------------------
# 在 PR 中创建或更新评论,显示构建正在进行中
# ---------------------------------------------------------------------------
update-comment-building:
needs: check-build
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 更新 PR 评论,显示构建中状态
- name: Update building comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
run: node --experimental-strip-types .github/scripts/pr-build-building.ts
# ---------------------------------------------------------------------------
# Job 3: 构建 Framework 包
# ---------------------------------------------------------------------------
# 执行 napcat-framework 的构建流程
# ---------------------------------------------------------------------------
build-framework:
needs: [check-build, update-comment-building]
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
# 这样即使 PR 中修改了脚本,也不会被执行
- name: Checkout scripts from base
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
path: _scripts
# 将 PR 代码 checkout 到单独的 workspace 目录
- name: Checkout PR code
uses: actions/checkout@v4
with:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 获取最新 release tag 并生成版本号
- name: Generate Version
id: version
working-directory: workspace
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
SHORT_SHA="${SHORT_SHA::7}"
VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
# 执行构建,使用 base 分支的脚本处理 workspace 中的代码
- name: Build
id: build
working-directory: workspace
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework
continue-on-error: true # 允许失败,后续更新评论时处理
# 构建成功时上传产物
- name: Upload Artifact
if: steps.build.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: NapCat.Framework
path: workspace/framework-dist
retention-days: 7 # 保留 7 天
# ---------------------------------------------------------------------------
# Job 4: 构建 Shell 包
# ---------------------------------------------------------------------------
# 执行 napcat-shell 的构建流程(与 Framework 并行执行)
# ---------------------------------------------------------------------------
build-shell:
needs: [check-build, update-comment-building]
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
- name: Checkout scripts from base
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
path: _scripts
# 将 PR 代码 checkout 到单独的 workspace 目录
- name: Checkout PR code
uses: actions/checkout@v4
with:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 获取最新 release tag 并生成版本号
- name: Generate Version
id: version
working-directory: workspace
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
SHORT_SHA="${SHORT_SHA::7}"
VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
# 执行构建
- name: Build
id: build
working-directory: workspace
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell
continue-on-error: true
# 构建成功时上传产物
- name: Upload Artifact
if: steps.build.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell
path: workspace/shell-dist
retention-days: 7 # 保留 7 天
# ---------------------------------------------------------------------------
# Job 5: 更新评论为构建结果
# ---------------------------------------------------------------------------
# 汇总所有构建结果,更新 PR 评论显示最终状态
# 使用 always() 确保即使构建失败/取消也会执行
# ---------------------------------------------------------------------------
update-comment-result:
needs: [check-build, update-comment-building, build-framework, build-shell]
if: always() && needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 更新评论,显示构建结果和下载链接
- name: Update result comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
RUN_ID: ${{ github.run_id }}
# 构建版本号
NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
# 获取构建状态,如果 job 被跳过则标记为 cancelled
FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }}
FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }}
SHELL_STATUS: ${{ needs.build-shell.outputs.status || 'cancelled' }}
SHELL_ERROR: ${{ needs.build-shell.outputs.error }}
run: node --experimental-strip-types .github/scripts/pr-build-result.ts

View File

@@ -4,17 +4,48 @@ on:
workflow_dispatch:
push:
tags:
- '*'
- 'v*'
permissions: write-all
env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
OPENROUTER_MODEL: "kimi-k2-0905-turbo"
OPENROUTER_MODEL: "copilot/ant/gemini-3-flash-preview"
RELEASE_NAME: "NapCat"
jobs:
# 验证版本号格式
validate-version:
runs-on: ubuntu-latest
outputs:
valid: ${{ steps.check.outputs.valid }}
version: ${{ steps.check.outputs.version }}
steps:
- name: Validate semantic version
id: check
run: |
TAG="${GITHUB_REF#refs/tags/}"
echo "Checking tag: $TAG"
# 语义化版本正则表达式
# 支持: v1.0.0, v1.0.0-beta, v1.0.0-rc.1, v1.0.0-alpha.1+build.123
SEMVER_REGEX="^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?$"
if [[ "$TAG" =~ $SEMVER_REGEX ]]; then
echo "✅ Valid semantic version: $TAG"
echo "valid=true" >> $GITHUB_OUTPUT
echo "version=$TAG" >> $GITHUB_OUTPUT
else
echo "❌ Invalid version format: $TAG"
echo "Expected format: vX.Y.Z or vX.Y.Z-prerelease"
echo "Examples: v1.0.0, v1.2.3-beta, v2.0.0-rc.1"
echo "valid=false" >> $GITHUB_OUTPUT
exit 1
fi
Build-Framework:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
@@ -24,6 +55,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@@ -40,6 +73,8 @@ jobs:
path: framework-dist
Build-Shell:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
@@ -49,6 +84,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@@ -161,6 +198,10 @@ jobs:
with:
path: ./artifacts
- name: Download NapCat.Shell.Windows.OneKey.zip
run: |
curl -L -o NapCat.Shell.Windows.OneKey.zip https://github.com/NapNeko/NapCatResource/raw/main/NapCat.Shell.Windows.OneKey.zip
- name: Zip Artifacts
run: |
cd artifacts
@@ -171,10 +212,10 @@ jobs:
- name: Generate release note via OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner
GITHUB_OWNER: "NapNeko" # 替换成你的 repo owner
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
run: |
set -euo pipefail
@@ -199,41 +240,162 @@ jobs:
done
if [ -z "$PREV_TAG" ]; then
echo " Could not find previous tag for $CURRENT_TAG, aborting."
exit 1
echo "⚠️ Could not find previous tag for $CURRENT_TAG, using first commit"
PREV_TAG=$(git rev-list --max-parents=0 HEAD | head -1)
fi
echo "Previous tag: $PREV_TAG"
# 强制拉取上一个 tag 和当前 tag
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force || true
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force || true
# 获取 commit title + body + 作者,保留换行
COMMITS=$(git log --pretty=format:'%h %B (%an)' "$PREV_TAG".."$CURRENT_TAG" | sed 's/$/\\n/')
# 获取 commit,使用更清晰的格式
# 格式: <type>: <subject> (<hash>)
COMMITS=$(git log --pretty=format:'- %s (%h)' "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || git log --pretty=format:'- %s (%h)' -20)
echo "Commit list from $PREV_TAG to $CURRENT_TAG:"
echo -e "$COMMITS"
echo "$COMMITS"
# 获取文件变化统计
echo "Getting file change statistics..."
FILE_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || echo "")
# 获取总体统计(最后一行)
SUMMARY_LINE=$(echo "$FILE_STATS" | tail -1)
echo "Summary: $SUMMARY_LINE"
# 获取每个文件的变化(去掉最后一行汇总)
# 截断过长的输出最多50个文件每行最多80字符
FILE_CHANGES=$(echo "$FILE_STATS" | head -n -1 | head -50 | cut -c1-80)
# 如果文件变化太多,进一步精简:只保留主要目录的变化
FILE_COUNT=$(echo "$FILE_STATS" | head -n -1 | wc -l)
if [ "$FILE_COUNT" -gt 50 ]; then
echo "Too many files ($FILE_COUNT), grouping by directory..."
# 按目录分组统计
DIR_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | head -n -1 | \
sed 's/|.*//g' | \
awk -F'/' '{if(NF>1) print $1"/"$2; else print $1}' | \
sort | uniq -c | sort -rn | head -20)
FILE_CHANGES="[按目录分组统计 - 共 $FILE_COUNT 个文件变更]
$DIR_STATS"
fi
echo "File changes:"
echo "$FILE_CHANGES"
# 获取具体代码变化关键文件的diff
echo "Getting code diff for key files..."
# 定义关键目录(优先展示这些目录的变化)
KEY_DIRS="packages/napcat-core packages/napcat-onebot packages/napcat-webui-backend"
# 获取变更的关键文件列表(排除测试、配置等)
# 使用 || true 防止 grep 无匹配时返回非零退出码
KEY_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
grep -E "^packages/napcat-(core|onebot|webui-backend|shell)/" || true | \
grep -E "\.(ts|js)$" || true | \
grep -v -E "(test|spec|\.d\.ts|config)" || true | \
head -15) || true
CODE_DIFF=""
DIFF_CHAR_LIMIT=6000 # 总diff字符限制
CURRENT_CHARS=0
if [ -n "$KEY_FILES" ]; then
for file in $KEY_FILES; do
if [ "$CURRENT_CHARS" -ge "$DIFF_CHAR_LIMIT" ]; then
CODE_DIFF="$CODE_DIFF
[... 更多文件变化已截断 ...]"
break
fi
# 获取单个文件的diff限制每个文件最多50行
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -50) || true
FILE_DIFF_LEN=${#FILE_DIFF}
# 如果单个文件diff超过1500字符截断
if [ "$FILE_DIFF_LEN" -gt 1500 ]; then
FILE_DIFF=$(echo "$FILE_DIFF" | head -c 1500)
FILE_DIFF="$FILE_DIFF
[... 文件 $file 变化已截断 ...]"
fi
if [ -n "$FILE_DIFF" ]; then
CODE_DIFF="$CODE_DIFF
### $file
\`\`\`diff
$FILE_DIFF
\`\`\`"
CURRENT_CHARS=$((CURRENT_CHARS + FILE_DIFF_LEN))
fi
done
fi
# 如果没有关键文件变化获取前5个变更文件的diff
if [ -z "$CODE_DIFF" ]; then
echo "No key files changed, getting top changed files..."
TOP_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
grep -E "\.(ts|js|yml|md)$" | head -5) || true
if [ -n "$TOP_FILES" ]; then
for file in $TOP_FILES; do
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -30) || true
if [ -n "$FILE_DIFF" ] && [ ${#FILE_DIFF} -lt 1000 ]; then
CODE_DIFF="$CODE_DIFF
### $file
\`\`\`diff
$FILE_DIFF
\`\`\`"
fi
done
fi
fi
# 如果仍然没有代码变化,添加说明
if [ -z "$CODE_DIFF" ]; then
CODE_DIFF="[本次更新主要涉及配置文件和文档变更,无核心代码变化]"
fi
echo "Code diff preview:"
echo "$CODE_DIFF" | head -50
# 读取 prompt
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
# 构建用户内容
USER_CONTENT="当前真正的版本: $CURRENT_TAG\n提交列表:\n$COMMITS"
# 构建用户内容传递更多上下文包含文件变化和代码diff
USER_CONTENT="当前版本: $CURRENT_TAG
上一版本: $PREV_TAG
## 提交列表
$COMMITS
## 文件变化统计
$SUMMARY_LINE
## 变更文件列表
$FILE_CHANGES
## 关键代码变化
$CODE_DIFF"
# 构建请求 JSON
# 构建请求 JSON,增加 max_tokens 以获取更完整的输出
BODY=$(jq -n \
--arg system "$SYSTEM_PROMPT" \
--arg user "$USER_CONTENT" \
'{model: env.OPENROUTER_MODEL, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.3, max_tokens:800}')
--arg model "$OPENROUTER_MODEL" \
'{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:1500}')
echo "=== OpenRouter request body ==="
echo "$BODY" | jq .
# 调用 OpenRouter
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Authorization: Bearer $OPENAI_KEY" \
-H "Content-Type: application/json" \
-d "$BODY"); then
echo "=== raw response ==="
@@ -250,13 +412,18 @@ jobs:
if [ -z "$RELEASE_BODY" ]; then
echo "❌ OpenRouter failed to generate release note, using default.md"
cp .github/prompt/default.md CHANGELOG.md
# 替换默认模板中的版本占位符
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
else
# 后处理:确保版本号正确,并添加比较链接
echo -e "$RELEASE_BODY" > CHANGELOG.md
# 替换可能的占位符
sed -i "s/{VERSION}/$CURRENT_TAG/g" CHANGELOG.md
sed -i "s/{PREV_VERSION}/$PREV_TAG/g" CHANGELOG.md
fi
else
echo "❌ Curl failed, using default.md"
cp .github/prompt/default.md CHANGELOG.md
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
fi
echo "=== generated release note ==="
cat CHANGELOG.md
@@ -271,4 +438,5 @@ jobs:
NapCat.Shell.Windows.Node.zip
NapCat.Framework.zip
NapCat.Shell.zip
NapCat.Shell.Windows.OneKey.zip
draft: true

View File

@@ -28,7 +28,6 @@
},
"dependencies": {
"express": "^5.0.0",
"silk-wasm": "^3.6.1",
"ws": "^8.18.3"
}
}

View File

@@ -17,8 +17,7 @@
},
"dependencies": {
"ajv": "^8.13.0",
"file-type": "^21.0.0",
"silk-wasm": "^3.6.1"
"file-type": "^21.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"

View File

@@ -1,20 +0,0 @@
import { encode } from 'silk-wasm';
import { parentPort } from 'worker_threads';
export interface EncodeArgs {
input: ArrayBufferView | ArrayBuffer
sampleRate: number
}
export function recvTask<T> (cb: (taskData: T) => Promise<unknown>) {
parentPort?.on('message', async (taskData: T) => {
try {
const ret = await cb(taskData);
parentPort?.postMessage(ret);
} catch (error: unknown) {
parentPort?.postMessage({ error: (error as Error).message });
}
});
}
recvTask<EncodeArgs>(async ({ input, sampleRate }) => {
return await encode(input, sampleRate);
});

View File

@@ -2,7 +2,11 @@ import path from 'node:path';
import fs from 'fs';
import os from 'node:os';
import { QQVersionConfigType, QQLevel } from './types';
import { RequestUtil } from './request';
import { compareSemVer } from './version';
import { getAllGitHubTags as getAllTagsFromMirror } from './mirror';
// 导出 compareSemVer 供其他模块使用
export { compareSemVer } from './version';
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
return new Promise<ReturnType<T> | undefined>((resolve) => {
@@ -213,56 +217,19 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
return undefined;
}
const baseUrl = 'https://github.com/NapNeko/NapCatQQ.git/info/refs?service=git-upload-pack';
const urls = [
'https://j.1win.ggff.net/' + baseUrl,
'https://git.yylx.win/' + baseUrl,
'https://ghfile.geekertao.top/' + baseUrl,
'https://gh-proxy.net/' + baseUrl,
'https://ghm.078465.xyz/' + baseUrl,
'https://gitproxy.127731.xyz/' + baseUrl,
'https://jiashu.1win.eu.org/' + baseUrl,
baseUrl,
];
// ============== GitHub Tags 获取 ==============
// 使用 mirror 模块统一管理镜像
async function testUrl (url: string): Promise<boolean> {
try {
await PromiseTimer(RequestUtil.HttpGetText(url), 5000);
return true;
} catch {
return false;
}
}
async function findAvailableUrl (): Promise<string | null> {
for (const url of urls) {
if (await testUrl(url)) {
return url;
}
}
return null;
}
export async function getAllTags (): Promise<string[]> {
const availableUrl = await findAvailableUrl();
if (!availableUrl) {
throw new Error('No available URL for fetching tags');
}
const raw = await RequestUtil.HttpGetText(availableUrl);
return raw
.split('\n')
.map(line => {
const match = line.match(/refs\/tags\/(.+)$/);
return match ? match[1] : null;
})
.filter(tag => tag !== null && !tag!.endsWith('^{}')) as string[];
export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
}
export async function getLatestTag (): Promise<string> {
const tags = await getAllTags();
const { tags } = await getAllTags();
tags.sort((a, b) => compareVersion(a, b));
// 使用 SemVer 规范排序
tags.sort((a, b) => compareSemVer(a, b));
const latest = tags.at(-1);
if (!latest) {
@@ -271,22 +238,3 @@ export async function getLatestTag (): Promise<string> {
// 去掉开头的 v
return latest.replace(/^v/, '');
}
function compareVersion (a: string, b: string): number {
const normalize = (v: string) =>
v.replace(/^v/, '') // 去掉开头的 v
.split('.')
.map(n => parseInt(n) || 0);
const pa = normalize(a);
const pb = normalize(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na !== nb) return na - nb;
}
return 0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
// 协议管理器 - 用于统一管理多协议适配
export interface ProtocolInfo {
id: string;
name: string;
description: string;
version: string;
enabled: boolean;
}
export interface ProtocolConfig {
protocols: {
[key: string]: {
enabled: boolean;
config: unknown;
};
};
}
export const SUPPORTED_PROTOCOLS: ProtocolInfo[] = [
{
id: 'onebot11',
name: 'OneBot 11',
description: 'OneBot 11 协议适配器,兼容 go-cqhttp',
version: '11.0.0',
enabled: true,
},
{
id: 'satori',
name: 'Satori',
description: 'Satori 协议适配器,跨平台机器人协议',
version: '1.0.0',
enabled: false,
},
];
export function getProtocolInfo (protocolId: string): ProtocolInfo | undefined {
return SUPPORTED_PROTOCOLS.find((p) => p.id === protocolId);
}
export function getSupportedProtocols (): ProtocolInfo[] {
return SUPPORTED_PROTOCOLS;
}

View File

@@ -3,11 +3,11 @@ import http from 'node:http';
export class RequestUtil {
// 适用于获取服务器下发cookies时获取仅GET
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string }> {
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string; }> {
const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
const req = client.get(url, (res) => {
const cookies: { [key: string]: string } = {};
const cookies: { [key: string]: string; } = {};
res.on('data', () => { }); // Necessary to consume the stream
res.on('end', () => {
@@ -27,7 +27,7 @@ export class RequestUtil {
});
}
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string }): Promise<{ [key: string]: string }> {
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string; }): Promise<{ [key: string]: string; }> {
if (res.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) {
const redirectUrl = new URL(res.headers.location, url);
@@ -39,7 +39,7 @@ export class RequestUtil {
return cookies;
}
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) {
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) {
setCookieHeaders.forEach((cookie) => {
const parts = cookie.split(';')[0]?.split('=');
if (parts) {
@@ -53,9 +53,10 @@ export class RequestUtil {
}
// 请求和回复都是JSON data传原始内容 自动编码json
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
// 支持 301/302 重定向(最多 5 次)
static async HttpGetJson<T> (url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string;
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise<T> {
const option = new URL(url);
const protocol = url.startsWith('https://') ? https : http;
const options = {
@@ -71,6 +72,20 @@ export class RequestUtil {
// },
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res: http.IncomingMessage) => {
// 处理重定向
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
if (maxRedirects <= 0) {
reject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).href;
// 递归跟随重定向
this.HttpGetJson<T>(redirectUrl, method, data, headers, isJsonRet, isArgJson, maxRedirects - 1)
.then(resolve)
.catch(reject);
return;
}
let responseBody = '';
res.on('data', (chunk: string | Buffer) => {
responseBody += chunk.toString();
@@ -109,7 +124,7 @@ export class RequestUtil {
}
// 请求返回都是原始内容
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string; } = {}) {
return this.HttpGetJson<string>(url, method, data, headers, false, false);
}
}

View File

@@ -1,2 +1,118 @@
// @ts-ignore
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || 'alpha';
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev';
/**
* SemVer 2.0 正则表达式
* 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
export interface SemVerInfo {
valid: boolean;
normalized: string;
major: number;
minor: number;
patch: number;
prerelease: string | null;
buildmetadata: string | null;
}
/**
* 解析并验证版本号是否符合 SemVer 2.0 规范
* @param version - 版本字符串 (支持 v 前缀)
* @returns SemVer 解析结果
*/
export function parseSemVer (version: string | undefined | null): SemVerInfo {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1]!, 10);
const minor = parseInt(match[2]!, 10);
const patch = parseInt(match[3]!, 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
// 构建标准化版本号(不带 v 前缀)
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
/**
* 验证版本号是否符合 SemVer 2.0 规范
* @param version - 版本字符串
* @returns 是否有效
*/
export function isValidSemVer (version: string | undefined | null): boolean {
return parseSemVer(version).valid;
}
/**
* 比较两个 SemVer 版本号
* @param v1 - 版本号1
* @param v2 - 版本号2
* @returns -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
*/
export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
const a = parseSemVer(v1);
const b = parseSemVer(v2);
if (!a.valid || !b.valid) {
return 0;
}
// 比较主版本号
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
// 比较次版本号
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
// 比较修订号
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
// 有先行版本号的版本优先级较低
if (a.prerelease && !b.prerelease) return -1;
if (!a.prerelease && b.prerelease) return 1;
// 两者都有先行版本号时,按字典序比较
if (a.prerelease && b.prerelease) {
const aParts = a.prerelease.split('.');
const bParts = b.prerelease.split('.');
const len = Math.max(aParts.length, bParts.length);
for (let i = 0; i < len; i++) {
const aPart = aParts[i];
const bPart = bParts[i];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN;
const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN;
// 数字 vs 数字
if (!isNaN(aNum) && !isNaN(bNum)) {
if (aNum !== bNum) return aNum > bNum ? 1 : -1;
continue;
}
// 数字优先级低于字符串
if (!isNaN(aNum)) return -1;
if (!isNaN(bNum)) return 1;
// 字符串 vs 字符串
if (aPart !== bPart) return aPart > bPart ? 1 : -1;
}
}
return 0;
}
/**
* 获取解析后的当前版本信息
*/
export const napCatVersionInfo = parseSemVer(napCatVersion);

View File

@@ -33,7 +33,7 @@ export class NTQQFileApi {
'http://ss.xingzhige.com/music_card/rkey',
'https://secret-service.bietiaop.com/rkeys',
],
this.context.logger
this.context.logger
);
}
@@ -138,7 +138,7 @@ export class NTQQFileApi {
})).urlResult.domainUrl;
}
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0) {
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0, uploadGroupFile = true) {
const fileMd5 = await calculateFileMD5(filePath);
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
@@ -146,24 +146,33 @@ export class NTQQFileApi {
if (fileName.indexOf('.') === -1) {
fileName += ext;
}
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5,
fileName,
elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
});
await this.copyFile(filePath, mediaPath);
const fileSize = await this.getFileSize(filePath);
if (uploadGroupFile) {
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5,
fileName,
elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
});
await this.copyFile(filePath, mediaPath);
return {
md5: fileMd5,
fileName,
path: mediaPath,
fileSize,
ext,
};
}
return {
md5: fileMd5,
fileName,
path: mediaPath,
path: filePath,
fileSize,
ext,
};

View File

@@ -498,5 +498,22 @@
"6.9.86-42941": {
"appid": 537328648,
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
},
"9.9.26-44175": {
"appid": 537336450,
"qua": "V1_WIN_NQ_9.9.26_44175_GW_B"
},
"9.9.26-44343": {
"appid": 537336603,
"qua": "V1_WIN_NQ_9.9.26_44343_GW_B"
},
"3.2.23-44343": {
"appid": 537336639,
"qua": "V1_LNX_NQ_3.2.23_44343_GW_B"
},
"9.9.26-44498": {
"appid": 537337416,
"offset": "0x1809C2810",
"qua": "V1_WIN_NQ_9.9.26_44498_GW_B"
}
}

View File

@@ -126,5 +126,25 @@
"6.9.86-42941-arm64": {
"send": "2346108",
"recv": "09675F0"
},
"9.9.26-44175-x64": {
"send": "0A0F2EC",
"recv": "1D3AD4D"
},
"9.9.26-44343-x64": {
"send": "0A0F7BC",
"recv": "1D3C3CD"
},
"3.2.23-44343-arm64": {
"send": "3C867DC",
"recv": "1404938"
},
"3.2.23-44343-x64": {
"send": "59A27B0",
"recv": "2FFBE90"
},
"9.9.26-44498-x64": {
"send": "0A1051C",
"recv": "1D3BC0D"
}
}

View File

@@ -638,5 +638,25 @@
"6.9.86-42941-arm64": {
"send": "3DDDAD0",
"recv": "3DE03E0"
},
"9.9.26-44175-x64": {
"send": "2CD84A0",
"recv": "2CDBA20"
},
"3.2.23-44343-x64": {
"send": "A46F140",
"recv": "A472BE0"
},
"9.9.26-44343-x64": {
"send": "2CD8EE0",
"recv": "2CDC460"
},
"3.2.23-44343-arm64": {
"send": "6926F60",
"recv": "692A910"
},
"9.9.26-44498-x64": {
"send": "2CDAE40",
"recv": "2CDE3C0"
}
}

View File

@@ -1,19 +1,8 @@
import fsPromise from 'fs/promises';
import path from 'node:path';
import { randomUUID } from 'crypto';
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
import { LogWrapper } from '@/napcat-core/helper/log';
import { EncodeArgs } from 'napcat-common/src/audio-worker';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { runTask } from 'napcat-common/src/worker';
import { fileURLToPath } from 'node:url';
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
function getWorkerPath () {
// return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'audio-worker.mjs');
}
async function guessDuration (pttPath: string, logger: LogWrapper) {
const pttFileInfo = await fsPromise.stat(pttPath);
@@ -22,51 +11,23 @@ async function guessDuration (pttPath: string, logger: LogWrapper) {
return duration;
}
async function handleWavFile (
file: Buffer,
filePath: string,
pcmPath: string
): Promise<{ input: Buffer; sampleRate: number }> {
const { fmt } = getWavFileInfo(file);
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
const result = await FFmpegService.convert(filePath, pcmPath);
return { input: await fsPromise.readFile(pcmPath), sampleRate: result.sampleRate };
}
return { input: file, sampleRate: fmt.sampleRate };
}
export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) {
try {
const file = await fsPromise.readFile(filePath);
const pttPath = path.join(TEMP_DIR, randomUUID());
if (!isSilk(file)) {
if (!(await FFmpegService.isSilk(filePath))) {
logger.log(`语音文件${filePath}需要转换成silk`);
const pcmPath = `${pttPath}.pcm`;
// const { input, sampleRate } = isWav(file) ? await handleWavFile(file, filePath, pcmPath): { input: await FFmpegService.convert(filePath, pcmPath) ? await fsPromise.readFile(pcmPath) : Buffer.alloc(0), sampleRate: 24000 };
let input: Buffer;
let sampleRate: number;
if (isWav(file)) {
const result = await handleWavFile(file, filePath, pcmPath);
input = result.input;
sampleRate = result.sampleRate;
} else {
const result = await FFmpegService.convert(filePath, pcmPath);
input = await fsPromise.readFile(pcmPath);
sampleRate = result.sampleRate;
}
const silk = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { input, sampleRate });
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
await FFmpegService.convertToNTSilkTct(filePath, pttPath);
const duration = await FFmpegService.getDuration(filePath);
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', duration);
return {
converted: true,
path: pttPath,
duration: silk.duration / 1000,
duration: duration,
};
} else {
let duration = 0;
try {
duration = getDuration(file) / 1000;
duration = await FFmpegService.getDuration(filePath);
} catch (e: unknown) {
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
duration = await guessDuration(filePath, logger);

View File

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

View File

@@ -27,21 +27,27 @@ export interface IFFmpegAdapter {
readonly name: string;
/** 是否可用 */
isAvailable(): Promise<boolean>;
isAvailable (): Promise<boolean>;
/**
* 获取视频信息(包含缩略图)
* @param videoPath 视频文件路径
* @returns 视频信息
*/
getVideoInfo(videoPath: string): Promise<VideoInfoResult>;
getVideoInfo (videoPath: string): Promise<VideoInfoResult>;
/**
* 获取音视频文件时长
* @param filePath 文件路径
* @returns 时长(秒)
*/
getDuration(filePath: string): Promise<number>;
getDuration (filePath: string): Promise<number>;
/**
* 判断是否为 Silk 格式
* @param filePath 文件路径
*/
isSilk (filePath: string): Promise<boolean>;
/**
* 转换音频为 PCM 格式
@@ -49,7 +55,7 @@ export interface IFFmpegAdapter {
* @param pcmPath 输出 PCM 文件路径
* @returns PCM 数据 Buffer
*/
convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }>;
convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }>;
/**
* 转换音频文件
@@ -57,12 +63,14 @@ export interface IFFmpegAdapter {
* @param outputFile 输出文件路径
* @param format 目标格式 ('amr' | 'silk' 等)
*/
convertFile(inputFile: string, outputFile: string, format: string): Promise<void>;
convertFile (inputFile: string, outputFile: string, format: string): Promise<void>;
/**
* 提取视频缩略图
* @param videoPath 视频文件路径
* @param thumbnailPath 缩略图输出路径
*/
extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>;
extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void>;
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
}

View File

@@ -5,7 +5,7 @@
import { platform, arch } from 'node:os';
import path from 'node:path';
import { existsSync } from 'node:fs';
import { existsSync, openSync, readSync, closeSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import type { FFmpeg } from './ffmpeg-addon';
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
@@ -87,6 +87,22 @@ export class FFmpegAddonAdapter implements IFFmpegAdapter {
return addon.getDuration(filePath);
}
/**
* 判断是否为 Silk 格式
*/
async isSilk (filePath: string): Promise<boolean> {
try {
const fd = openSync(filePath, 'r');
const buffer = Buffer.alloc(10);
readSync(fd, buffer, 0, 10, 0);
closeSync(fd);
const header = buffer.toString();
return header.includes('#!SILK') || header.includes('\x02#!SILK');
} catch {
return false;
}
}
/**
* 转换为 PCM
*/
@@ -106,6 +122,11 @@ export class FFmpegAddonAdapter implements IFFmpegAdapter {
await addon.decodeAudioToFmt(inputFile, outputFile, format);
}
async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
const addon = this.ensureAddon();
await addon.convertToNTSilkTct(inputFile, outputFile);
}
/**
* 提取缩略图
*/

View File

@@ -70,4 +70,6 @@ export interface FFmpeg {
*/
decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>;
decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>;
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
}

View File

@@ -3,7 +3,7 @@
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
*/
import { readFileSync, existsSync, mkdirSync } from 'fs';
import { readFileSync, existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
import { dirname, join } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
@@ -154,6 +154,22 @@ export class FFmpegExecAdapter implements IFFmpegAdapter {
}
}
/**
* 判断是否为 Silk 格式
*/
async isSilk (filePath: string): Promise<boolean> {
try {
const fd = openSync(filePath, 'r');
const buffer = Buffer.alloc(10);
readSync(fd, buffer, 0, 10, 0);
closeSync(fd);
const header = buffer.toString();
return header.includes('#!SILK') || header.includes('\x02#!SILK');
} catch {
return false;
}
}
/**
* 转换为 PCM
*/
@@ -241,4 +257,8 @@ export class FFmpegExecAdapter implements IFFmpegAdapter {
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
}
}
async convertToNTSilkTct (_inputFile: string, _outputFile: string): Promise<void> {
throw new Error('convertToNTSilkTct is not implemented in FFmpegExecAdapter');
}
}

View File

@@ -64,7 +64,10 @@ export class FFmpegService {
}
return this.adapter;
}
public static async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
const adapter = await this.getAdapter();
await adapter.convertToNTSilkTct(inputFile, outputFile);
}
/**
* 设置 FFmpeg 路径并更新适配器
* @deprecated 建议使用 init() 方法初始化
@@ -92,11 +95,27 @@ export class FFmpegService {
/**
* 转换音频文件
*/
public static async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
public static async convertAudioFmt (inputFile: string, outputFile: string, format: string): Promise<void> {
const adapter = await this.getAdapter();
await adapter.convertFile(inputFile, outputFile, format);
}
/**
* 获取音频时长
*/
public static async getDuration (filePath: string): Promise<number> {
const adapter = await this.getAdapter();
return adapter.getDuration(filePath);
}
/**
* 判断是否为 Silk 格式
*/
public static async isSilk (filePath: string): Promise<boolean> {
const adapter = await this.getAdapter();
return adapter.isSilk(filePath);
}
/**
* 转换为 PCM 格式
*/

View File

@@ -17,7 +17,6 @@ import {
WrapperSessionInitConfig,
} from '@/napcat-core/wrapper';
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
import { NodeIKernelLoginService } from '@/napcat-core/services';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import path from 'node:path';
@@ -278,7 +277,6 @@ export interface InstanceContext {
readonly wrapper: WrapperNodeApi;
readonly session: NodeIQQNTWrapperSession;
readonly logger: LogWrapper;
readonly loginService: NodeIKernelLoginService;
readonly basicInfoWrapper: QQBasicInfoWrapper;
readonly pathWrapper: NapCatPathWrapper;
readonly packetHandler: NativePacketHandler;

View File

@@ -53,6 +53,8 @@ export class NodeIKernelLoginListener {
onLoginState (..._args: any[]): any {
}
onLoginRecordUpdate (..._args: any[]): any {
}
}
export interface QRCodeLoginSucceedResult {

View File

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

View File

@@ -15,8 +15,12 @@ import { NapProtoMsg } from 'napcat-protobuf';
import * as proto from '@/napcat-core/packet/transformer/proto';
import * as trans from '@/napcat-core/packet/transformer';
import fs from 'fs';
import path from 'path';
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { defaultVideoThumbB64 } from '@/napcat-core/helper/ffmpeg/video';
import { calculateFileMD5 } from 'napcat-common/src/file';
export const BlockSize = 1024 * 1024;
@@ -105,13 +109,89 @@ export class PacketHighwayContext {
if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) {
throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB请使用文件上传`);
}
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupVideo(+peer.peerUid, video);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CVideo(peer.peerUid, video);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
// 如果缺少视频缩略图,自动生成一个
let tempThumbPath: string | null = null;
let thumbExists = false;
if (video.thumbPath) {
try {
await fs.promises.access(video.thumbPath, fs.constants.F_OK);
thumbExists = true;
} catch {
thumbExists = false;
}
}
if (!video.thumbPath || !thumbExists) {
tempThumbPath = await this.ensureVideoThumb(video);
}
try {
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupVideo(+peer.peerUid, video);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CVideo(peer.peerUid, video);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
}
} finally {
// 清理临时生成的缩略图文件
if (tempThumbPath) {
const thumbToClean = tempThumbPath;
fs.promises.unlink(thumbToClean)
.then(() => this.logger.debug(`[Highway] Cleaned up temp thumbnail: ${thumbToClean}`))
.catch((e) => {
// 文件不存在时忽略错误
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
this.logger.warn(`[Highway] Failed to clean up temp thumbnail: ${thumbToClean}, reason: ${e instanceof Error ? e.message : e}`);
}
});
}
}
}
/**
* 确保视频缩略图存在,如果不存在则自动生成
* @returns 生成的临时缩略图路径,用于后续清理
*/
private async ensureVideoThumb (video: PacketMsgVideoElement): Promise<string> {
if (!video.filePath) {
throw new Error('video.filePath is empty, cannot generate thumbnail');
}
// 生成缩略图路径
const videoDir = path.dirname(video.filePath);
const videoBasename = path.basename(video.filePath, path.extname(video.filePath));
const thumbPath = path.join(videoDir, `${videoBasename}_thumb.png`);
this.logger.debug(`[Highway] Video thumb missing, generating at: ${thumbPath}`);
try {
// 尝试使用 FFmpeg 提取视频缩略图
await FFmpegService.extractThumbnail(video.filePath, thumbPath);
try {
await fs.promises.access(thumbPath, fs.constants.F_OK);
this.logger.debug('[Highway] Video thumbnail generated successfully using FFmpeg');
} catch {
throw new Error('FFmpeg failed to generate thumbnail');
}
} catch (e) {
// FFmpeg 失败时(包括未初始化的情况)使用默认缩略图
this.logger.warn(`[Highway] Failed to extract thumbnail, using default. Reason: ${e instanceof Error ? e.message : e}`);
await fs.promises.writeFile(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
// 更新视频元素的缩略图信息
video.thumbPath = thumbPath;
const thumbStat = await fs.promises.stat(thumbPath);
video.thumbSize = thumbStat.size;
video.thumbMd5 = await calculateFileMD5(thumbPath);
// 默认缩略图尺寸(与 defaultVideoThumbB64 匹配的尺寸)
if (!video.thumbWidth) video.thumbWidth = 240;
if (!video.thumbHeight) video.thumbHeight = 383;
this.logger.debug(`[Highway] Video thumb info set: path=${thumbPath}, size=${video.thumbSize}, md5=${video.thumbMd5}`);
return thumbPath;
}
async uploadPtt (peer: Peer, ptt: PacketMsgPttElement): Promise<void> {

View File

@@ -14,7 +14,7 @@ export class PacketMsgBuilder {
buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] {
return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => {
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=640&img_type=jpg`;
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=0&img_type=jpg`;
const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => {
return acc ?? msg.buildContent();
}, undefined);

View File

@@ -510,15 +510,15 @@ export class PacketMsgPttElement extends IPacketMsgElement<SendPttElement> {
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [];
// if (!this.msgInfo) return [];
// return [{
// commonElem: {
// serviceType: 48,
// pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
// businessType: 22,
// }
// }];
//return [];
if (!this.msgInfo) return [];
return [{
commonElem: {
serviceType: 48,
pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
businessType: 22,
}
}];
}
override toPreview (): string {

View File

@@ -181,7 +181,7 @@ export interface MessageElement {
tofuRecordElement?: TofuRecordElement,
taskTopMsgElement?: TaskTopMsgElement,
recommendedMsgElement?: RecommendedMsgElement,
actionBarElement?: ActionBarElement
actionBarElement?: ActionBarElement;
}
/**
@@ -337,7 +337,7 @@ export interface InlineKeyboardElementRowButton {
*/
export interface InlineKeyboardElement {
rows: [{
buttons: InlineKeyboardElementRowButton[]
buttons: InlineKeyboardElementRowButton[];
}],
botAppid: string;
}
@@ -441,14 +441,14 @@ export interface TipGroupElement {
uid: string;
card: string;
name: string;
role: NTGroupMemberRole
role: NTGroupMemberRole;
};
member: {
uid: string
uid: string;
card: string;
name: string;
role: NTGroupMemberRole
}
role: NTGroupMemberRole;
};
};
}
@@ -498,6 +498,7 @@ export interface RawMessage {
sendStatus?: SendStatusType;// 消息状态
recallTime: string;// 撤回时间,"0" 是没有撤回
records: RawMessage[];// 消息记录
emojiLikesList?: Array<{ emojiId: string; emojiType: string; likesCnt: string; isClicked: string; }>;
elements: MessageElement[];// 消息元素
sourceType: MsgSourceType;// 消息来源类型
isOnlineMsg: boolean;// 是否为在线消息
@@ -508,9 +509,9 @@ export interface RawMessage {
* 查询消息参数接口
*/
export interface QueryMsgsParams {
chatInfo: Peer & { privilegeFlag?: number };
chatInfo: Peer & { privilegeFlag?: number; };
// searchFields: number;
filterMsgType: Array<{ type: NTMsgType, subType: Array<number> }>;
filterMsgType: Array<{ type: NTMsgType, subType: Array<number>; }>;
filterSendersUid: string[];
filterMsgFromTime: string;
filterMsgToTime: string;
@@ -554,7 +555,7 @@ export interface MsgReqType {
queryOrder: boolean,
includeSelf: boolean,
includeDeleteMsg: boolean,
extraCnt: number
extraCnt: number;
}
/**

View File

@@ -73,7 +73,7 @@ export interface WebApiGroupNoticeFeed {
fn: number;
cn: number;
vn: number;
settings: {
settings?: {
is_show_edit_card: number
remind_ts: number
tip_window_type: number

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import react from '@vitejs/plugin-react-swc';
import napcatVersion from 'napcat-vite/vite-plugin-version.js';
// 依赖排除
const external = [
'silk-wasm',
'ws',
'express',
];
@@ -60,7 +59,6 @@ const FrameworkBaseConfig = () =>
lib: {
entry: {
napcat: path.resolve(__dirname, 'napcat.ts'),
'audio-worker': path.resolve(__dirname, '../napcat-common/src/audio-worker.ts'),
'worker/conoutSocketWorker': path.resolve(__dirname, '../napcat-pty/worker/conoutSocketWorker.ts'),
},
formats: ['es'],

View File

@@ -1,7 +1,6 @@
import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile';
import { ActionName } from '@/napcat-onebot/action/router';
import { promises as fs } from 'fs';
import { decode } from 'silk-wasm';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
const out_format = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac'];
@@ -21,19 +20,13 @@ export default class GetRecord extends GetFileBase {
if (!out_format.includes(payload.out_format)) {
throw new Error('转换失败 out_format 字段可能格式不正确');
}
const pcmFile = `${inputFile}.pcm`;
const outputFile = `${inputFile}.${payload.out_format}`;
try {
await fs.access(inputFile);
try {
await fs.access(outputFile);
} catch {
if (FFmpegService.getAdapterName() === 'FFmpegAddon') {
await FFmpegService.convertFile(inputFile, outputFile, payload.out_format);
} else {
await this.decodeFile(inputFile, pcmFile);
await FFmpegService.convertFile(pcmFile, outputFile, payload.out_format);
}
await FFmpegService.convertAudioFmt(inputFile, outputFile, payload.out_format);
}
const base64Data = await fs.readFile(outputFile, { encoding: 'base64' });
res.file = outputFile;
@@ -46,15 +39,4 @@ export default class GetRecord extends GetFileBase {
}
return res;
}
private async decodeFile (inputFile: string, outputFile: string): Promise<void> {
try {
const inputData = await fs.readFile(inputFile);
const decodedData = await decode(inputData, 24000);
await fs.writeFile(outputFile, Buffer.from(decodedData.data));
} catch (error) {
console.error('Error decoding file:', error);
throw error; // 重新抛出错误以便调用者可以处理
}
}
}

View File

@@ -100,7 +100,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
// 3. 定义协议回退逻辑函数
const protocolFallbackLogic = async (resId: string) => {
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId)))?.arrayMsg;
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId), true))?.arrayMsg;
if (ob) {
return {
messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content,
@@ -122,7 +122,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
if (rootMsg) {
// 5. 获取消息内容
const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
const data = await this.core.apis.MsgApi.getMsgHistory(rootMsg.Peer, rootMsg.MsgId, 1);//getMsgsIncludeSelf
if (data && data.result === 0 && data.msgList.length > 0) {
const singleMsg = data.msgList[0];

View File

@@ -12,6 +12,7 @@ const SchemaData = Type.Object({
name: Type.String(),
folder: Type.Optional(Type.String()),
folder_id: Type.Optional(Type.String()), // 临时扩展
upload_file: Type.Boolean({ default: true }),
});
type Payload = Static<typeof SchemaData>;
@@ -41,7 +42,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, Uploa
peer,
deleteAfterSentFiles: [],
};
const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id, payload.upload_file);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);

View File

@@ -11,6 +11,7 @@ const SchemaData = Type.Object({
user_id: Type.Union([Type.Number(), Type.String()]),
file: Type.String(),
name: Type.String(),
upload_file: Type.Boolean({ default: true }),
});
type Payload = Static<typeof SchemaData>;
@@ -51,7 +52,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, Upl
}, ContextMode.Private),
deleteAfterSentFiles: [],
};
const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, '', payload.upload_file);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);

View File

@@ -7,19 +7,26 @@ interface GroupNotice {
publish_time: number;
notice_id: string;
message: {
text: string
text: string;
// 保持一段时间兼容性 防止以往版本出现问题 后续版本可考虑移除
image: Array<{
height: string
width: string
id: string
height: string;
width: string;
id: string;
}>,
images: Array<{
height: string
width: string
id: string
}>
height: string;
width: string;
id: string;
}>;
};
settings?: {
is_show_edit_card: number,
remind_ts: number,
tip_window_type: number,
confirm_required: number;
};
read_num?: number;
}
const SchemaData = Type.Object({
@@ -59,6 +66,8 @@ export class GetGroupNotice extends OneBotAction<Payload, GroupNotice[]> {
image,
images: image,
},
settings: retApiNotice.settings,
read_num: retApiNotice.read_num
};
retNotices.push(retNotice);
}

View File

@@ -36,6 +36,15 @@ class GetMsg extends OneBotAction<Payload, OB11Message> {
if (!msg) throw Error('消息不存在');
const retMsg = await this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat);
if (!retMsg) throw Error('消息为空');
retMsg.emoji_likes_list = [];
msg.emojiLikesList?.map(emoji => {
retMsg.emoji_likes_list?.push({
emoji_id: emoji.emojiId,
emoji_type: emoji.emojiType,
likes_cnt: emoji.likesCnt,
});
});
// 烘焙emoji_likes_list 仅此处烘焙
try {
retMsg.message_id = MessageUnique.createUniqueMsgId(peer, msg.msgId)!;
retMsg.message_seq = retMsg.message_id;

View File

@@ -317,11 +317,11 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const MixElement = sendElements.filter(
element =>
element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK
element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK && element.elementType !== ElementType.PTT
);
const SingleElement = sendElements.filter(
element =>
element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK
element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK || element.elementType === ElementType.PTT
).map(e => [e]);
const AllElement: SendMessageElement[][] = [MixElement, ...SingleElement].filter(e => e !== undefined && e.length !== 0);

View File

@@ -4,7 +4,6 @@ import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { decode } from 'silk-wasm';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream';
@@ -38,7 +37,6 @@ export class DownloadFileRecordStream extends BaseDownloadStream<Payload, Downlo
throw new Error('转换失败 out_format 字段可能格式不正确');
}
const pcmFile = `${downloadPath}.pcm`;
const outputFile = `${downloadPath}.${payload.out_format}`;
try {
@@ -46,13 +44,8 @@ export class DownloadFileRecordStream extends BaseDownloadStream<Payload, Downlo
await fs.promises.access(outputFile);
streamPath = outputFile;
} catch {
// 尝试解码 silk 到 pcm 再用 ffmpeg 转换
if (FFmpegService.getAdapterName() === 'FFmpegAddon') {
await FFmpegService.convertFile(downloadPath, outputFile, payload.out_format);
} else {
await this.decodeFile(downloadPath, pcmFile);
await FFmpegService.convertFile(pcmFile, outputFile, payload.out_format);
}
// 尝试解码 amr 到 out format直接 ffmpeg 转换
await FFmpegService.convertAudioFmt(downloadPath, outputFile, payload.out_format);
streamPath = outputFile;
}
}
@@ -82,15 +75,4 @@ export class DownloadFileRecordStream extends BaseDownloadStream<Payload, Downlo
throw new Error(`Download failed: ${(error as Error).message}`);
}
}
private async decodeFile (inputFile: string, outputFile: string): Promise<void> {
try {
const inputData = await fs.promises.readFile(inputFile);
const decodedData = await decode(inputData, 24000);
await fs.promises.writeFile(outputFile, Buffer.from(decodedData.data));
} catch (error) {
console.error('Error decoding file:', error);
throw error;
}
}
}

View File

@@ -19,16 +19,18 @@ export class OneBotFileApi {
this.core = core;
}
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = ''): Promise<SendFileElement> {
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = '', uploadGroupFile: boolean = false): Promise<SendFileElement> {
const {
fileName: _fileName,
path,
fileSize,
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE);
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE, 0, uploadGroupFile);
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
context.deleteAfterSentFiles.push(path);
if (uploadGroupFile) {
context.deleteAfterSentFiles.push(path);
}
return {
elementType: ElementType.FILE,
elementId: '',

View File

@@ -19,6 +19,7 @@ import { OB11GroupCardEvent } from '@/napcat-onebot/event/notice/OB11GroupCardEv
import { OB11GroupPokeEvent } from '@/napcat-onebot/event/notice/OB11PokeEvent';
import { OB11GroupEssenceEvent } from '@/napcat-onebot/event/notice/OB11GroupEssenceEvent';
import { OB11GroupTitleEvent } from '@/napcat-onebot/event/notice/OB11GroupTitleEvent';
import { OB11GroupGrayTipEvent } from '@/napcat-onebot/event/notice/OB11GroupGrayTipEvent';
import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent';
import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent';
import { FileNapCatOneBotUUID } from 'napcat-common/src/file-uuid';
@@ -206,15 +207,24 @@ export class OneBotGroupApi {
}
return undefined;
}
async parseOtherJsonEvent (msg: RawMessage, jsonStr: string, context: InstanceContext) {
const json = JSON.parse(jsonStr);
const type = json.items[json.items.length - 1]?.txt;
async parseOtherJsonEvent (msg: RawMessage, jsonGrayTipElement: GrayTipElement['jsonGrayTipElement'], context: InstanceContext) {
let json: { items?: { txt?: string; param?: string[] }[] };
try {
json = JSON.parse(jsonGrayTipElement.jsonStr);
} catch (e) {
context.logger.logWarn('灰条消息JSON解析失败', jsonGrayTipElement.jsonStr, e);
return undefined;
}
const type = json.items?.[json.items.length - 1]?.txt;
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (type === '头衔') {
const memberUin = json.items[1].param[0];
const title = json.items[3].txt;
const memberUin = json.items?.[1]?.param?.[0];
const title = json.items?.[3]?.txt;
context.logger.logDebug('收到群成员新头衔消息', json);
if (memberUin == null || title == null) {
context.logger.logWarn('收到格式异常的群成员新头衔灰条消息', json);
return undefined;
}
return new OB11GroupTitleEvent(
this.core,
+msg.peerUid,
@@ -225,6 +235,27 @@ export class OneBotGroupApi {
context.logger.logDebug('收到机器人被踢消息', json);
} else {
context.logger.logWarn('收到未知的灰条消息', json);
// 如果有真实发送者非0生成事件上报可用于检测和撤回伪造灰条
const senderUin = Number(msg.senderUin) || 0;
if (senderUin !== 0) {
const peer = { chatType: ChatType.KCHATTYPEGROUP, guildId: '', peerUid: msg.peerUid };
const messageId = MessageUnique.createUniqueMsgId(peer, msg.msgId);
return new OB11GroupGrayTipEvent(
this.core,
+msg.peerUin,
senderUin,
messageId,
jsonGrayTipElement.busiId,
jsonGrayTipElement.jsonStr,
{
msgSeq: msg.msgSeq,
msgTime: msg.msgTime,
msgId: msg.msgId,
json,
}
);
}
}
return undefined;
}
@@ -376,7 +407,7 @@ export class OneBotGroupApi {
return await this.parse51TypeEvent(msg, grayTipElement);
} else {
console.log('Unknown JSON event:', grayTipElement.jsonGrayTipElement, JSON.stringify(grayTipElement));
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context);
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement, this.core.context);
}
}
return undefined;

View File

@@ -984,8 +984,20 @@ export class OneBotMsgApi {
disableGetUrl: boolean = false,
quick_reply: boolean = false
) {
if (msg.senderUin === '0' || msg.senderUin === '') return;
if (msg.peerUin === '0' || msg.peerUin === '') return;
if ((msg.senderUin === '0' || msg.senderUin === '')) {
if (msg.senderUid && msg.senderUid !== '' && msg.senderUid !== '0') {
msg.senderUin = await this.core.apis.UserApi.getUinByUidV2(msg.senderUid);
} else {
return undefined;
}
}
if (msg.peerUin === '0' || msg.peerUin === '') {
if (msg.peerUid && msg.peerUid !== '' && msg.peerUid !== '0') {
msg.peerUin = await this.core.apis.UserApi.getUinByUidV2(msg.peerUid);
} else {
return undefined;
}
}
const resMsg = this.initializeMessage(msg);
@@ -1063,7 +1075,8 @@ export class OneBotMsgApi {
resMsg.sub_type = 'group';
const ret = await this.core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, msg.senderUid);
if (ret.result === 0) {
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
// 避免uin:'' uid非空uid一般不空
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, await this.core.apis.UserApi.getUinByUidV2(msg.senderUid));
resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode);
resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话';
resMsg.temp_source = 0;

View File

@@ -6,7 +6,7 @@ const HttpServerConfigSchema = Type.Object({
port: Type.Number({ default: 3000 }),
host: Type.String({ default: '127.0.0.1' }),
enableCors: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: false }),
messagePostFormat: Type.String({ default: 'array' }),
token: Type.String({ default: '' }),
debug: Type.Boolean({ default: false }),
@@ -18,7 +18,7 @@ const HttpSseServerConfigSchema = Type.Object({
port: Type.Number({ default: 3000 }),
host: Type.String({ default: '127.0.0.1' }),
enableCors: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: true }),
enableWebsocket: Type.Boolean({ default: false }),
messagePostFormat: Type.String({ default: 'array' }),
token: Type.String({ default: '' }),
debug: Type.Boolean({ default: false }),

View File

@@ -0,0 +1,35 @@
import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent';
import { NapCatCore } from 'napcat-core';
/**
* 群灰条消息事件
* 用于上报未知类型的灰条消息,便于下游检测和处理伪造灰条攻击
*/
export class OB11GroupGrayTipEvent extends OB11BaseNoticeEvent {
notice_type = 'notify';
sub_type = 'gray_tip';
group_id: number;
user_id: number; // 真实发送者QQ如果是伪造的灰条这就是攻击者
message_id: number; // 消息ID可用于撤回
busi_id: string; // 业务ID
content: string; // 灰条内容JSON字符串
raw_info: unknown; // 原始信息
constructor (
core: NapCatCore,
groupId: number,
userId: number,
messageId: number,
busiId: string,
content: string,
rawInfo: unknown
) {
super(core);
this.group_id = groupId;
this.user_id = userId;
this.message_id = messageId;
this.busi_id = busiId;
this.content = content;
this.raw_info = rawInfo;
}
}

View File

@@ -246,7 +246,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 (
@@ -305,6 +305,9 @@ export class NapCatOneBot11Adapter {
};
msgListener.onRecvMsg = async (msg) => {
if (!this.networkManager.hasActiveAdapters()) {
return;
}
for (const m of msg) {
if (this.bootTime > parseInt(m.msgTime)) {
this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`);
@@ -517,15 +520,14 @@ export class NapCatOneBot11Adapter {
}
private async emitMsg (message: RawMessage) {
const network = await this.networkManager.getAllConfig();
this.context.logger.logDebug('收到新消息 RawMessage', message);
await Promise.allSettled([
this.handleMsg(message, network),
this.handleMsg(message),
message.chatType === ChatType.KCHATTYPEGROUP ? this.handleGroupEvent(message) : this.handlePrivateMsgEvent(message),
]);
}
private async handleMsg (message: RawMessage, network: Array<NetworkAdapterConfig>) {
private async handleMsg (message: RawMessage) {
// 过滤无效消息
if (message.msgType === NTMsgType.KMSGTYPENULL) {
return;
@@ -535,10 +537,36 @@ export class NapCatOneBot11Adapter {
if (ob11Msg) {
const isSelfMsg = this.isSelfMessage(ob11Msg);
this.context.logger.logDebug('转化为 OB11Message', ob11Msg);
const msgMap = this.createMsgMap(network, ob11Msg, isSelfMsg, message);
this.handleDebugNetwork(network, msgMap, message);
this.handleNotReportSelfNetwork(network, msgMap, isSelfMsg);
this.networkManager.emitEventByNames(msgMap);
if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) {
const targetId = parseInt(message.peerUin);
ob11Msg.stringMsg.target_id = targetId;
ob11Msg.arrayMsg.target_id = targetId;
}
const msgMap = new Map<string, OB11Message>();
for (const adapter of this.networkManager.adapters.values()) {
if (!adapter.isActive) continue;
const config = adapter.config;
if (isSelfMsg) {
if (!('reportSelfMessage' in config) || !config.reportSelfMessage) {
continue;
}
}
const msgData = config.messagePostFormat === 'string' ? ob11Msg.stringMsg : ob11Msg.arrayMsg;
if (config.debug) {
const clone = structuredClone(msgData);
clone.raw = message;
msgMap.set(adapter.name, clone);
} else {
msgMap.set(adapter.name, msgData);
}
}
if (msgMap.size > 0) {
this.networkManager.emitEventByNames(msgMap);
} else if (this.networkManager.hasActiveAdapters()) {
this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message);
}
}
} catch (e) {
this.context.logger.logError('constructMessage error: ', e);
@@ -553,48 +581,6 @@ export class NapCatOneBot11Adapter {
ob11Msg.arrayMsg.user_id.toString() === this.core.selfInfo.uin;
}
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 => {
if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) {
ob11Msg.stringMsg.target_id = parseInt(message.peerUin);
ob11Msg.arrayMsg.target_id = parseInt(message.peerUin);
}
if ('messagePostFormat' in e && e.messagePostFormat === 'string') {
msgMap.set(e.name, structuredClone(ob11Msg.stringMsg));
} else {
msgMap.set(e.name, structuredClone(ob11Msg.arrayMsg));
}
});
return msgMap;
}
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 => {
const msg = msgMap.get(adapter.name);
if (msg) {
msg.raw = message;
}
});
} else if (msgMap.size === 0) {
this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message);
}
}
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 => {
msgMap.delete(adapter.name);
});
}
}
private async handleGroupEvent (message: RawMessage) {
try {
// 群名片修改事件解析 任何都该判断

View File

@@ -23,11 +23,15 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
this.logger = core.context.logger;
}
abstract onEvent<T extends OB11EmitEventContent>(event: T): Promise<void>;
abstract onEvent<T extends OB11EmitEventContent> (event: T): Promise<void>;
abstract open (): void | Promise<void>;
abstract close (): void | Promise<void>;
abstract reload (config: unknown): OB11NetworkReloadType | Promise<OB11NetworkReloadType>;
get isActive (): boolean {
return this.isEnable;
}
}

View File

@@ -5,6 +5,10 @@ import { OB11HttpServerAdapter } from './http-server';
export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
private sseClients: Response[] = [];
override get isActive (): boolean {
return this.isEnable && (this.sseClients.length > 0 || super.isActive);
}
override async handleRequest (req: Request, res: Response) {
if (req.path === '/_events') {
this.createSseSupport(req, res);
@@ -25,7 +29,8 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
});
}
override async onEvent<T extends OB11EmitEventContent>(event: T) {
override async onEvent<T extends OB11EmitEventContent> (event: T) {
super.onEvent(event);
const promises: Promise<void>[] = [];
this.sseClients.forEach((res) => {
promises.push(new Promise<void>((resolve, reject) => {

View File

@@ -1,6 +1,6 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import express, { Express, NextFunction, Request, Response } from 'express';
import http from 'http';
import http, { IncomingMessage } from 'http';
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
import cors from 'cors';
import { HttpServerConfig } from '@/napcat-onebot/config/config';
@@ -8,13 +8,41 @@ import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import json5 from 'json5';
import { isFinished } from 'on-finished';
import typeis from 'type-is';
import { WebSocket, WebSocketServer, RawData } from 'ws';
import { URL } from 'url';
import { ActionName } from '@/napcat-onebot/action/router';
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
import { Mutex } from 'async-mutex';
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
private app: Express | undefined;
private server: http.Server | undefined;
private wsServer?: WebSocketServer;
private wsClients: WebSocket[] = [];
private wsClientsMutex = new Mutex();
private heartbeatIntervalId: NodeJS.Timeout | null = null;
private wsClientWithEvent: WebSocket[] = [];
override async onEvent<T extends OB11EmitEventContent> (_event: T) {
override get isActive (): boolean {
return this.isEnable && this.wsClientWithEvent.length > 0;
}
override async onEvent<T extends OB11EmitEventContent> (event: T) {
// http server is passive, no need to emit event
this.wsClientsMutex.runExclusive(async () => {
const promises = this.wsClientWithEvent.map((wsClient) => {
return new Promise<void>((resolve, reject) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(event));
resolve();
} else {
reject(new Error('WebSocket is not open'));
}
});
});
await Promise.allSettled(promises);
});
}
open () {
@@ -36,11 +64,24 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
this.isEnable = false;
this.server?.close();
this.app = undefined;
this.stopHeartbeat();
await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => {
wsClient.close();
});
this.wsClients = [];
this.wsClientWithEvent = [];
});
this.wsServer?.close();
}
private initializeServer () {
this.app = express();
this.server = http.createServer(this.app);
if (this.config.enableWebsocket) {
this.wsServer = new WebSocketServer({ server: this.server });
this.createWSServer(this.wsServer);
}
this.app.use(cors());
this.app.use(express.urlencoded({ extended: true, limit: '5000mb' }));
@@ -93,6 +134,137 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
}
}
createWSServer (newServer: WebSocketServer) {
newServer.on('connection', async (wsClient, wsReq) => {
if (!this.isEnable) {
wsClient.close();
return;
}
if (!this.authorizeWS(this.config.token, wsClient, wsReq)) {
return;
}
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
if (!isApiConnect) {
this.connectEvent(this.core, wsClient);
}
wsClient.on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Client Error:', err.message));
wsClient.on('message', (message) => {
this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e));
});
wsClient.on('ping', () => {
wsClient.pong();
});
wsClient.on('pong', () => {
// this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received');
});
wsClient.once('close', () => {
this.wsClientsMutex.runExclusive(async () => {
const NormolIndex = this.wsClients.indexOf(wsClient);
if (NormolIndex !== -1) {
this.wsClients.splice(NormolIndex, 1);
}
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
});
});
await this.wsClientsMutex.runExclusive(async () => {
if (!isApiConnect) {
this.wsClientWithEvent.push(wsClient);
}
this.wsClients.push(wsClient);
if (this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
}
});
}).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message));
}
connectEvent (core: any, wsClient: WebSocket) {
try {
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e));
} catch (e) {
this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e);
}
}
private startHeartbeat () {
if (this.heartbeatIntervalId) return;
this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
}
});
});
}, 30000);
}
private stopHeartbeat () {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
}
private authorizeWS (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
if (!token || token.length === 0) return true;
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
const QueryClientToken = url.searchParams.get('access_token');
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
if (ClientToken === token) {
return true;
}
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
wsClient.close();
return false;
}
private async checkStateAndReply<T> (data: T, wsClient: WebSocket) {
return await new Promise<void>((resolve, reject) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(data));
resolve();
} else {
reject(new Error('WebSocket is not open'));
}
});
}
private async handleWSMessage (wsClient: WebSocket, message: RawData) {
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
let echo;
try {
receiveData = json5.parse(message.toString());
echo = receiveData.echo;
} catch {
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
return;
}
receiveData.params = (receiveData?.params) ? receiveData.params : {};
const action = this.actions.get(receiveData.action as any);
if (!action) {
this.logger.logError('[OneBot] [HTTP WebSocket] 发生错误', '不支持的API ' + receiveData.action);
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
return;
}
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
send: async (data: object) => {
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
},
});
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
}
async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) {
let payload = req.body;
if (req.method === 'get') {
@@ -152,6 +324,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
async reload (newConfig: HttpServerConfig) {
const wasEnabled = this.isEnable;
const oldPort = this.config.port;
const oldEnableWebsocket = this.config.enableWebsocket;
this.config = newConfig;
if (newConfig.enable && !wasEnabled) {
@@ -162,7 +335,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
return OB11NetworkReloadType.NetWorkClose;
}
if (oldPort !== newConfig.port) {
if (oldPort !== newConfig.port || oldEnableWebsocket !== newConfig.enableWebsocket) {
this.close();
if (newConfig.enable) {
this.open();

View File

@@ -21,7 +21,7 @@ export class OB11NetworkManager {
async emitEvent (event: OB11EmitEventContent) {
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
if (adapter.isEnable) {
if (adapter.isActive) {
return await adapter.onEvent(event);
}
}));
@@ -34,7 +34,7 @@ export class OB11NetworkManager {
async emitEventByName (names: string[], event: OB11EmitEventContent) {
return Promise.all(names.map(async name => {
const adapter = this.adapters.get(name);
if (adapter && adapter.isEnable) {
if (adapter && adapter.isActive) {
return await adapter.onEvent(event);
}
}));
@@ -43,29 +43,29 @@ export class OB11NetworkManager {
async emitEventByNames (map: Map<string, OB11EmitEventContent>) {
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
const adapter = this.adapters.get(name);
if (adapter && adapter.isEnable) {
if (adapter && adapter.isActive) {
return await adapter.onEvent(event);
}
}));
}
registerAdapter<CT extends NetworkAdapterConfig>(adapter: IOB11NetworkAdapter<CT>) {
registerAdapter<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
this.adapters.set(adapter.name, adapter);
}
async registerAdapterAndOpen<CT extends NetworkAdapterConfig>(adapter: IOB11NetworkAdapter<CT>) {
async registerAdapterAndOpen<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
this.registerAdapter(adapter);
await adapter.open();
}
async closeSomeAdapters<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) {
async closeSomeAdapters<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
for (const adapter of adaptersToClose) {
this.adapters.delete(adapter.name);
await adapter.close();
}
}
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) {
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
for (const adapter of adaptersToClose) {
this.adapters.delete(adapter.name);
if (adapter.isEnable) {
@@ -88,17 +88,21 @@ export class OB11NetworkManager {
this.adapters.clear();
}
async readloadAdapter<T>(name: string, config: T) {
async readloadAdapter<T> (name: string, config: T) {
const adapter = this.adapters.get(name);
if (adapter) {
await adapter.reload(config);
}
}
async readloadSomeAdapters<T>(configMap: Map<string, T>) {
async readloadSomeAdapters<T> (configMap: Map<string, T>) {
await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.readloadAdapter(name, config)));
}
hasActiveAdapters (): boolean {
return Array.from(this.adapters.values()).some(adapter => adapter.isActive);
}
async getAllConfig () {
return Array.from(this.adapters.values()).map(adapter => adapter.config);
}

View File

@@ -33,6 +33,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig;
override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0;
}
constructor (
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) {
@@ -251,7 +255,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
async onEvent<T extends OB11EmitEventContent>(event: T) {
async onEvent<T extends OB11EmitEventContent> (event: T) {
if (!this.isEnable) {
return;
}
@@ -359,7 +363,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath;
plugin.pluginPath !== this.pluginPath;
if (isDirectory) {
const dirname = path.basename(plugin.pluginPath);

View File

@@ -33,6 +33,10 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig;
override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0;
}
constructor (
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) {

View File

@@ -13,6 +13,10 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
private connection: WebSocket | null = null;
private heartbeatRef: NodeJS.Timeout | null = null;
override get isActive (): boolean {
return this.isEnable && !!this.connection && this.connection.readyState === WebSocket.OPEN;
}
async onEvent<T extends OB11EmitEventContent> (event: T) {
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
this.connection.send(JSON.stringify(event));

View File

@@ -21,6 +21,10 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
private heartbeatIntervalId: NodeJS.Timeout | null = null;
wsClientWithEvent: WebSocket[] = [];
override get isActive (): boolean {
return this.isEnable && this.wsClientWithEvent.length > 0;
}
constructor (
name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) {
@@ -70,6 +74,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
});
});
await this.wsClientsMutex.runExclusive(async () => {
@@ -77,6 +84,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.wsClientWithEvent.push(wsClient);
}
this.wsClients.push(wsClient);
if (this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
}
});
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
}
@@ -114,9 +124,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
this.isEnable = true;
if (this.config.heartInterval > 0) {
this.registerHeartBeat();
}
}
async close () {
@@ -128,10 +135,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.logger.log('[OneBot] [WebSocket Server] Server Closed');
}
});
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
this.stopHeartbeat();
await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => {
wsClient.close();
@@ -141,7 +145,8 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
});
}
private registerHeartBeat () {
private startHeartbeat () {
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
@@ -153,6 +158,13 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}, this.config.heartInterval);
}
private stopHeartbeat () {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
}
private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
if (!token || token.length === 0) return true;// 客户端未设置密钥
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
@@ -235,12 +247,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}
if (oldHeartbeatInterval !== newConfig.heartInterval) {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
if (newConfig.heartInterval > 0 && this.isEnable) {
this.registerHeartBeat();
this.stopHeartbeat();
if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
}
return OB11NetworkReloadType.NetWorkReload;
}

View File

@@ -31,6 +31,7 @@ export interface OB11Message {
font: number;
post_type?: EventType;
raw?: RawMessage;
emoji_likes_list?: Array<{ emoji_id: string; emoji_type: string; likes_cnt: string; }>;// 仅get_msg生效
}
// 合并转发消息接口定义
@@ -46,7 +47,7 @@ export interface OB11Return<DataType> {
message: string;
echo?: unknown; // ws调用api才有此字段
wording?: string; // go-cqhttp字段错误信息
stream?: 'stream-action' | 'normal-action' ; // 流式返回标记
stream?: 'stream-action' | 'normal-action'; // 流式返回标记
}
// 消息数据类型枚举
@@ -186,7 +187,7 @@ export interface OB11MessageNode {
name?: string; // compatible with go-cqhttp
content: OB11MessageMixType;
source?: string;
news?: { text: string }[];
news?: { text: string; }[];
summary?: string;
prompt?: string;
time?: string;
@@ -210,13 +211,13 @@ export interface OB11MessageIdMusic {
// 自定义音乐消息接口定义
export interface OB11MessageCustomMusic {
type: OB11MessageDataType.music;
data: Omit<CustomMusicSignPostData, 'singer'> & { content?: string };
data: Omit<CustomMusicSignPostData, 'singer'> & { content?: string; };
}
// JSON消息接口定义
export interface OB11MessageJson {
type: OB11MessageDataType.json;
data: { config?: { token: string }, data: string | object };
data: { config?: { token: string; }, data: string | object; };
}
// 骰子消息接口定义
@@ -254,12 +255,12 @@ export interface OB11MessageForward {
// 消息数据类型定义
export type OB11MessageData =
OB11MessageText |
OB11MessageFace | OB11MessageMFace |
OB11MessageAt | OB11MessageReply |
OB11MessageImage | OB11MessageRecord | OB11MessageFile | OB11MessageVideo |
OB11MessageNode | OB11MessageIdMusic | OB11MessageCustomMusic | OB11MessageJson |
OB11MessageDice | OB11MessageRPS | OB11MessageMarkdown | OB11MessageForward | OB11MessageContext | OB11MessagePoke;
OB11MessageText |
OB11MessageFace | OB11MessageMFace |
OB11MessageAt | OB11MessageReply |
OB11MessageImage | OB11MessageRecord | OB11MessageFile | OB11MessageVideo |
OB11MessageNode | OB11MessageIdMusic | OB11MessageCustomMusic | OB11MessageJson |
OB11MessageDice | OB11MessageRPS | OB11MessageMarkdown | OB11MessageForward | OB11MessageContext | OB11MessagePoke;
// 发送消息接口定义
export interface OB11PostSendMsg {
@@ -270,7 +271,7 @@ export interface OB11PostSendMsg {
messages?: OB11MessageMixType;
auto_escape?: boolean | string;
source?: string;
news?: { text: string }[];
news?: { text: string; }[];
summary?: string;
prompt?: string;
time?: string;

View File

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

View File

@@ -9,7 +9,7 @@ export default defineConfig({
resolve: {
conditions: ['node', 'default'],
alias: {
'@/napcat-core': resolve(__dirname, '../core'),
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@': resolve(__dirname, '../'),
},
},

View File

@@ -0,0 +1,251 @@
# NapCat Protocol Manager
统一管理 NapCat 的多协议适配器OneBot 和 Satori
## 特性
- 🔌 **统一接口**: 提供统一的协议管理接口
- 🎯 **插件化设计**: 支持动态注册和管理协议适配器
- 🔄 **热重载**: 支持协议配置的热重载
- 📦 **开箱即用**: 内置 OneBot11 和 Satori 协议支持
## 架构
```
napcat-protocol
├── types.ts # 协议接口定义
├── manager.ts # 协议管理器
├── adapters/
│ ├── onebot.ts # OneBot11 协议适配器包装
│ └── satori.ts # Satori 协议适配器包装
└── index.ts # 导出入口
```
## 使用方法
### 基础使用
```typescript
import { ProtocolManager } from 'napcat-protocol';
// 创建协议管理器
const protocolManager = new ProtocolManager(core, context, pathWrapper);
// 初始化所有协议
await protocolManager.initAllProtocols();
// 获取协议适配器
const onebotAdapter = protocolManager.getOneBotAdapter();
const satoriAdapter = protocolManager.getSatoriAdapter();
```
### 单独初始化协议
```typescript
// 只初始化 OneBot11
await protocolManager.initProtocol('onebot11');
// 只初始化 Satori
await protocolManager.initProtocol('satori');
```
### 获取原始适配器
```typescript
// 获取 OneBot 原始适配器
const onebotAdapter = protocolManager.getOneBotAdapter();
if (onebotAdapter) {
const rawOneBot = onebotAdapter.getRawAdapter();
// 使用 NapCatOneBot11Adapter 的所有功能
}
// 获取 Satori 原始适配器
const satoriAdapter = protocolManager.getSatoriAdapter();
if (satoriAdapter) {
const rawSatori = satoriAdapter.getRawAdapter();
// 使用 NapCatSatoriAdapter 的所有功能
}
```
### 配置重载
```typescript
// 重载 OneBot 配置
await protocolManager.reloadProtocolConfig('onebot11', prevConfig, newConfig);
// 重载 Satori 配置
await protocolManager.reloadProtocolConfig('satori', prevConfig, newConfig);
```
### 查询协议状态
```typescript
// 获取所有已注册的协议信息
const protocols = protocolManager.getRegisteredProtocols();
// 检查协议是否已初始化
const isInitialized = protocolManager.isProtocolInitialized('onebot11');
// 获取所有已初始化的协议ID
const initializedIds = protocolManager.getInitializedProtocolIds();
```
### 销毁协议
```typescript
// 销毁指定协议
await protocolManager.destroyProtocol('onebot11');
// 销毁所有协议
await protocolManager.destroyAllProtocols();
```
## 在 Framework 中使用
```typescript
// packages/napcat-framework/napcat.ts
import { ProtocolManager } from 'napcat-protocol';
const protocolManager = new ProtocolManager(core, context, pathWrapper);
await protocolManager.initAllProtocols();
// 注册到 WebUI
const onebotAdapter = protocolManager.getOneBotAdapter();
if (onebotAdapter) {
WebUiDataRuntime.setOneBotContext(onebotAdapter.getRawAdapter());
}
const satoriAdapter = protocolManager.getSatoriAdapter();
if (satoriAdapter) {
WebUiDataRuntime.setSatoriContext(satoriAdapter.getRawAdapter());
}
```
## 在 Shell 中使用
```typescript
// packages/napcat-shell/base.ts
import { ProtocolManager } from 'napcat-protocol';
export class NapCatShell {
public protocolManager?: ProtocolManager;
async InitNapCat() {
await this.core.initCore();
this.protocolManager = new ProtocolManager(
this.core,
this.context,
this.context.pathWrapper
);
await this.protocolManager.initAllProtocols();
}
}
```
## 扩展自定义协议
如果需要添加新的协议支持,可以实现 `IProtocolAdapter``IProtocolAdapterFactory` 接口:
```typescript
import { IProtocolAdapter, IProtocolAdapterFactory } from 'napcat-protocol';
// 实现协议适配器
class MyProtocolAdapter implements IProtocolAdapter {
readonly name = 'MyProtocol';
readonly id = 'myprotocol';
readonly version = '1.0.0';
readonly description = '我的自定义协议';
async init(): Promise<void> {
// 初始化逻辑
}
async destroy(): Promise<void> {
// 清理逻辑
}
async reloadConfig(prevConfig: unknown, newConfig: unknown): Promise<void> {
// 配置重载逻辑
}
}
// 实现工厂
class MyProtocolAdapterFactory implements IProtocolAdapterFactory {
readonly protocolId = 'myprotocol';
readonly protocolName = 'MyProtocol';
readonly protocolVersion = '1.0.0';
readonly protocolDescription = '我的自定义协议';
create(core, context, pathWrapper) {
return new MyProtocolAdapter(core, context, pathWrapper);
}
}
// 注册到管理器
protocolManager.registerFactory(new MyProtocolAdapterFactory());
await protocolManager.initProtocol('myprotocol');
```
## API 文档
### ProtocolManager
#### 方法
- `registerFactory(factory: IProtocolAdapterFactory)`: 注册协议工厂
- `getRegisteredProtocols()`: 获取所有已注册的协议信息
- `initProtocol(protocolId: string)`: 初始化指定协议
- `initAllProtocols()`: 初始化所有协议
- `destroyProtocol(protocolId: string)`: 销毁指定协议
- `destroyAllProtocols()`: 销毁所有协议
- `getAdapter<T>(protocolId: string)`: 获取协议适配器
- `getOneBotAdapter()`: 获取 OneBot 协议适配器
- `getSatoriAdapter()`: 获取 Satori 协议适配器
- `reloadProtocolConfig(protocolId, prevConfig, newConfig)`: 重载协议配置
- `isProtocolInitialized(protocolId: string)`: 检查协议是否已初始化
- `getInitializedProtocolIds()`: 获取所有已初始化的协议ID
### IProtocolAdapter
协议适配器接口,所有协议适配器都需要实现此接口。
#### 属性
- `name: string`: 协议名称
- `id: string`: 协议ID
- `version: string`: 协议版本
- `description: string`: 协议描述
#### 方法
- `init()`: 初始化协议适配器
- `destroy()`: 销毁协议适配器
- `reloadConfig(prevConfig, newConfig)`: 重载配置
### IProtocolAdapterFactory
协议适配器工厂接口,用于创建协议适配器实例。
#### 属性
- `protocolId: string`: 协议ID
- `protocolName: string`: 协议名称
- `protocolVersion: string`: 协议版本
- `protocolDescription: string`: 协议描述
#### 方法
- `create(core, context, pathWrapper)`: 创建协议适配器实例
## 依赖
- `napcat-core`: NapCat 核心
- `napcat-common`: NapCat 通用工具
- `napcat-onebot`: OneBot11 协议实现
- `napcat-satori`: Satori 协议实现
## 许可证
与 NapCat 主项目保持一致

View File

@@ -0,0 +1,2 @@
export * from './onebot';
export * from './satori';

View File

@@ -0,0 +1,67 @@
import { InstanceContext, NapCatCore } from 'napcat-core';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
import { OB11ConfigLoader } from 'napcat-onebot/config';
import { IProtocolAdapter, IProtocolAdapterFactory } from '../types';
/**
* OneBot11 协议适配器包装器
*/
export class OneBotProtocolAdapter implements IProtocolAdapter {
readonly name = 'OneBot11';
readonly id = 'onebot11';
readonly version = '11';
readonly description = 'OneBot v11 协议适配器';
private adapter: NapCatOneBot11Adapter;
constructor (
_core: NapCatCore,
_context: InstanceContext,
_pathWrapper: NapCatPathWrapper
) {
this.adapter = new NapCatOneBot11Adapter(_core, _context, _pathWrapper);
}
async init (): Promise<void> {
await this.adapter.InitOneBot();
}
async destroy (): Promise<void> {
await this.adapter.networkManager.closeAllAdapters();
}
async reloadConfig (_prevConfig: unknown, newConfig: unknown): Promise<void> {
const now = newConfig as Parameters<typeof this.adapter.configLoader.save>[0];
this.adapter.configLoader.save(now);
// 内部会处理网络重载
}
/** 获取原始适配器实例 */
getRawAdapter (): NapCatOneBot11Adapter {
return this.adapter;
}
/** 获取配置加载器 */
getConfigLoader (): OB11ConfigLoader {
return this.adapter.configLoader;
}
}
/**
* OneBot11 协议适配器工厂
*/
export class OneBotProtocolAdapterFactory implements IProtocolAdapterFactory<OneBotProtocolAdapter> {
readonly protocolId = 'onebot11';
readonly protocolName = 'OneBot11';
readonly protocolVersion = '11';
readonly protocolDescription = 'OneBot v11 协议适配器,支持 HTTP、WebSocket 等多种网络方式';
create (
core: NapCatCore,
context: InstanceContext,
pathWrapper: NapCatPathWrapper
): OneBotProtocolAdapter {
return new OneBotProtocolAdapter(core, context, pathWrapper);
}
}

View File

@@ -0,0 +1,68 @@
import { InstanceContext, NapCatCore } from 'napcat-core';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import { NapCatSatoriAdapter } from 'napcat-satori/index';
import { SatoriConfig, SatoriConfigLoader } from 'napcat-satori/config';
import { IProtocolAdapter, IProtocolAdapterFactory } from '../types';
/**
* Satori 协议适配器包装器
*/
export class SatoriProtocolAdapter implements IProtocolAdapter {
readonly name = 'Satori';
readonly id = 'satori';
readonly version = '1';
readonly description = 'Satori 协议适配器';
private adapter: NapCatSatoriAdapter;
constructor (
_core: NapCatCore,
_context: InstanceContext,
_pathWrapper: NapCatPathWrapper
) {
this.adapter = new NapCatSatoriAdapter(_core, _context, _pathWrapper);
}
async init (): Promise<void> {
await this.adapter.InitSatori();
}
async destroy (): Promise<void> {
await this.adapter.networkManager.closeAllAdapters();
}
async reloadConfig (prevConfig: unknown, newConfig: unknown): Promise<void> {
const prev = prevConfig as SatoriConfig;
const now = newConfig as SatoriConfig;
this.adapter.configLoader.save(now);
await this.adapter.reloadNetwork(prev, now);
}
/** 获取原始适配器实例 */
getRawAdapter (): NapCatSatoriAdapter {
return this.adapter;
}
/** 获取配置加载器 */
getConfigLoader (): SatoriConfigLoader {
return this.adapter.configLoader;
}
}
/**
* Satori 协议适配器工厂
*/
export class SatoriProtocolAdapterFactory implements IProtocolAdapterFactory<SatoriProtocolAdapter> {
readonly protocolId = 'satori';
readonly protocolName = 'Satori';
readonly protocolVersion = '1';
readonly protocolDescription = 'Satori 协议适配器,支持 WebSocket、HTTP、WebHook 等多种网络方式';
create (
core: NapCatCore,
context: InstanceContext,
pathWrapper: NapCatPathWrapper
): SatoriProtocolAdapter {
return new SatoriProtocolAdapter(core, context, pathWrapper);
}
}

View File

@@ -0,0 +1,35 @@
/**
* NapCat Protocol Manager
*
* 统一管理 OneBot 和 Satori 协议适配器
*
* @example
* ```typescript
* import { ProtocolManager } from 'napcat-protocol';
*
* const protocolManager = new ProtocolManager(core, context, pathWrapper);
*
* // 初始化所有协议
* await protocolManager.initAllProtocols();
*
* // 或者只初始化特定协议
* await protocolManager.initProtocol('onebot11');
* await protocolManager.initProtocol('satori');
*
* // 获取协议适配器
* const onebotAdapter = protocolManager.getOneBotAdapter();
* const satoriAdapter = protocolManager.getSatoriAdapter();
*
* // 获取原始适配器实例
* const rawOneBot = onebotAdapter?.getRawAdapter();
* const rawSatori = satoriAdapter?.getRawAdapter();
* ```
*/
export * from './types';
export * from './manager';
export * from './adapters';
// 重新导出原始适配器类型,方便使用
export { NapCatOneBot11Adapter } from 'napcat-onebot/index';
export { NapCatSatoriAdapter } from 'napcat-satori/index';

View File

@@ -0,0 +1,260 @@
import { InstanceContext, NapCatCore } from 'napcat-core';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import json5 from 'json5';
import { IProtocolAdapter, IProtocolAdapterFactory, ProtocolInfo } from './types';
import { OneBotProtocolAdapterFactory, OneBotProtocolAdapter } from './adapters/onebot';
import { SatoriProtocolAdapterFactory, SatoriProtocolAdapter } from './adapters/satori';
/**
* 协议管理器 - 统一管理所有协议适配器
*/
export class ProtocolManager {
private factories: Map<string, IProtocolAdapterFactory> = new Map();
private adapters: Map<string, IProtocolAdapter> = new Map();
private initialized: boolean = false;
constructor (
private core: NapCatCore,
private context: InstanceContext,
private pathWrapper: NapCatPathWrapper
) {
// 注册内置协议工厂
this.registerFactory(new OneBotProtocolAdapterFactory());
this.registerFactory(new SatoriProtocolAdapterFactory());
}
/**
* 注册协议适配器工厂
*/
registerFactory (factory: IProtocolAdapterFactory): void {
if (this.factories.has(factory.protocolId)) {
this.context.logger.logWarn(`[Protocol] 协议工厂 ${factory.protocolId} 已存在,将被覆盖`);
}
this.factories.set(factory.protocolId, factory);
this.context.logger.log(`[Protocol] 注册协议工厂: ${factory.protocolName} (${factory.protocolId})`);
}
/**
* 加载协议状态
*/
private loadProtocolStatus (): Record<string, boolean> {
return (this.core.configLoader.configData.protocols || { onebot11: true }) as Record<string, boolean>;
}
/**
* 保存协议状态
*/
private saveProtocolStatus (status: Record<string, boolean>): void {
const config = this.core.configLoader.configData;
config.protocols = status;
this.core.configLoader.save(config);
}
/**
* 设置协议启用状态
*/
async setProtocolEnabled (protocolId: string, enabled: boolean): Promise<void> {
const status = this.loadProtocolStatus();
status[protocolId] = enabled;
this.saveProtocolStatus(status);
if (enabled) {
await this.initProtocol(protocolId);
} else {
await this.destroyProtocol(protocolId);
}
}
/**
* 获取所有已注册的协议信息
*/
getRegisteredProtocols (): ProtocolInfo[] {
const status = this.loadProtocolStatus();
const protocols: ProtocolInfo[] = [];
for (const [id, factory] of this.factories) {
protocols.push({
id,
name: factory.protocolName,
version: factory.protocolVersion,
description: factory.protocolDescription,
enabled: status[id] ?? false, // 使用持久化的状态
});
}
return protocols;
}
/**
* 初始化所有协议
*/
async initAllProtocols (): Promise<void> {
if (this.initialized) {
this.context.logger.logWarn('[Protocol] 协议管理器已初始化');
return;
}
this.context.logger.log('[Protocol] 开始初始化所有协议...');
const status = this.loadProtocolStatus();
for (const [protocolId] of this.factories) {
if (status[protocolId]) {
await this.initProtocol(protocolId);
}
}
this.initialized = true;
this.context.logger.log('[Protocol] 所有协议初始化完成');
}
/**
* 初始化指定协议
*/
async initProtocol (protocolId: string): Promise<IProtocolAdapter | null> {
const factory = this.factories.get(protocolId);
if (!factory) {
this.context.logger.logError(`[Protocol] 未找到协议工厂: ${protocolId}`);
return null;
}
if (this.adapters.has(protocolId)) {
// Already initialized
return this.adapters.get(protocolId)!;
}
try {
const adapter = factory.create(this.core, this.context, this.pathWrapper);
await adapter.init();
this.adapters.set(protocolId, adapter);
this.context.logger.log(`[Protocol] 协议 ${adapter.name} 初始化成功`);
return adapter;
} catch (error) {
this.context.logger.logError(`[Protocol] 协议 ${protocolId} 初始化失败:`, error);
return null;
}
}
/**
* 销毁指定协议
*/
async destroyProtocol (protocolId: string): Promise<void> {
const adapter = this.adapters.get(protocolId);
if (!adapter) {
this.context.logger.logWarn(`[Protocol] 协议 ${protocolId} 未初始化`);
return;
}
try {
await adapter.destroy();
this.adapters.delete(protocolId);
this.context.logger.log(`[Protocol] 协议 ${adapter.name} 已销毁`);
} catch (error) {
this.context.logger.logError(`[Protocol] 协议 ${protocolId} 销毁失败:`, error);
}
}
/**
* 销毁所有协议
*/
async destroyAllProtocols (): Promise<void> {
this.context.logger.log('[Protocol] 开始销毁所有协议...');
for (const [protocolId] of this.adapters) {
await this.destroyProtocol(protocolId);
}
this.initialized = false;
this.context.logger.log('[Protocol] 所有协议已销毁');
}
/**
* 获取协议适配器
*/
getAdapter<T extends IProtocolAdapter = IProtocolAdapter> (protocolId: string): T | null {
return (this.adapters.get(protocolId) as T) ?? null;
}
/**
* 获取 OneBot 协议适配器
*/
getOneBotAdapter (): OneBotProtocolAdapter | null {
return this.getAdapter<OneBotProtocolAdapter>('onebot11');
}
/**
* 获取 Satori 协议适配器
*/
getSatoriAdapter (): SatoriProtocolAdapter | null {
return this.getAdapter<SatoriProtocolAdapter>('satori');
}
/**
* 获取协议配置
*/
async getProtocolConfig (protocolId: string, uin: string): Promise<any> {
const configPath = resolve(this.pathWrapper.configPath, `./${protocolId}_${uin}.json`);
if (!existsSync(configPath)) {
return {};
}
try {
const content = readFileSync(configPath, 'utf-8');
return json5.parse(content);
} catch (error) {
this.context.logger.logError(`[Protocol] 读取协议 ${protocolId} 配置失败:`, error);
return {};
}
}
/**
* 设置协议配置
*/
async setProtocolConfig (protocolId: string, uin: string, config: any): Promise<void> {
const configPath = resolve(this.pathWrapper.configPath, `./${protocolId}_${uin}.json`);
const prevConfig = await this.getProtocolConfig(protocolId, uin);
try {
writeFileSync(configPath, json5.stringify(config, null, 2), 'utf-8');
// 热重载配置
if (this.adapters.has(protocolId)) {
await this.reloadProtocolConfig(protocolId, prevConfig, config);
}
} catch (error) {
this.context.logger.logError(`[Protocol] 保存协议 ${protocolId} 配置失败:`, error);
throw error;
}
}
/**
* 重载协议配置
*/
async reloadProtocolConfig (protocolId: string, prevConfig: unknown, newConfig: unknown): Promise<void> {
const adapter = this.adapters.get(protocolId);
if (!adapter) {
this.context.logger.logWarn(`[Protocol] 协议 ${protocolId} 未初始化,无法重载配置`);
return;
}
try {
await adapter.reloadConfig(prevConfig, newConfig);
this.context.logger.log(`[Protocol] 协议 ${adapter.name} 配置已重载`);
} catch (error) {
this.context.logger.logError(`[Protocol] 协议 ${protocolId} 配置重载失败:`, error);
}
}
/**
* 检查协议是否已初始化
*/
isProtocolInitialized (protocolId: string): boolean {
return this.adapters.has(protocolId);
}
/**
* 获取所有已初始化的协议ID
*/
getInitializedProtocolIds (): string[] {
return Array.from(this.adapters.keys());
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "napcat-protocol",
"version": "1.0.0",
"description": "NapCat Protocol Manager - Unified protocol adapter management for OneBot and Satori",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"build": "tsc"
},
"dependencies": {
"napcat-core": "workspace:*",
"napcat-common": "workspace:*",
"napcat-onebot": "workspace:*",
"napcat-satori": "workspace:*",
"json5": "^2.2.3"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"composite": true,
"declaration": true,
"declarationMap": true
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -0,0 +1,64 @@
import { InstanceContext, NapCatCore } from 'napcat-core';
import { NapCatPathWrapper } from 'napcat-common/src/path';
/**
* 协议适配器基础接口
*/
export interface IProtocolAdapter {
/** 协议名称 */
readonly name: string;
/** 协议ID */
readonly id: string;
/** 协议版本 */
readonly version: string;
/** 协议描述 */
readonly description: string;
/** 初始化协议适配器 */
init (): Promise<void>;
/** 销毁协议适配器 */
destroy (): Promise<void>;
/** 重载配置 */
reloadConfig (prevConfig: unknown, newConfig: unknown): Promise<void>;
}
/**
* 协议适配器工厂接口
*/
export interface IProtocolAdapterFactory<T extends IProtocolAdapter = IProtocolAdapter> {
/** 协议ID */
readonly protocolId: string;
/** 协议名称 */
readonly protocolName: string;
/** 协议版本 */
readonly protocolVersion: string;
/** 协议描述 */
readonly protocolDescription: string;
/** 创建协议适配器实例 */
create (
core: NapCatCore,
context: InstanceContext,
pathWrapper: NapCatPathWrapper
): T;
}
/**
* 协议信息
*/
export interface ProtocolInfo {
id: string;
name: string;
version: string;
description: string;
enabled: boolean;
}
/**
* 协议管理器配置变更回调
*/
export type ProtocolConfigChangeCallback = (
protocolId: string,
prevConfig: unknown,
newConfig: unknown
) => Promise<void>;

View File

@@ -0,0 +1,94 @@
import { NapCatCore } from 'napcat-core';
import { NapCatSatoriAdapter } from '../index';
import Ajv, { ErrorObject, ValidateFunction } from 'ajv';
import { TSchema } from '@sinclair/typebox';
export interface SatoriCheckResult {
valid: boolean;
message?: string;
}
export interface SatoriResponse<T = unknown> {
data?: T;
error?: {
code: number;
message: string;
};
}
export class SatoriResponseHelper {
static success<T> (data: T): SatoriResponse<T> {
return { data };
}
static error (code: number, message: string): SatoriResponse<null> {
return { error: { code, message } };
}
}
export abstract class SatoriAction<PayloadType, ReturnType> {
abstract actionName: string;
protected satoriAdapter: NapCatSatoriAdapter;
protected core: NapCatCore;
payloadSchema?: TSchema = undefined;
private validate?: ValidateFunction<unknown> = undefined;
constructor (satoriAdapter: NapCatSatoriAdapter, core: NapCatCore) {
this.satoriAdapter = satoriAdapter;
this.core = core;
}
/**
* 验证请求参数
*/
protected async check (payload: PayloadType): Promise<SatoriCheckResult> {
if (this.payloadSchema) {
this.validate = new Ajv({
allowUnionTypes: true,
useDefaults: true,
coerceTypes: true,
}).compile(this.payloadSchema);
}
if (this.validate && !this.validate(payload)) {
const errors = this.validate.errors as ErrorObject[];
const errorMessages = errors.map(
(e) => `Key: ${e.instancePath.split('/').slice(1).join('.')}, Message: ${e.message}`
);
return {
valid: false,
message: errorMessages.join('\n') ?? '未知错误',
};
}
return { valid: true };
}
/**
* 处理请求入口(带验证)
*/
async handle (payload: PayloadType): Promise<ReturnType> {
const checkResult = await this.check(payload);
if (!checkResult.valid) {
throw new Error(checkResult.message || '参数验证失败');
}
return this._handle(payload);
}
/**
* 实际处理逻辑(子类实现)
*/
protected abstract _handle (payload: PayloadType): Promise<ReturnType>;
protected get logger () {
return this.core.context.logger;
}
protected get selfInfo () {
return this.core.selfInfo;
}
protected get platform () {
return this.satoriAdapter.configLoader.configData.platform;
}
}

View File

@@ -0,0 +1,60 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriChannel, SatoriChannelType } from '../../types';
const SchemaData = Type.Object({
channel_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
export class ChannelGetAction extends SatoriAction<Payload, SatoriChannel> {
actionName = SatoriActionName.ChannelGet;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<SatoriChannel> {
const { channel_id } = payload;
const parts = channel_id.split(':');
const type = parts[0];
const id = parts[1];
if (!type || !id) {
throw new Error(`无效的频道ID格式: ${channel_id}`);
}
if (type === 'private') {
const uid = await this.core.apis.UserApi.getUidByUinV2(id);
const userInfo = await this.core.apis.UserApi.getUserDetailInfo(uid, false);
return {
id: channel_id,
type: SatoriChannelType.DIRECT,
name: userInfo.nick || id,
};
} else if (type === 'group') {
// 先从群列表缓存中查找
const groups = await this.core.apis.GroupApi.getGroups();
const group = groups.find((e) => e.groupCode === id);
if (!group) {
// 如果缓存中没有,尝试获取详细信息
const data = await this.core.apis.GroupApi.fetchGroupDetail(id);
return {
id: channel_id,
type: SatoriChannelType.TEXT,
name: data.groupName,
};
}
return {
id: channel_id,
type: SatoriChannelType.TEXT,
name: group.groupName,
};
}
throw new Error(`不支持的频道类型: ${type}`);
}
}

View File

@@ -0,0 +1,44 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriChannel, SatoriChannelType, SatoriPageResult } from '../../types';
const SchemaData = Type.Object({
guild_id: Type.String(),
next: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export class ChannelListAction extends SatoriAction<Payload, SatoriPageResult<SatoriChannel>> {
actionName = SatoriActionName.ChannelList;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<SatoriPageResult<SatoriChannel>> {
const { guild_id } = payload;
// 在 QQ 中,群组只有一个文本频道
// 先从群列表缓存中查找
const groups = await this.core.apis.GroupApi.getGroups();
const group = groups.find((e) => e.groupCode === guild_id);
let groupName: string;
if (!group) {
// 如果缓存中没有,尝试获取详细信息
const data = await this.core.apis.GroupApi.fetchGroupDetail(guild_id);
groupName = data.groupName;
} else {
groupName = group.groupName;
}
const channel: SatoriChannel = {
id: `group:${guild_id}`,
type: SatoriChannelType.TEXT,
name: groupName,
};
return {
data: [channel],
};
}
}

View File

@@ -0,0 +1,39 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { GroupNotifyMsgType, NTGroupRequestOperateTypes } from 'napcat-core';
const SchemaData = Type.Object({
message_id: Type.String(), // 邀请请求的 seq
approve: Type.Boolean(),
comment: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export class GuildApproveAction extends SatoriAction<Payload, void> {
actionName = SatoriActionName.GuildApprove;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<void> {
const { message_id, approve, comment } = payload;
// message_id 是邀请请求的 seq
const notifies = await this.core.apis.GroupApi.getSingleScreenNotifies(true, 100);
const notify = notifies.find(
(e) =>
e.seq == message_id && // 使用 loose equality 以防类型不匹配
(e.type === GroupNotifyMsgType.INVITED_BY_MEMBER || e.type === GroupNotifyMsgType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS)
);
if (!notify) {
throw new Error(`未找到加群邀请: ${message_id}`);
}
const operateType = approve
? NTGroupRequestOperateTypes.KAGREE
: NTGroupRequestOperateTypes.KREFUSE;
await this.core.apis.GroupApi.handleGroupRequest(false, notify, operateType, comment);
}
}

View File

@@ -0,0 +1,39 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriGuild } from '../../types';
const SchemaData = Type.Object({
guild_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
export class GuildGetAction extends SatoriAction<Payload, SatoriGuild> {
actionName = SatoriActionName.GuildGet;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<SatoriGuild> {
const { guild_id } = payload;
// 先从群列表缓存中查找
const groups = await this.core.apis.GroupApi.getGroups();
const group = groups.find((e) => e.groupCode === guild_id);
if (!group) {
// 如果缓存中没有,尝试获取详细信息
const data = await this.core.apis.GroupApi.fetchGroupDetail(guild_id);
return {
id: guild_id,
name: data.groupName,
avatar: `https://p.qlogo.cn/gh/${guild_id}/${guild_id}/640`,
};
}
return {
id: guild_id,
name: group.groupName,
avatar: `https://p.qlogo.cn/gh/${guild_id}/${guild_id}/640`,
};
}
}

View File

@@ -0,0 +1,29 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriGuild, SatoriPageResult } from '../../types';
const SchemaData = Type.Object({
next: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export class GuildListAction extends SatoriAction<Payload, SatoriPageResult<SatoriGuild>> {
actionName = SatoriActionName.GuildList;
override payloadSchema = SchemaData;
protected async _handle (_payload: Payload): Promise<SatoriPageResult<SatoriGuild>> {
const groups = await this.core.apis.GroupApi.getGroups(true);
const guilds: SatoriGuild[] = groups.map((group) => ({
id: group.groupCode,
name: group.groupName,
avatar: `https://p.qlogo.cn/gh/${group.groupCode}/${group.groupCode}/640`,
}));
return {
data: guilds,
};
}
}

View File

@@ -0,0 +1,39 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { GroupNotifyMsgType, NTGroupRequestOperateTypes } from 'napcat-core';
const SchemaData = Type.Object({
message_id: Type.String(), // 入群请求的 seq
approve: Type.Boolean(),
comment: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export class GuildMemberApproveAction extends SatoriAction<Payload, void> {
actionName = SatoriActionName.GuildMemberApprove;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<void> {
const { message_id, approve, comment } = payload;
// message_id 是入群请求的 seq
const notifies = await this.core.apis.GroupApi.getSingleScreenNotifies(true, 100);
const notify = notifies.find(
(e) =>
e.seq === message_id &&
e.type === GroupNotifyMsgType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS
);
if (!notify) {
throw new Error(`未找到入群请求: ${message_id}`);
}
const operateType = approve
? NTGroupRequestOperateTypes.KAGREE
: NTGroupRequestOperateTypes.KREFUSE;
await this.core.apis.GroupApi.handleGroupRequest(false, notify, operateType, comment);
}
}

View File

@@ -0,0 +1,36 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriGuildMember } from '../../types';
const SchemaData = Type.Object({
guild_id: Type.String(),
user_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
export class GuildMemberGetAction extends SatoriAction<Payload, SatoriGuildMember> {
actionName = SatoriActionName.GuildMemberGet;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<SatoriGuildMember> {
const { guild_id, user_id } = payload;
const memberInfo = await this.core.apis.GroupApi.getGroupMember(guild_id, user_id);
if (!memberInfo) {
throw new Error('群成员不存在');
}
return {
user: {
id: memberInfo.uin,
name: memberInfo.nick,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${memberInfo.uin}&s=640`,
},
nick: memberInfo.cardName || memberInfo.nick,
joined_at: memberInfo.joinTime ? Number(memberInfo.joinTime) * 1000 : undefined,
};
}
}

View File

@@ -0,0 +1,27 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
const SchemaData = Type.Object({
guild_id: Type.String(),
user_id: Type.String(),
permanent: Type.Optional(Type.Boolean({ default: false })),
});
type Payload = Static<typeof SchemaData>;
export class GuildMemberKickAction extends SatoriAction<Payload, void> {
actionName = SatoriActionName.GuildMemberKick;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<void> {
const { guild_id, user_id, permanent } = payload;
await this.core.apis.GroupApi.kickMember(
guild_id,
[await this.core.apis.UserApi.getUidByUinV2(user_id)],
permanent ?? false,
''
);
}
}

View File

@@ -0,0 +1,39 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriGuildMember, SatoriPageResult } from '../../types';
import { GroupMember } from 'napcat-core';
const SchemaData = Type.Object({
guild_id: Type.String(),
next: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export class GuildMemberListAction extends SatoriAction<Payload, SatoriPageResult<SatoriGuildMember>> {
actionName = SatoriActionName.GuildMemberList;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<SatoriPageResult<SatoriGuildMember>> {
const { guild_id } = payload;
// 使用 getGroupMemberAll 获取所有群成员
const result = await this.core.apis.GroupApi.getGroupMemberAll(guild_id, true);
const members: Map<string, GroupMember> = result.result.infos;
const memberList: SatoriGuildMember[] = Array.from(members.values()).map((member: GroupMember) => ({
user: {
id: member.uin,
name: member.nick,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${member.uin}&s=640`,
},
nick: member.cardName || member.nick,
joined_at: member.joinTime ? Number(member.joinTime) * 1000 : undefined,
}));
return {
data: memberList,
};
}
}

View File

@@ -0,0 +1,28 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
const SchemaData = Type.Object({
guild_id: Type.String(),
user_id: Type.String(),
duration: Type.Optional(Type.Number({ default: 0 })), // 禁言时长毫秒0 表示解除禁言
});
type Payload = Static<typeof SchemaData>;
export class GuildMemberMuteAction extends SatoriAction<Payload, void> {
actionName = SatoriActionName.GuildMemberMute;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<void> {
const { guild_id, user_id, duration } = payload;
// 将毫秒转换为秒
const durationSeconds = duration ? Math.floor(duration / 1000) : 0;
await this.core.apis.GroupApi.banMember(
guild_id,
[{ uid: await this.core.apis.UserApi.getUidByUinV2(user_id), timeStamp: durationSeconds }]
);
}
}

View File

@@ -0,0 +1,68 @@
import { NapCatCore } from 'napcat-core';
import { NapCatSatoriAdapter } from '../index';
import { SatoriAction } from './SatoriAction';
// 导入所有 Action
import { MessageCreateAction } from './message/MessageCreate';
import { MessageGetAction } from './message/MessageGet';
import { MessageDeleteAction } from './message/MessageDelete';
import { ChannelGetAction } from './channel/ChannelGet';
import { ChannelListAction } from './channel/ChannelList';
import { GuildGetAction } from './guild/GuildGet';
import { GuildListAction } from './guild/GuildList';
import { GuildApproveAction } from './guild/GuildApprove';
import { GuildMemberGetAction } from './guild/GuildMemberGet';
import { GuildMemberListAction } from './guild/GuildMemberList';
import { GuildMemberKickAction } from './guild/GuildMemberKick';
import { GuildMemberMuteAction } from './guild/GuildMemberMute';
import { GuildMemberApproveAction } from './guild/GuildMemberApprove';
import { UserGetAction } from './user/UserGet';
import { FriendListAction } from './user/FriendList';
import { FriendApproveAction } from './user/FriendApprove';
import { LoginGetAction } from './login/LoginGet';
import { UploadCreateAction } from './upload/UploadCreate';
export type SatoriActionMap = Map<string, SatoriAction<unknown, unknown>>;
export function createSatoriActionMap (
satoriAdapter: NapCatSatoriAdapter,
core: NapCatCore
): SatoriActionMap {
const actionMap: SatoriActionMap = new Map();
const actions: SatoriAction<unknown, unknown>[] = [
// 消息相关
new MessageCreateAction(satoriAdapter, core),
new MessageGetAction(satoriAdapter, core),
new MessageDeleteAction(satoriAdapter, core),
// 频道相关
new ChannelGetAction(satoriAdapter, core),
new ChannelListAction(satoriAdapter, core),
// 群组相关
new GuildGetAction(satoriAdapter, core),
new GuildListAction(satoriAdapter, core),
new GuildApproveAction(satoriAdapter, core),
new GuildMemberGetAction(satoriAdapter, core),
new GuildMemberListAction(satoriAdapter, core),
new GuildMemberKickAction(satoriAdapter, core),
new GuildMemberMuteAction(satoriAdapter, core),
new GuildMemberApproveAction(satoriAdapter, core),
// 用户相关
new UserGetAction(satoriAdapter, core),
new FriendListAction(satoriAdapter, core),
new FriendApproveAction(satoriAdapter, core),
// 登录相关
new LoginGetAction(satoriAdapter, core),
// 上传相关
new UploadCreateAction(satoriAdapter, core),
];
for (const action of actions) {
actionMap.set(action.actionName, action);
}
return actionMap;
}
export { SatoriAction, SatoriCheckResult, SatoriResponse, SatoriResponseHelper } from './SatoriAction';
export { SatoriActionName, SatoriActionNameType } from './router';

View File

@@ -0,0 +1,26 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriLogin, SatoriLoginStatus } from '../../types';
const SchemaData = Type.Object({});
type Payload = Static<typeof SchemaData>;
export class LoginGetAction extends SatoriAction<Payload, SatoriLogin> {
actionName = SatoriActionName.LoginGet;
override payloadSchema = SchemaData;
protected async _handle (_payload: Payload): Promise<SatoriLogin> {
return {
user: {
id: this.selfInfo.uin,
name: this.selfInfo.nick,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.selfInfo.uin}&s=640`,
},
self_id: this.selfInfo.uin,
platform: this.platform,
status: SatoriLoginStatus.ONLINE,
};
}
}

View File

@@ -0,0 +1,74 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriMessage, SatoriChannelType } from '../../types';
import { ChatType, SendMessageElement } from 'napcat-core';
const SchemaData = Type.Object({
channel_id: Type.String(),
content: Type.String(),
});
type Payload = Static<typeof SchemaData>;
export class MessageCreateAction extends SatoriAction<Payload, SatoriMessage[]> {
actionName = SatoriActionName.MessageCreate;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<SatoriMessage[]> {
const { channel_id, content } = payload;
// 解析 channel_id格式: private:{user_id} 或 group:{group_id}
const parts = channel_id.split(':');
const type = parts[0];
const id = parts[1];
if (!type || !id) {
throw new Error(`无效的频道ID格式: ${channel_id}`);
}
let chatType: ChatType;
let peerUid: string;
if (type === 'private') {
chatType = ChatType.KCHATTYPEC2C;
peerUid = await this.core.apis.UserApi.getUidByUinV2(id);
} else if (type === 'group') {
chatType = ChatType.KCHATTYPEGROUP;
peerUid = id;
} else {
throw new Error(`不支持的频道类型: ${type}`);
}
// 解析 Satori 消息内容为 NapCat 消息元素
const elements = await this.satoriAdapter.apis.MsgApi.parseContent(content);
// 发送消息
const result = await this.core.apis.MsgApi.sendMsg(
{ chatType, peerUid, guildId: '' },
elements as SendMessageElement[],
30000
);
if (!result) {
throw new Error('消息发送失败: 未知错误');
}
// 构造返回结果
const message: SatoriMessage = {
id: result.msgId,
content,
channel: {
id: channel_id,
type: type === 'private' ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
},
user: {
id: this.selfInfo.uin,
name: this.selfInfo.nick,
},
created_at: Date.now(),
};
return [message];
}
}

View File

@@ -0,0 +1,44 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { ChatType } from 'napcat-core';
const SchemaData = Type.Object({
channel_id: Type.String(),
message_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
export class MessageDeleteAction extends SatoriAction<Payload, void> {
actionName = SatoriActionName.MessageDelete;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<void> {
const { channel_id, message_id } = payload;
const parts = channel_id.split(':');
const type = parts[0];
const id = parts[1];
if (!type || !id) {
throw new Error(`无效的频道ID格式: ${channel_id}`);
}
let chatType: ChatType;
let peerUid: string;
if (type === 'private') {
chatType = ChatType.KCHATTYPEC2C;
peerUid = await this.core.apis.UserApi.getUidByUinV2(id);
} else if (type === 'group') {
chatType = ChatType.KCHATTYPEGROUP;
peerUid = id;
} else {
throw new Error(`不支持的频道类型: ${type}`);
}
const peer = { chatType, peerUid, guildId: '' };
await this.core.apis.MsgApi.recallMsg(peer, message_id);
}
}

View File

@@ -0,0 +1,72 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriMessage, SatoriChannelType } from '../../types';
import { ChatType } from 'napcat-core';
const SchemaData = Type.Object({
channel_id: Type.String(),
message_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
export class MessageGetAction extends SatoriAction<Payload, SatoriMessage> {
actionName = SatoriActionName.MessageGet;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<SatoriMessage> {
const { channel_id, message_id } = payload;
const parts = channel_id.split(':');
const type = parts[0];
const id = parts[1];
if (!type || !id) {
throw new Error(`无效的频道ID: ${channel_id}`);
}
let chatType: ChatType;
let peerUid: string;
if (type === 'private') {
chatType = ChatType.KCHATTYPEC2C;
peerUid = await this.core.apis.UserApi.getUidByUinV2(id);
} else if (type === 'group') {
chatType = ChatType.KCHATTYPEGROUP;
peerUid = id;
} else {
throw new Error(`不支持的频道类型: ${type}`);
}
const peer = { chatType, peerUid, guildId: '' };
const msgs = await this.core.apis.MsgApi.getMsgsByMsgId(peer, [message_id]);
if (!msgs || msgs.msgList.length === 0) {
throw new Error('消息不存在');
}
const msg = msgs.msgList[0];
if (!msg) {
throw new Error('消息不存在');
}
const content = await this.satoriAdapter.apis.MsgApi.parseElements(msg.elements);
const message: SatoriMessage = {
id: msg.msgId,
content,
channel: {
id: channel_id,
type: type === 'private' ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
},
user: {
id: msg.senderUin,
name: msg.sendNickName,
},
created_at: parseInt(msg.msgTime) * 1000,
};
return message;
}
}

View File

@@ -0,0 +1,57 @@
/**
* Satori Action 名称映射
*/
export const SatoriActionName = {
// 消息相关
MessageCreate: 'message.create',
MessageGet: 'message.get',
MessageDelete: 'message.delete',
MessageUpdate: 'message.update',
MessageList: 'message.list',
// 频道相关
ChannelGet: 'channel.get',
ChannelList: 'channel.list',
ChannelCreate: 'channel.create',
ChannelUpdate: 'channel.update',
ChannelDelete: 'channel.delete',
ChannelMute: 'channel.mute',
// 群组/公会相关
GuildGet: 'guild.get',
GuildList: 'guild.list',
GuildApprove: 'guild.approve',
// 群成员相关
GuildMemberGet: 'guild.member.get',
GuildMemberList: 'guild.member.list',
GuildMemberKick: 'guild.member.kick',
GuildMemberMute: 'guild.member.mute',
GuildMemberApprove: 'guild.member.approve',
GuildMemberRole: 'guild.member.role',
// 角色相关
GuildRoleList: 'guild.role.list',
GuildRoleCreate: 'guild.role.create',
GuildRoleUpdate: 'guild.role.update',
GuildRoleDelete: 'guild.role.delete',
// 用户相关
UserGet: 'user.get',
UserChannelCreate: 'user.channel.create',
// 好友相关
FriendList: 'friend.list',
FriendApprove: 'friend.approve',
// 登录相关
LoginGet: 'login.get',
// 上传相关
UploadCreate: 'upload.create',
// 内部互操作Satori 可选)
InternalAction: 'internal.action',
} as const;
export type SatoriActionNameType = typeof SatoriActionName[keyof typeof SatoriActionName];

View File

@@ -0,0 +1,46 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
const SchemaData = Type.Record(Type.String(), Type.Unknown());
type Payload = Static<typeof SchemaData>;
interface UploadResult {
[key: string]: string;
}
export class UploadCreateAction extends SatoriAction<Payload, UploadResult> {
actionName = SatoriActionName.UploadCreate;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<UploadResult> {
const result: UploadResult = {};
// 处理上传的文件
for (const [key, value] of Object.entries(payload)) {
if (typeof value === 'string' && value.startsWith('data:')) {
// Base64 数据
const matches = value.match(/^data:([^;]+);base64,(.+)$/);
if (matches && matches[1] && matches[2]) {
const mimeType = matches[1];
const base64Data = matches[2];
// 保存文件并返回 URL
const url = await this.saveFile(base64Data, mimeType);
result[key] = url;
}
} else if (typeof value === 'string') {
// 可能是 URL直接返回
result[key] = value;
}
}
return result;
}
private async saveFile (base64Data: string, _mimeType: string): Promise<string> {
// 将 base64 数据保存为临时文件并返回 URL
// 这里简化处理,实际应该保存到文件系统
return `base64://${base64Data}`;
}
}

View File

@@ -0,0 +1,31 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
const SchemaData = Type.Object({
message_id: Type.String(),
approve: Type.Boolean(),
comment: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export class FriendApproveAction extends SatoriAction<Payload, void> {
actionName = SatoriActionName.FriendApprove;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<void> {
const { message_id, approve } = payload;
// message_id 格式: reqTime (好友请求的时间戳)
// 需要从好友请求列表中找到对应的请求
const buddyReqData = await this.core.apis.FriendApi.getBuddyReq();
const notify = buddyReqData.buddyReqs.find((e) => e.reqTime === message_id);
if (!notify) {
throw new Error(`未找到好友请求: ${message_id}`);
}
await this.core.apis.FriendApi.handleFriendRequest(notify, approve);
}
}

View File

@@ -0,0 +1,30 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriUser, SatoriPageResult } from '../../types';
const SchemaData = Type.Object({
next: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export class FriendListAction extends SatoriAction<Payload, SatoriPageResult<SatoriUser>> {
actionName = SatoriActionName.FriendList;
override payloadSchema = SchemaData;
protected async _handle (_payload: Payload): Promise<SatoriPageResult<SatoriUser>> {
const friends = await this.core.apis.FriendApi.getBuddy();
const friendList: SatoriUser[] = friends.map((friend) => ({
id: friend.uin || '',
name: friend.coreInfo?.nick || '',
nick: friend.coreInfo?.remark || friend.coreInfo?.nick || '',
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${friend.uin}&s=640`,
}));
return {
data: friendList,
};
}
}

View File

@@ -0,0 +1,28 @@
import { Static, Type } from '@sinclair/typebox';
import { SatoriAction } from '../SatoriAction';
import { SatoriActionName } from '../router';
import { SatoriUser } from '../../types';
const SchemaData = Type.Object({
user_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
export class UserGetAction extends SatoriAction<Payload, SatoriUser> {
actionName = SatoriActionName.UserGet;
override payloadSchema = SchemaData;
protected async _handle (payload: Payload): Promise<SatoriUser> {
const { user_id } = payload;
const uid = await this.core.apis.UserApi.getUidByUinV2(user_id);
const userInfo = await this.core.apis.UserApi.getUserDetailInfo(uid, false);
return {
id: user_id,
name: userInfo.nick,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${user_id}&s=640`,
};
}
}

View File

@@ -0,0 +1,417 @@
import { NapCatCore, RawMessage, ChatType, GroupNotify, FriendRequest } from 'napcat-core';
import { NapCatSatoriAdapter } from '../index';
import {
SatoriEvent,
SatoriChannelType,
SatoriLoginStatus,
} from '../types';
/**
* Satori 事件类型定义
*/
export const SatoriEventType = {
// 消息事件
MESSAGE_CREATED: 'message-created',
MESSAGE_UPDATED: 'message-updated',
MESSAGE_DELETED: 'message-deleted',
// 频道事件
CHANNEL_CREATED: 'channel-created',
CHANNEL_UPDATED: 'channel-updated',
CHANNEL_DELETED: 'channel-deleted',
// 群组/公会事件
GUILD_ADDED: 'guild-added',
GUILD_UPDATED: 'guild-updated',
GUILD_REMOVED: 'guild-removed',
GUILD_REQUEST: 'guild-request',
// 群成员事件
GUILD_MEMBER_ADDED: 'guild-member-added',
GUILD_MEMBER_UPDATED: 'guild-member-updated',
GUILD_MEMBER_REMOVED: 'guild-member-removed',
GUILD_MEMBER_REQUEST: 'guild-member-request',
// 角色事件
GUILD_ROLE_CREATED: 'guild-role-created',
GUILD_ROLE_UPDATED: 'guild-role-updated',
GUILD_ROLE_DELETED: 'guild-role-deleted',
// 好友事件
FRIEND_REQUEST: 'friend-request',
// 登录事件
LOGIN_ADDED: 'login-added',
LOGIN_REMOVED: 'login-removed',
LOGIN_UPDATED: 'login-updated',
// 内部事件
INTERNAL: 'internal',
} as const;
export type SatoriEventTypeName = typeof SatoriEventType[keyof typeof SatoriEventType];
export class SatoriEventApi {
private satoriAdapter: NapCatSatoriAdapter;
private core: NapCatCore;
private eventId: number = 0;
constructor (satoriAdapter: NapCatSatoriAdapter, core: NapCatCore) {
this.satoriAdapter = satoriAdapter;
this.core = core;
}
private getNextEventId (): number {
return ++this.eventId;
}
private get platform (): string {
return this.satoriAdapter.configLoader.configData.platform;
}
private get selfId (): string {
return this.core.selfInfo.uin;
}
/**
* 创建基础事件结构
*/
private createBaseEvent (type: SatoriEventTypeName): SatoriEvent {
return {
id: this.getNextEventId(),
type,
platform: this.platform,
self_id: this.selfId,
timestamp: Date.now(),
};
}
/**
* 将 NapCat 消息转换为 Satori 事件
*/
async createMessageEvent (message: RawMessage): Promise<SatoriEvent | null> {
try {
const content = await this.satoriAdapter.apis.MsgApi.parseElements(message.elements);
const isPrivate = message.chatType === ChatType.KCHATTYPEC2C;
const event = this.createBaseEvent(SatoriEventType.MESSAGE_CREATED);
event.timestamp = parseInt(message.msgTime) * 1000;
event.channel = {
id: isPrivate ? `private:${message.senderUin}` : `group:${message.peerUin}`,
type: isPrivate ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
};
event.user = {
id: message.senderUin,
name: message.sendNickName,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${message.senderUin}&s=640`,
};
event.message = {
id: message.msgId,
content,
};
if (!isPrivate) {
event.guild = {
id: message.peerUin,
name: message.peerName,
avatar: `https://p.qlogo.cn/gh/${message.peerUin}/${message.peerUin}/640`,
};
event.member = {
nick: message.sendMemberName || message.sendNickName,
};
}
return event;
} catch (error) {
this.core.context.logger.logError('[Satori] 创建消息事件失败:', error);
return null;
}
}
/**
* 创建消息更新事件
*/
async createMessageUpdatedEvent (message: RawMessage): Promise<SatoriEvent | null> {
try {
const content = await this.satoriAdapter.apis.MsgApi.parseElements(message.elements);
const isPrivate = message.chatType === ChatType.KCHATTYPEC2C;
const event = this.createBaseEvent(SatoriEventType.MESSAGE_UPDATED);
event.channel = {
id: isPrivate ? `private:${message.senderUin}` : `group:${message.peerUin}`,
type: isPrivate ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
};
event.user = {
id: message.senderUin,
name: message.sendNickName,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${message.senderUin}&s=640`,
};
event.message = {
id: message.msgId,
content,
};
return event;
} catch (error) {
this.core.context.logger.logError('[Satori] 创建消息更新事件失败:', error);
return null;
}
}
/**
* 创建好友请求事件
*/
createFriendRequestEvent (request: FriendRequest): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.FRIEND_REQUEST);
event.user = {
id: request.friendUid,
name: request.friendNick,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${request.friendUid}&s=640`,
};
event.message = {
id: request.reqTime,
content: request.extWords,
};
return event;
}
/**
* 创建群组加入请求事件
*/
createGuildMemberRequestEvent (notify: GroupNotify): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.GUILD_MEMBER_REQUEST);
event.guild = {
id: notify.group.groupCode,
name: notify.group.groupName,
avatar: `https://p.qlogo.cn/gh/${notify.group.groupCode}/${notify.group.groupCode}/640`,
};
event.user = {
id: notify.user1.uid,
name: notify.user1.nickName,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${notify.user1.uid}&s=640`,
};
event.message = {
id: notify.seq,
content: notify.postscript,
};
return event;
}
/**
* 创建群组邀请事件
*/
createGuildRequestEvent (notify: GroupNotify): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.GUILD_REQUEST);
event.guild = {
id: notify.group.groupCode,
name: notify.group.groupName,
avatar: `https://p.qlogo.cn/gh/${notify.group.groupCode}/${notify.group.groupCode}/640`,
};
event.user = {
id: notify.user2.uid,
name: notify.user2.nickName,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${notify.user2.uid}&s=640`,
};
event.message = {
id: notify.seq,
content: notify.postscript,
};
return event;
}
/**
* 创建群成员增加事件
*/
createGuildMemberAddedEvent (
guildId: string,
guildName: string,
userId: string,
userName: string,
operatorId?: string
): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.GUILD_MEMBER_ADDED);
event.guild = {
id: guildId,
name: guildName,
avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`,
};
event.user = {
id: userId,
name: userName,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${userId}&s=640`,
};
if (operatorId) {
event.operator = {
id: operatorId,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${operatorId}&s=640`,
};
}
return event;
}
/**
* 创建群成员移除事件
*/
createGuildMemberRemovedEvent (
guildId: string,
guildName: string,
userId: string,
userName: string,
operatorId?: string
): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.GUILD_MEMBER_REMOVED);
event.guild = {
id: guildId,
name: guildName,
avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`,
};
event.user = {
id: userId,
name: userName,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${userId}&s=640`,
};
if (operatorId) {
event.operator = {
id: operatorId,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${operatorId}&s=640`,
};
}
return event;
}
/**
* 创建群添加事件(自己被邀请或加入群)
*/
createGuildAddedEvent (
guildId: string,
guildName: string
): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.GUILD_ADDED);
event.guild = {
id: guildId,
name: guildName,
avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`,
};
return event;
}
/**
* 创建群移除事件(被踢出或退出群)
*/
createGuildRemovedEvent (
guildId: string,
guildName: string
): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.GUILD_REMOVED);
event.guild = {
id: guildId,
name: guildName,
avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`,
};
return event;
}
/**
* 创建消息删除事件
*/
createMessageDeletedEvent (
channelId: string,
messageId: string,
userId: string,
operatorId?: string
): SatoriEvent {
const isPrivate = channelId.startsWith('private:');
const event = this.createBaseEvent(SatoriEventType.MESSAGE_DELETED);
event.channel = {
id: channelId,
type: isPrivate ? SatoriChannelType.DIRECT : SatoriChannelType.TEXT,
};
event.user = {
id: userId,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${userId}&s=640`,
};
event.message = {
id: messageId,
content: '',
};
if (operatorId) {
event.operator = {
id: operatorId,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${operatorId}&s=640`,
};
}
return event;
}
/**
* 创建登录添加事件
*/
createLoginAddedEvent (): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.LOGIN_ADDED);
event.login = {
user: {
id: this.selfId,
name: this.core.selfInfo.nick,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.selfId}&s=640`,
},
self_id: this.selfId,
platform: this.platform,
status: SatoriLoginStatus.ONLINE,
};
return event;
}
/**
* 创建登录移除事件
*/
createLoginRemovedEvent (): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.LOGIN_REMOVED);
event.login = {
user: {
id: this.selfId,
name: this.core.selfInfo.nick,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.selfId}&s=640`,
},
self_id: this.selfId,
platform: this.platform,
status: SatoriLoginStatus.OFFLINE,
};
return event;
}
/**
* 创建登录状态更新事件
*/
createLoginUpdatedEvent (status: SatoriLoginStatus): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.LOGIN_UPDATED);
event.login = {
user: {
id: this.selfId,
name: this.core.selfInfo.nick,
avatar: `https://q1.qlogo.cn/g?b=qq&nk=${this.selfId}&s=640`,
},
self_id: this.selfId,
platform: this.platform,
status,
};
return event;
}
/**
* 创建内部事件(用于扩展)
*/
createInternalEvent (typeName: string, data: Record<string, unknown>): SatoriEvent {
const event = this.createBaseEvent(SatoriEventType.INTERNAL);
event._type = typeName;
event._data = data;
return event;
}
}
export { SatoriEventType as EventType };

View File

@@ -0,0 +1,22 @@
import { NapCatCore } from 'napcat-core';
import { NapCatSatoriAdapter } from '../index';
import { SatoriMsgApi } from './msg';
import { SatoriEventApi } from './event';
export interface SatoriApiList {
MsgApi: SatoriMsgApi;
EventApi: SatoriEventApi;
}
export function createSatoriApis (
satoriAdapter: NapCatSatoriAdapter,
core: NapCatCore
): SatoriApiList {
return {
MsgApi: new SatoriMsgApi(satoriAdapter, core),
EventApi: new SatoriEventApi(satoriAdapter, core),
};
}
export { SatoriMsgApi } from './msg';
export { SatoriEventApi } from './event';

View File

@@ -0,0 +1,392 @@
import { NapCatCore, MessageElement, ElementType, NTMsgAtType } from 'napcat-core';
import { NapCatSatoriAdapter } from '../index';
import SatoriElement from '@satorijs/element';
/**
* Satori 消息处理 API
* 使用 @satorijs/element 处理消息格式转换
*/
export class SatoriMsgApi {
private core: NapCatCore;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private _adapter: NapCatSatoriAdapter;
constructor (satoriAdapter: NapCatSatoriAdapter, core: NapCatCore) {
this._adapter = satoriAdapter;
this.core = core;
}
/**
* 解析 Satori 消息内容为 NapCat 消息元素
* 使用 @satorijs/element 解析
*/
async parseContent (content: string): Promise<MessageElement[]> {
const elements: MessageElement[] = [];
const parsed = SatoriElement.parse(content);
for (const elem of parsed) {
const parsedElements = await this.parseSatoriElement(elem);
elements.push(...parsedElements);
}
// 如果没有解析到任何元素,将整个内容作为文本
if (elements.length === 0 && content.trim()) {
elements.push(this.createTextElement(content));
}
return elements;
}
/**
* 解析 satorijs 元素为消息元素
*/
private async parseSatoriElement (elem: SatoriElement): Promise<MessageElement[]> {
const elements: MessageElement[] = [];
switch (elem.type) {
case 'text':
if (elem.attrs['content']) {
elements.push(this.createTextElement(elem.attrs['content']));
}
break;
case 'at': {
const attrs = elem.attrs;
elements.push(await this.createAtElement({
id: attrs['id'] || '',
type: attrs['type'] || '',
name: attrs['name'] || '',
}));
break;
}
case 'img':
case 'image': {
const attrs = elem.attrs;
elements.push(await this.createImageElement({
src: attrs['src'] || '',
width: attrs['width'] || '',
height: attrs['height'] || '',
}));
break;
}
case 'audio': {
const attrs = elem.attrs;
elements.push(await this.createAudioElement({
src: attrs['src'] || '',
duration: attrs['duration'] || '',
}));
break;
}
case 'video': {
const attrs = elem.attrs;
elements.push(await this.createVideoElement({
src: attrs['src'] || '',
}));
break;
}
case 'file': {
const attrs = elem.attrs;
elements.push(await this.createFileElement({
src: attrs['src'] || '',
title: attrs['title'] || '',
}));
break;
}
case 'face': {
const attrs = elem.attrs;
elements.push(this.createFaceElement({
id: attrs['id'] || '0',
}));
break;
}
case 'quote': {
const attrs = elem.attrs;
elements.push(await this.createQuoteElement({
id: attrs['id'] || '',
}));
break;
}
case 'a': {
const href = elem.attrs['href'];
if (href) {
const linkText = elem.children.map((c) => c.toString()).join('');
elements.push(this.createTextElement(`${linkText} (${href})`));
}
break;
}
case 'button': {
const text = elem.attrs['text'];
if (text) {
elements.push(this.createTextElement(`[${text}]`));
}
break;
}
case 'br':
elements.push(this.createTextElement('\n'));
break;
case 'p':
for (const child of elem.children) {
elements.push(...await this.parseSatoriElement(child));
}
elements.push(this.createTextElement('\n'));
break;
default:
// 递归处理子元素
if (elem.children) {
for (const child of elem.children) {
elements.push(...await this.parseSatoriElement(child));
}
}
}
return elements;
}
/**
* 解析 NapCat 消息元素为 Satori XML 消息内容
*/
async parseElements (elements: MessageElement[]): Promise<string> {
const satoriElements: SatoriElement[] = [];
for (const element of elements) {
const node = await this.elementToSatoriElement(element);
if (node) {
satoriElements.push(node);
}
}
return satoriElements.map((e) => e.toString()).join('');
}
/**
* 将单个消息元素转换为 SatoriElement
*/
private async elementToSatoriElement (element: MessageElement): Promise<SatoriElement | null> {
switch (element.elementType) {
case ElementType.TEXT:
if (element.textElement) {
if (element.textElement.atType === NTMsgAtType.ATTYPEALL) {
return SatoriElement('at', { type: 'all' });
} else if (element.textElement.atType === NTMsgAtType.ATTYPEONE && element.textElement.atUid) {
const uin = await this.core.apis.UserApi.getUinByUidV2(element.textElement.atUid);
return SatoriElement('at', { id: uin, name: element.textElement.content?.replace('@', '') });
}
return SatoriElement.text(element.textElement.content);
}
break;
case ElementType.PIC:
if (element.picElement) {
const src = await this.getMediaUrl(element.picElement.sourcePath || '', 'image');
return SatoriElement('img', {
src,
width: element.picElement.picWidth,
height: element.picElement.picHeight,
});
}
break;
case ElementType.PTT:
if (element.pttElement) {
const src = await this.getMediaUrl(element.pttElement.filePath || '', 'audio');
return SatoriElement('audio', {
src,
duration: element.pttElement.duration,
});
}
break;
case ElementType.VIDEO:
if (element.videoElement) {
const src = await this.getMediaUrl(element.videoElement.filePath || '', 'video');
return SatoriElement('video', { src });
}
break;
case ElementType.FILE:
if (element.fileElement) {
const src = element.fileElement.filePath || '';
return SatoriElement('file', {
src,
title: element.fileElement.fileName,
});
}
break;
case ElementType.FACE:
if (element.faceElement) {
return SatoriElement('face', { id: element.faceElement.faceIndex });
}
break;
case ElementType.REPLY:
if (element.replyElement) {
const msgId = element.replyElement.sourceMsgIdInRecords || element.replyElement.replayMsgId || '';
return SatoriElement('quote', { id: msgId });
}
break;
case ElementType.MFACE:
if (element.marketFaceElement) {
return SatoriElement('face', { id: element.marketFaceElement.emojiId || '0' });
}
break;
default:
break;
}
return null;
}
/**
* 获取媒体资源 URL
*/
private async getMediaUrl (path: string, _type: 'image' | 'audio' | 'video'): Promise<string> {
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:')) {
return path;
}
if (path.startsWith('/') || /^[a-zA-Z]:/.test(path)) {
return `file://${path.replace(/\\/g, '/')}`;
}
return path;
}
private createTextElement (content: string): MessageElement {
return {
elementType: ElementType.TEXT,
elementId: '',
textElement: {
content,
atType: NTMsgAtType.ATTYPEUNKNOWN,
atUid: '',
atTinyId: '',
atNtUid: '',
},
};
}
private async createAtElement (attrs: { id: string; type?: string; name?: string; }): Promise<MessageElement> {
const { id, type } = attrs;
if (type === 'all') {
return {
elementType: ElementType.TEXT,
elementId: '',
textElement: {
content: '@全体成员',
atType: NTMsgAtType.ATTYPEALL,
atUid: '',
atTinyId: '',
atNtUid: '',
},
};
}
const uid = await this.core.apis.UserApi.getUidByUinV2(id);
const userInfo = await this.core.apis.UserApi.getUserDetailInfo(uid, false);
return {
elementType: ElementType.TEXT,
elementId: '',
textElement: {
content: `@${userInfo.nick || id}`,
atType: NTMsgAtType.ATTYPEONE,
atUid: uid,
atTinyId: '',
atNtUid: uid,
},
};
}
private async createImageElement (attrs: { src: string; width?: string; height?: string; }): Promise<MessageElement> {
const src = attrs.src;
return {
elementType: ElementType.PIC,
elementId: '',
picElement: {
sourcePath: src,
picWidth: parseInt(attrs.width || '0', 10),
picHeight: parseInt(attrs.height || '0', 10),
},
} as MessageElement;
}
private async createAudioElement (attrs: { src: string; duration?: string; }): Promise<MessageElement> {
const src = attrs.src;
return {
elementType: ElementType.PTT,
elementId: '',
pttElement: {
filePath: src,
duration: parseInt(attrs.duration || '0', 10),
},
} as MessageElement;
}
private async createVideoElement (attrs: { src: string; }): Promise<MessageElement> {
const src = attrs.src;
return {
elementType: ElementType.VIDEO,
elementId: '',
videoElement: {
filePath: src,
videoMd5: '',
thumbMd5: '',
fileSize: '',
},
} as MessageElement;
}
private async createFileElement (attrs: { src: string; title?: string; }): Promise<MessageElement> {
const src = attrs.src;
return {
elementType: ElementType.FILE,
elementId: '',
fileElement: {
filePath: src,
fileName: attrs.title || '',
fileSize: '',
},
} as MessageElement;
}
private createFaceElement (attrs: { id: string; }): MessageElement {
return {
elementType: ElementType.FACE,
elementId: '',
faceElement: {
faceIndex: parseInt(attrs.id || '0', 10),
faceType: 1,
},
} as MessageElement;
}
private async createQuoteElement (attrs: { id: string; }): Promise<MessageElement> {
const id = attrs.id;
return {
elementType: ElementType.REPLY,
elementId: '',
replyElement: {
sourceMsgIdInRecords: id,
replayMsgSeq: '',
replayMsgId: id,
senderUin: '',
senderUinStr: '',
},
} as MessageElement;
}
}

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