mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-10 06:50:26 +00:00
Compare commits
196 Commits
style-webu
...
v4.15.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f6dad118 | ||
|
|
c3b29f1ee6 | ||
|
|
3e85d18ab5 | ||
|
|
df48c01ce4 | ||
|
|
209776a9e8 | ||
|
|
09dae7269a | ||
|
|
2dcf8004ab | ||
|
|
74b1da67d8 | ||
|
|
78ac36f670 | ||
|
|
74781fda0a | ||
|
|
3bead89d46 | ||
|
|
a4527fd8ca | ||
|
|
52b6627ebd | ||
|
|
a5769b6a62 | ||
|
|
d9297c1e10 | ||
|
|
94f07ab98b | ||
|
|
01a6594707 | ||
|
|
82a7154b92 | ||
|
|
9b385ac9c9 | ||
|
|
e3d4cee416 | ||
|
|
f6c79370cb | ||
|
|
39460e4acb | ||
|
|
f971c312b9 | ||
|
|
2c3a304440 | ||
|
|
286b0e03f7 | ||
|
|
447f86e2b5 | ||
|
|
0592f1a99a | ||
|
|
90e3936204 | ||
|
|
1239f622d2 | ||
|
|
d511e2bb3f | ||
|
|
ff93aa3dc7 | ||
|
|
cc8891b6a1 | ||
|
|
7c65b1eaf1 | ||
|
|
ebe3e9c63c | ||
|
|
d33a872c42 | ||
|
|
9377dc3d52 | ||
|
|
17322bb5a4 | ||
|
|
c0bcced5fb | ||
|
|
805c1d5ea2 | ||
|
|
b3399b07ad | ||
|
|
71f8504849 | ||
|
|
3b7ca1a08f | ||
|
|
57f3c4dd31 | ||
|
|
5b20ebb7b0 | ||
|
|
3a3eaeec7c | ||
|
|
b0cc7b6ee5 | ||
|
|
e5108c0427 | ||
|
|
927797f3d5 | ||
|
|
72e01f8c84 | ||
|
|
c38b98a0c4 | ||
|
|
05d27e86ce | ||
|
|
40409a3841 | ||
|
|
65bae6b57a | ||
|
|
0b6afb66d9 | ||
|
|
52be000fdd | ||
|
|
55ce5bcfd3 | ||
|
|
29888cb38b | ||
|
|
6ea4c9ec65 | ||
|
|
4bec3aa597 | ||
|
|
38c320d2c9 | ||
|
|
76cbd8a1c1 | ||
|
|
0779628be5 | ||
|
|
34ca919c4d | ||
|
|
b1b357347b | ||
|
|
129d63f66e | ||
|
|
699b46acbd | ||
|
|
7f05aee11d | ||
|
|
542036f46e | ||
|
|
b958e9e803 | ||
|
|
73fcfb5900 | ||
|
|
adabc4da46 | ||
|
|
bf073b544b | ||
|
|
a71219062a | ||
|
|
001fe01ace | ||
|
|
0aa0c44634 | ||
|
|
93126e514e | ||
|
|
1ae10ae0c6 | ||
|
|
4b693bf6e2 | ||
|
|
574c257591 | ||
|
|
d680328762 | ||
|
|
d711cdecaf | ||
|
|
c5f1792009 | ||
|
|
a5e705e6a4 | ||
|
|
007f1db339 | ||
|
|
008fb39f8f | ||
|
|
6b8cc6756d | ||
|
|
24623f18d8 | ||
|
|
8b676ed693 | ||
|
|
ab2dfcfd8f | ||
|
|
2998e04435 | ||
|
|
613690f5af | ||
|
|
a60d8d109c | ||
|
|
28c9761e3d | ||
|
|
805cc32d7f | ||
|
|
de9d5180fe | ||
|
|
791e359199 | ||
|
|
63a9d571f3 | ||
|
|
59d4b08982 | ||
|
|
81e4e54f25 | ||
|
|
b75b733bb0 | ||
|
|
246269b519 | ||
|
|
3c24d6b700 | ||
|
|
679c980683 | ||
|
|
19766002ae | ||
|
|
c2d3a8034d | ||
|
|
58220d3fbc | ||
|
|
2daddbb030 | ||
|
|
6ec5bbeddf | ||
|
|
75236dd50c | ||
|
|
01958d47a4 | ||
|
|
772f07c58b | ||
|
|
0f9647bf64 | ||
|
|
8197ebcbcf | ||
|
|
d0519feb4f | ||
|
|
d43c6b10a3 | ||
|
|
857be5ee49 | ||
|
|
af8005dd6f | ||
|
|
6e8adad7ca | ||
|
|
0f8584b8e1 | ||
|
|
37f40a2635 | ||
|
|
1b4d604e32 | ||
|
|
81a0c07922 | ||
|
|
a8cb6b5865 | ||
|
|
d25bd65b2d | ||
|
|
e510a75f0c | ||
|
|
e3c6048a7f | ||
|
|
789c72d4cf | ||
|
|
711a060dd9 | ||
|
|
6268923f01 | ||
|
|
f6b9017429 | ||
|
|
178e51bbb8 | ||
|
|
8a232d8c68 | ||
|
|
7216755430 | ||
|
|
0c91f9c66b | ||
|
|
e8855a59b0 | ||
|
|
5de2664af4 | ||
|
|
5284e0ac5a | ||
|
|
67d6cd3f2e | ||
|
|
0ba5862753 | ||
|
|
d4478275ee | ||
|
|
163bb88751 | ||
|
|
ec6762d916 | ||
|
|
ed1872a349 | ||
|
|
a7fd70ac3a | ||
|
|
7e38f1d227 | ||
|
|
0ca68010a5 | ||
|
|
822f683a14 | ||
|
|
f4d3d33954 | ||
|
|
d1abf788a5 | ||
|
|
9ba6b2ed40 | ||
|
|
3a880e389b | ||
|
|
1c7ac42a46 | ||
|
|
3e8b575015 | ||
|
|
7c22170e1e | ||
|
|
f143da6ba8 | ||
|
|
d0d3934869 | ||
|
|
808165b008 | ||
|
|
d23785f34d | ||
|
|
31daf41135 | ||
|
|
a2450b72be | ||
|
|
fbccf8be24 | ||
|
|
37ae17b53f | ||
|
|
35566970fd | ||
|
|
e70cd1eff7 | ||
|
|
fbd3241845 | ||
|
|
cf69ccdbc9 | ||
|
|
f3de4d48d3 | ||
|
|
17d5110069 | ||
|
|
c5de5e00fc | ||
|
|
ea7cd7f7e1 | ||
|
|
cc23599776 | ||
|
|
c6ec2126e0 | ||
|
|
f1756c4d1c | ||
|
|
4940d72867 | ||
|
|
91e0839ed5 | ||
|
|
334c4233e6 | ||
|
|
71bb4f68f3 | ||
|
|
47983e2915 | ||
|
|
ae42eed6e2 | ||
|
|
cb061890d3 | ||
|
|
31feec26b5 | ||
|
|
e93cd3529f | ||
|
|
1ad700b935 | ||
|
|
68c8b984ad | ||
|
|
8eb1aa2fb4 | ||
|
|
2d3f4e696b | ||
|
|
b241881c74 | ||
|
|
aecf33f4dc | ||
|
|
dd4374389b | ||
|
|
100efb03ab | ||
|
|
ce9482f19d | ||
|
|
4e37b002f9 | ||
|
|
7e7262415b | ||
|
|
3365211507 | ||
|
|
05b38825c0 | ||
|
|
95f4a4d37e |
32
.github/prompt/default.md
vendored
32
.github/prompt/default.md
vendored
@@ -1,8 +1,8 @@
|
||||
# V?.?.?
|
||||
# {VERSION}
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
@@ -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)
|
||||
|
||||
115
.github/prompt/release_note_prompt.txt
vendored
115
.github/prompt/release_note_prompt.txt
vendored
@@ -1,43 +1,42 @@
|
||||
注意:输出必须严格使用 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 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
|
||||
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
231
.github/scripts/lib/comment.ts
vendored
Normal 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 构建中',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'</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 = '';
|
||||
headerTitle = '# ✅ NapCat 构建成功';
|
||||
} else if (anyCancelled && !anyFailure) {
|
||||
statusBadge = '';
|
||||
headerTitle = '# ⚪ NapCat 构建已取消';
|
||||
} else {
|
||||
statusBadge = '';
|
||||
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
189
.github/scripts/lib/github.ts
vendored
Normal 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
36
.github/scripts/pr-build-building.ts
vendored
Normal 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
206
.github/scripts/pr-build-check.ts
vendored
Normal 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
90
.github/scripts/pr-build-result.ts
vendored
Normal 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
149
.github/scripts/pr-build-run.ts
vendored
Normal 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();
|
||||
78
.github/workflows/auto-release.yml
vendored
78
.github/workflows/auto-release.yml
vendored
@@ -5,6 +5,63 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish-schema:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||
version=${latest_tag#v}
|
||||
echo "version=${version}" >> $GITHUB_ENV
|
||||
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
|
||||
echo "Debug: Version is ${version}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build napcat-schema
|
||||
run: |
|
||||
cd packages/napcat-schema
|
||||
pnpm run build:openapi
|
||||
|
||||
- name: Checkout NapCatDocs
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: NapNeko/NapCatDocs
|
||||
token: ${{ secrets.NAPCAT_BUILD }}
|
||||
path: napcat-docs
|
||||
|
||||
- name: Copy OpenAPI Schema
|
||||
run: |
|
||||
mkdir -p napcat-docs/src/api/${{ env.version }}
|
||||
cp packages/napcat-schema/dist/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
|
||||
echo "OpenAPI schema copied to napcat-docs/src/api/${{ env.version }}/openapi.json"
|
||||
|
||||
- name: Commit and Push
|
||||
run: |
|
||||
cd napcat-docs
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add src/api/${{ env.version }}/openapi.json
|
||||
git commit -m "chore: update OpenAPI schema for version ${{ env.version }}" || echo "No changes to commit"
|
||||
git push
|
||||
|
||||
shell-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -46,8 +103,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,12 +129,25 @@ 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 \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/release.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"
|
||||
- name: Trigger Release NapCat AppImage Workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
NAPCAT_VERSION: ${{ env.latest_tag }}
|
||||
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 \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/docker-publish.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"
|
||||
34
.github/workflows/build.yml
vendored
34
.github/workflows/build.yml
vendored
@@ -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
|
||||
@@ -25,6 +41,7 @@ jobs:
|
||||
pnpm test || exit 1
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:framework
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv packages/napcat-framework/dist framework-dist
|
||||
cd framework-dist
|
||||
npm install --omit=dev
|
||||
@@ -39,11 +56,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
|
||||
@@ -51,6 +84,7 @@ jobs:
|
||||
pnpm test || exit 1
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:shell
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv packages/napcat-shell/dist shell-dist
|
||||
cd shell-dist
|
||||
npm install --omit=dev
|
||||
|
||||
303
.github/workflows/pr-build.yml
vendored
Normal file
303
.github/workflows/pr-build.yml
vendored
Normal 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
|
||||
260
.github/workflows/release.yml
vendored
260
.github/workflows/release.yml
vendored
@@ -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/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,11 +55,14 @@ jobs:
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Framework
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm i -g pnpm
|
||||
pnpm i
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:framework
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv packages/napcat-framework/dist framework-dist
|
||||
cd framework-dist
|
||||
npm install --omit=dev
|
||||
@@ -40,6 +74,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,11 +85,14 @@ jobs:
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Shell
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm i -g pnpm
|
||||
pnpm i
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:shell
|
||||
pnpm --filter napcat-plugin-builtin run build || exit 1
|
||||
mv packages/napcat-shell/dist shell-dist
|
||||
cd shell-dist
|
||||
npm install --omit=dev
|
||||
@@ -86,18 +125,54 @@ jobs:
|
||||
cd "$TMPDIR"
|
||||
|
||||
# -----------------------------
|
||||
# 1) 下载 QQ x64
|
||||
# 1) 下载 QQ x64 (使用缓存)
|
||||
# -----------------------------
|
||||
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
||||
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe"
|
||||
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
|
||||
QQ_ZIP="$(basename "$NT_URL")"
|
||||
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
||||
# 根据 URL 生成缓存键
|
||||
QQ_CACHE_KEY="qq-x64-$(echo "$NT_URL" | md5sum | cut -d' ' -f1)"
|
||||
echo "QQ_CACHE_KEY=$QQ_CACHE_KEY" >> $GITHUB_ENV
|
||||
echo "QQ_ZIP=$QQ_ZIP" >> $GITHUB_ENV
|
||||
echo "NT_URL=$NT_URL" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache QQ x64 Installer
|
||||
id: cache-qq
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/qq-cache
|
||||
key: ${{ env.QQ_CACHE_KEY }}
|
||||
|
||||
- name: Download and Extract QQ x64
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMPDIR=$(mktemp -d)
|
||||
cd "$TMPDIR"
|
||||
|
||||
QQ_CACHE_DIR="$HOME/qq-cache"
|
||||
mkdir -p "$QQ_CACHE_DIR"
|
||||
|
||||
if [ -f "$QQ_CACHE_DIR/$QQ_ZIP" ]; then
|
||||
echo "Using cached QQ installer: $QQ_ZIP"
|
||||
cp "$QQ_CACHE_DIR/$QQ_ZIP" "$QQ_ZIP"
|
||||
else
|
||||
echo "Downloading QQ installer: $QQ_ZIP"
|
||||
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
||||
cp "$QQ_ZIP" "$QQ_CACHE_DIR/$QQ_ZIP"
|
||||
fi
|
||||
|
||||
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
||||
mkdir -p "$QQ_EXTRACT"
|
||||
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
||||
echo "QQ_EXTRACT=$QQ_EXTRACT" >> $GITHUB_ENV
|
||||
echo "WORK_TMPDIR=$TMPDIR" >> $GITHUB_ENV
|
||||
|
||||
- name: Download Node.js and Assemble NapCat.Shell.Windows.Node.zip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$WORK_TMPDIR"
|
||||
|
||||
# -----------------------------
|
||||
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
||||
@@ -107,7 +182,7 @@ jobs:
|
||||
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
||||
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
||||
|
||||
NODE_EXTRACT="$TMPDIR/node_extracted"
|
||||
NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
|
||||
mkdir -p "$NODE_EXTRACT"
|
||||
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
||||
|
||||
@@ -125,11 +200,18 @@ jobs:
|
||||
# -----------------------------
|
||||
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
||||
# -----------------------------
|
||||
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node")
|
||||
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node" "LightQuic.dll")
|
||||
for name in "${QQ_TARGETS[@]}"; do
|
||||
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
||||
done
|
||||
|
||||
# -----------------------------
|
||||
# 5.1) 拷贝 win64 目录下的文件
|
||||
# -----------------------------
|
||||
mkdir -p "$OUT_DIR/win64"
|
||||
find "$QQ_EXTRACT" -ipath "*/win64/SSOShareInfoHelper64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||
find "$QQ_EXTRACT" -ipath "*/win64/parent-ipc-core-x64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||
|
||||
# -----------------------------
|
||||
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
||||
# -----------------------------
|
||||
@@ -139,6 +221,7 @@ jobs:
|
||||
# -----------------------------
|
||||
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
||||
# -----------------------------
|
||||
NODE_VER="22.11.0"
|
||||
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
||||
|
||||
- name: Upload Artifact
|
||||
@@ -161,6 +244,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 +258,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 +286,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 +458,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 +484,5 @@ jobs:
|
||||
NapCat.Shell.Windows.Node.zip
|
||||
NapCat.Framework.zip
|
||||
NapCat.Shell.zip
|
||||
NapCat.Shell.Windows.OneKey.zip
|
||||
draft: true
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ checkVersion.sh
|
||||
bun.lockb
|
||||
tests/run/
|
||||
guild1.db-wal
|
||||
guild1.db-shm
|
||||
guild1.db-shm
|
||||
packages/napcat-develop/config/.env
|
||||
|
||||
@@ -50,7 +50,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.cyou/) | [](https://www.napcat.wiki) |
|
||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
"scripts": {
|
||||
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
|
||||
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
|
||||
"build:shell:config": "pnpm --filter napcat-shell run build && pnpm --filter napcat-develop run copy-env",
|
||||
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
|
||||
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
|
||||
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
|
||||
"dev:shell": "pnpm --filter napcat-develop run dev || exit 1",
|
||||
"typecheck": "pnpm -r --if-present run typecheck",
|
||||
"test": "pnpm --filter napcat-test run test",
|
||||
@@ -28,7 +30,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.0.0",
|
||||
"silk-wasm": "^3.6.1",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
176
packages/napcat-adapter/index.ts
Normal file
176
packages/napcat-adapter/index.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { InstanceContext, NapCatCore } from 'napcat-core';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot';
|
||||
import { NapCatProtocolAdapter } from 'napcat-protocol';
|
||||
|
||||
// 协议适配器类型
|
||||
export type ProtocolAdapterType = 'onebot11' | 'napcat-protocol';
|
||||
|
||||
// 协议适配器接口
|
||||
export interface IProtocolAdapter {
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
init (): Promise<void>;
|
||||
close (): Promise<void>;
|
||||
}
|
||||
|
||||
// 协议适配器包装器
|
||||
class OneBotAdapterWrapper implements IProtocolAdapter {
|
||||
readonly name = 'onebot11';
|
||||
private adapter: NapCatOneBot11Adapter;
|
||||
|
||||
constructor (adapter: NapCatOneBot11Adapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
get enabled (): boolean {
|
||||
return true; // OneBot11 默认启用
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
await this.adapter.InitOneBot();
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
await this.adapter.networkManager.closeAllAdapters();
|
||||
}
|
||||
|
||||
getAdapter (): NapCatOneBot11Adapter {
|
||||
return this.adapter;
|
||||
}
|
||||
}
|
||||
|
||||
// NapCat Protocol 适配器包装器
|
||||
class NapCatProtocolAdapterWrapper implements IProtocolAdapter {
|
||||
readonly name = 'napcat-protocol';
|
||||
private adapter: NapCatProtocolAdapter;
|
||||
|
||||
constructor (adapter: NapCatProtocolAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
get enabled (): boolean {
|
||||
return this.adapter.isEnabled();
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
await this.adapter.initProtocol();
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
await this.adapter.close();
|
||||
}
|
||||
|
||||
getAdapter (): NapCatProtocolAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
}
|
||||
|
||||
// 协议适配器管理器
|
||||
export class NapCatAdapterManager {
|
||||
private core: NapCatCore;
|
||||
private context: InstanceContext;
|
||||
private pathWrapper: NapCatPathWrapper;
|
||||
|
||||
// 协议适配器实例
|
||||
private onebotAdapter: OneBotAdapterWrapper | null = null;
|
||||
private napcatProtocolAdapter: NapCatProtocolAdapterWrapper | null = null;
|
||||
|
||||
// 所有已注册的适配器
|
||||
private adapters: Map<string, IProtocolAdapter> = new Map();
|
||||
|
||||
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||
this.core = core;
|
||||
this.context = context;
|
||||
this.pathWrapper = pathWrapper;
|
||||
}
|
||||
|
||||
// 初始化所有协议适配器
|
||||
async initAdapters (): Promise<void> {
|
||||
this.context.logger.log('[AdapterManager] 开始初始化协议适配器...');
|
||||
|
||||
// 初始化 OneBot11 适配器 (默认启用)
|
||||
try {
|
||||
const onebot = new NapCatOneBot11Adapter(this.core, this.context, this.pathWrapper);
|
||||
this.onebotAdapter = new OneBotAdapterWrapper(onebot);
|
||||
this.adapters.set('onebot11', this.onebotAdapter);
|
||||
await this.onebotAdapter.init();
|
||||
this.context.logger.log('[AdapterManager] OneBot11 适配器初始化完成');
|
||||
} catch (e) {
|
||||
this.context.logger.logError('[AdapterManager] OneBot11 适配器初始化失败:', e);
|
||||
}
|
||||
|
||||
// 初始化 NapCat Protocol 适配器 (默认关闭,需要配置启用)
|
||||
try {
|
||||
const napcatProtocol = new NapCatProtocolAdapter(this.core, this.context, this.pathWrapper);
|
||||
this.napcatProtocolAdapter = new NapCatProtocolAdapterWrapper(napcatProtocol);
|
||||
this.adapters.set('napcat-protocol', this.napcatProtocolAdapter);
|
||||
|
||||
if (this.napcatProtocolAdapter.enabled) {
|
||||
await this.napcatProtocolAdapter.init();
|
||||
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器初始化完成');
|
||||
} else {
|
||||
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器未启用,跳过初始化');
|
||||
}
|
||||
} catch (e) {
|
||||
this.context.logger.logError('[AdapterManager] NapCat Protocol 适配器初始化失败:', e);
|
||||
}
|
||||
|
||||
this.context.logger.log(`[AdapterManager] 协议适配器初始化完成,已加载 ${this.adapters.size} 个适配器`);
|
||||
}
|
||||
|
||||
// 获取 OneBot11 适配器
|
||||
getOneBotAdapter (): NapCatOneBot11Adapter | null {
|
||||
return this.onebotAdapter?.getAdapter() ?? null;
|
||||
}
|
||||
|
||||
// 获取 NapCat Protocol 适配器
|
||||
getNapCatProtocolAdapter (): NapCatProtocolAdapter | null {
|
||||
return this.napcatProtocolAdapter?.getAdapter() ?? null;
|
||||
}
|
||||
|
||||
// 获取指定适配器
|
||||
getAdapter (name: ProtocolAdapterType): IProtocolAdapter | undefined {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
// 获取所有已启用的适配器
|
||||
getEnabledAdapters (): IProtocolAdapter[] {
|
||||
return Array.from(this.adapters.values()).filter(adapter => adapter.enabled);
|
||||
}
|
||||
|
||||
// 获取所有适配器
|
||||
getAllAdapters (): IProtocolAdapter[] {
|
||||
return Array.from(this.adapters.values());
|
||||
}
|
||||
|
||||
// 关闭所有适配器
|
||||
async closeAllAdapters (): Promise<void> {
|
||||
this.context.logger.log('[AdapterManager] 开始关闭所有协议适配器...');
|
||||
|
||||
for (const [name, adapter] of this.adapters) {
|
||||
try {
|
||||
await adapter.close();
|
||||
this.context.logger.log(`[AdapterManager] ${name} 适配器已关闭`);
|
||||
} catch (e) {
|
||||
this.context.logger.logError(`[AdapterManager] 关闭 ${name} 适配器失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
this.adapters.clear();
|
||||
this.context.logger.log('[AdapterManager] 所有协议适配器已关闭');
|
||||
}
|
||||
|
||||
// 重新加载指定适配器
|
||||
async reloadAdapter (name: ProtocolAdapterType): Promise<void> {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
await adapter.init();
|
||||
this.context.logger.log(`[AdapterManager] ${name} 适配器已重新加载`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { NapCatOneBot11Adapter } from 'napcat-onebot';
|
||||
export { NapCatProtocolAdapter } from 'napcat-protocol';
|
||||
30
packages/napcat-adapter/package.json
Normal file
30
packages/napcat-adapter/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "napcat-adapter",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,28 @@
|
||||
{
|
||||
"name": "napcat-common",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-common",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./src/*": {
|
||||
"import": "./src/*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.13.0",
|
||||
"file-type": "^21.0.0",
|
||||
"silk-wasm": "^3.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./src/*": {
|
||||
"import": "./src/*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.13.0",
|
||||
"file-type": "^21.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,11 +2,14 @@ import fs from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import crypto, { randomUUID } from 'crypto';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import tls from 'node:tls';
|
||||
import { solveProblem } from '@/napcat-common/src/helper';
|
||||
|
||||
export interface HttpDownloadOptions {
|
||||
url: string;
|
||||
headers?: Record<string, string> | string;
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
type Uri2LocalRes = {
|
||||
@@ -96,6 +99,7 @@ export function calculateFileMD5 (filePath: string): Promise<string> {
|
||||
|
||||
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
|
||||
let url: string;
|
||||
let proxy: string | undefined;
|
||||
let headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
|
||||
};
|
||||
@@ -104,6 +108,7 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
headers['Host'] = new URL(url).hostname;
|
||||
} else {
|
||||
url = options.url;
|
||||
proxy = options.proxy;
|
||||
if (options.headers) {
|
||||
if (typeof options.headers === 'string') {
|
||||
headers = JSON.parse(options.headers);
|
||||
@@ -115,6 +120,18 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
if (useReferer && !headers['Referer']) {
|
||||
headers['Referer'] = url;
|
||||
}
|
||||
|
||||
// 如果配置了代理,使用代理下载
|
||||
if (proxy) {
|
||||
try {
|
||||
const response = await httpDownloadWithProxy(url, headers, proxy);
|
||||
return new Response(response, { status: 200, statusText: 'OK' });
|
||||
} catch (proxyError) {
|
||||
// 如果代理失败,记录错误并尝试直接下载
|
||||
console.error('代理下载失败,尝试直接下载:', proxyError);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
|
||||
if (err.cause) {
|
||||
throw err.cause;
|
||||
@@ -124,6 +141,220 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
return fetchRes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HTTP/HTTPS 代理下载文件
|
||||
*/
|
||||
function httpDownloadWithProxy (url: string, headers: Record<string, string>, proxy: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const targetUrl = new URL(url);
|
||||
const proxyUrl = new URL(proxy);
|
||||
|
||||
const isTargetHttps = targetUrl.protocol === 'https:';
|
||||
const proxyPort = parseInt(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80);
|
||||
|
||||
// 代理认证头
|
||||
const proxyAuthHeader = proxyUrl.username && proxyUrl.password
|
||||
? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString('base64') }
|
||||
: {};
|
||||
|
||||
if (isTargetHttps) {
|
||||
// HTTPS 目标:需要通过 CONNECT 建立隧道
|
||||
const connectReq = http.request({
|
||||
host: proxyUrl.hostname,
|
||||
port: proxyPort,
|
||||
method: 'CONNECT',
|
||||
path: `${targetUrl.hostname}:${targetUrl.port || 443}`,
|
||||
headers: {
|
||||
'Host': `${targetUrl.hostname}:${targetUrl.port || 443}`,
|
||||
...proxyAuthHeader,
|
||||
},
|
||||
});
|
||||
|
||||
connectReq.on('connect', (res, socket) => {
|
||||
if (res.statusCode !== 200) {
|
||||
socket.destroy();
|
||||
reject(new Error(`代理 CONNECT 失败: ${res.statusCode} ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 在隧道上建立 TLS 连接
|
||||
const tlsSocket = tls.connect({
|
||||
socket: socket,
|
||||
servername: targetUrl.hostname,
|
||||
rejectUnauthorized: true,
|
||||
}, () => {
|
||||
// TLS 握手成功,发送 HTTP 请求
|
||||
const requestPath = targetUrl.pathname + targetUrl.search;
|
||||
const requestHeaders = {
|
||||
...headers,
|
||||
'Host': targetUrl.hostname,
|
||||
'Connection': 'close',
|
||||
};
|
||||
|
||||
const headerLines = Object.entries(requestHeaders)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\r\n');
|
||||
|
||||
const httpRequest = `GET ${requestPath} HTTP/1.1\r\n${headerLines}\r\n\r\n`;
|
||||
tlsSocket.write(httpRequest);
|
||||
});
|
||||
|
||||
// 解析 HTTP 响应
|
||||
let responseData = Buffer.alloc(0);
|
||||
let headersParsed = false;
|
||||
let statusCode = 0;
|
||||
let isChunked = false;
|
||||
let bodyData = Buffer.alloc(0);
|
||||
let redirectLocation: string | null = null;
|
||||
|
||||
tlsSocket.on('data', (chunk: Buffer) => {
|
||||
responseData = Buffer.concat([responseData, chunk]);
|
||||
|
||||
if (!headersParsed) {
|
||||
const headerEndIndex = responseData.indexOf('\r\n\r\n');
|
||||
if (headerEndIndex !== -1) {
|
||||
headersParsed = true;
|
||||
const headerStr = responseData.subarray(0, headerEndIndex).toString();
|
||||
const headerLines = headerStr.split('\r\n');
|
||||
|
||||
// 解析状态码
|
||||
const statusLine = headerLines[0];
|
||||
const statusMatch = statusLine?.match(/HTTP\/\d\.\d\s+(\d+)/);
|
||||
statusCode = statusMatch ? parseInt(statusMatch[1]!) : 0;
|
||||
|
||||
// 解析响应头
|
||||
for (const line of headerLines.slice(1)) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key?.toLowerCase() === 'transfer-encoding' && value.toLowerCase() === 'chunked') {
|
||||
isChunked = true;
|
||||
} else if (key?.toLowerCase() === 'location') {
|
||||
redirectLocation = value;
|
||||
}
|
||||
}
|
||||
|
||||
bodyData = responseData.subarray(headerEndIndex + 4);
|
||||
}
|
||||
} else {
|
||||
bodyData = Buffer.concat([bodyData, chunk]);
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('end', () => {
|
||||
// 处理重定向
|
||||
if (statusCode >= 300 && statusCode < 400 && redirectLocation) {
|
||||
const redirectUrl = redirectLocation.startsWith('http')
|
||||
? redirectLocation
|
||||
: `${targetUrl.protocol}//${targetUrl.host}${redirectLocation}`;
|
||||
httpDownloadWithProxy(redirectUrl, headers, proxy).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
reject(new Error(`下载失败: ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 chunked 编码
|
||||
if (isChunked) {
|
||||
resolve(parseChunkedBody(bodyData));
|
||||
} else {
|
||||
resolve(bodyData);
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('error', (err) => {
|
||||
reject(new Error(`TLS 连接错误: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
connectReq.on('error', (err) => {
|
||||
reject(new Error(`代理连接错误: ${err.message}`));
|
||||
});
|
||||
|
||||
connectReq.end();
|
||||
} else {
|
||||
// HTTP 目标:直接通过代理请求
|
||||
const req = http.request({
|
||||
host: proxyUrl.hostname,
|
||||
port: proxyPort,
|
||||
method: 'GET',
|
||||
path: url, // 完整 URL
|
||||
headers: {
|
||||
...headers,
|
||||
'Host': targetUrl.hostname,
|
||||
...proxyAuthHeader,
|
||||
},
|
||||
}, (response) => {
|
||||
handleResponse(response, resolve, reject, url, headers, proxy);
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`代理请求错误: ${err.message}`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 chunked 编码的响应体
|
||||
*/
|
||||
function parseChunkedBody (data: Buffer): Buffer {
|
||||
const chunks: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < data.length) {
|
||||
// 查找 chunk 大小行的结束
|
||||
const lineEnd = data.indexOf('\r\n', offset);
|
||||
if (lineEnd === -1) break;
|
||||
|
||||
const sizeStr = data.subarray(offset, lineEnd).toString().split(';')[0]; // 忽略 chunk 扩展
|
||||
const chunkSize = parseInt(sizeStr!, 16);
|
||||
|
||||
if (chunkSize === 0) break; // 最后一个 chunk
|
||||
|
||||
const chunkStart = lineEnd + 2;
|
||||
const chunkEnd = chunkStart + chunkSize;
|
||||
|
||||
if (chunkEnd > data.length) break;
|
||||
|
||||
chunks.push(data.subarray(chunkStart, chunkEnd));
|
||||
offset = chunkEnd + 2; // 跳过 chunk 数据后的 \r\n
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 响应
|
||||
*/
|
||||
function handleResponse (
|
||||
response: http.IncomingMessage,
|
||||
resolve: (value: Buffer) => void,
|
||||
reject: (reason: Error) => void,
|
||||
_url: string,
|
||||
headers: Record<string, string>,
|
||||
proxy: string
|
||||
): void {
|
||||
// 处理重定向
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||
httpDownloadWithProxy(response.headers.location, headers, proxy).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`下载失败: ${response.statusCode} ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
response.on('error', reject);
|
||||
}
|
||||
|
||||
export async function httpDownload (options: string | HttpDownloadOptions): Promise<Buffer> {
|
||||
const useReferer = typeof options === 'string';
|
||||
let resp = await tryDownload(options);
|
||||
@@ -176,7 +407,7 @@ export async function checkUriType (Uri: string) {
|
||||
return { Uri, Type: FileUriType.Unknown };
|
||||
}
|
||||
|
||||
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> {
|
||||
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>, proxy?: string): Promise<Uri2LocalRes> {
|
||||
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
|
||||
|
||||
const filePath = path.join(dir, filename);
|
||||
@@ -191,7 +422,7 @@ export async function uriToLocalFile (dir: string, uri: string, filename: string
|
||||
}
|
||||
|
||||
case FileUriType.Remote: {
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {}, proxy });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
@@ -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 (mirror?: string): Promise<{ tags: string[], mirror: string; }> {
|
||||
return getAllTagsFromMirror('NapNeko', 'NapCatQQ', mirror);
|
||||
}
|
||||
|
||||
|
||||
export async function getLatestTag (): Promise<string> {
|
||||
const tags = await getAllTags();
|
||||
export async function getLatestTag (mirror?: string): Promise<string> {
|
||||
const { tags } = await getAllTags(mirror);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -58,12 +58,12 @@ export class LimitedHashTable<K, V> {
|
||||
}
|
||||
|
||||
// 获取最近刚写入的几个值
|
||||
getHeads (size: number): { key: K; value: V }[] | undefined {
|
||||
getHeads (size: number): { key: K; value: V; }[] | undefined {
|
||||
const keyList = this.getKeyList();
|
||||
if (keyList.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const result: { key: K; value: V }[] = [];
|
||||
const result: { key: K; value: V; }[] = [];
|
||||
const listSize = Math.min(size, keyList.length);
|
||||
for (let i = 0; i < listSize; i++) {
|
||||
const key = keyList[listSize - i];
|
||||
@@ -108,7 +108,7 @@ class MessageUniqueWrapper {
|
||||
return shortId;
|
||||
}
|
||||
|
||||
getMsgIdAndPeerByShortId (shortId: number): { MsgId: string; Peer: Peer } | undefined {
|
||||
getMsgIdAndPeerByShortId (shortId: number): { MsgId: string; Peer: Peer; } | undefined {
|
||||
const data = this.msgDataMap.getKey(shortId);
|
||||
if (data) {
|
||||
const [msgId, chatTypeStr, peerUid] = data.split('|');
|
||||
@@ -136,6 +136,12 @@ class MessageUniqueWrapper {
|
||||
this.msgIdMap.resize(maxSize);
|
||||
this.msgDataMap.resize(maxSize);
|
||||
}
|
||||
|
||||
isShortId (message_id: string): boolean {
|
||||
const num = Number(message_id);
|
||||
// 判断是否是整数并且在 INT32 的范围内
|
||||
return Number.isInteger(num) && num >= -2147483648 && num <= 2147483647;
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageUnique: MessageUniqueWrapper = new MessageUniqueWrapper();
|
||||
|
||||
1095
packages/napcat-common/src/mirror.ts
Normal file
1095
packages/napcat-common/src/mirror.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ export interface IStatusHelperSubscription {
|
||||
on (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
|
||||
off (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
|
||||
emit (event: 'statusUpdate', status: SystemStatus): boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,120 @@
|
||||
// @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 v1.localeCompare(v2) as -1 | 0 | 1;
|
||||
}
|
||||
if (!a.valid) return -1;
|
||||
if (!b.valid) return 1;
|
||||
|
||||
// 比较主版本号
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
307
packages/napcat-core/apis/flash.ts
Normal file
307
packages/napcat-core/apis/flash.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { GeneralCallResult, InstanceContext, NapCatCore } from '@/napcat-core';
|
||||
import {
|
||||
createFlashTransferResult,
|
||||
FileListResponse,
|
||||
FlashFileSetInfo,
|
||||
SendStatus,
|
||||
UploadSceneType,
|
||||
} from '@/napcat-core/data/flash';
|
||||
import { Peer } from '@/napcat-core/types';
|
||||
|
||||
export class NTQQFlashApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
|
||||
constructor (context: InstanceContext, core: NapCatCore) {
|
||||
this.context = context;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起闪传上传任务
|
||||
* @param fileListToUpload 上传文件绝对路径的列表,可以是文件夹!!
|
||||
* @param thumbnailPath
|
||||
* @param filesetName
|
||||
*/
|
||||
async createFlashTransferUploadTask (fileListToUpload: string[], thumbnailPath: string, filesetName: string): Promise<GeneralCallResult & {
|
||||
createFlashTransferResult: createFlashTransferResult;
|
||||
seq: number;
|
||||
}> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const timestamp: number = Date.now();
|
||||
const selfInfo = this.core.selfInfo;
|
||||
|
||||
const fileUploadArg = {
|
||||
screen: 1, // 1
|
||||
name: filesetName,
|
||||
uploaders: [{
|
||||
uin: selfInfo.uin,
|
||||
uid: selfInfo.uid,
|
||||
sendEntrance: '',
|
||||
nickname: selfInfo.nick,
|
||||
}],
|
||||
coverPath: thumbnailPath,
|
||||
paths: fileListToUpload,
|
||||
excludePaths: [],
|
||||
expireLeftTime: 0,
|
||||
isNeedDelDeviceInfo: false,
|
||||
isNeedDelLocation: false,
|
||||
coverOriginalInfos: [
|
||||
{
|
||||
path: fileListToUpload[0] || '',
|
||||
thumbnailPath,
|
||||
},
|
||||
],
|
||||
uploadSceneType: UploadSceneType.KUPLOADSCENEAIOFILESELECTOR, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
|
||||
detectPrivacyInfoResult: {
|
||||
exists: false,
|
||||
allDetectResults: new Map(),
|
||||
},
|
||||
};
|
||||
|
||||
const uploadResult = await flashService.createFlashTransferUploadTask(timestamp, fileUploadArg);
|
||||
if (uploadResult.result === 0) {
|
||||
this.context.logger.log('[Flash] 发起闪传任务成功');
|
||||
return uploadResult;
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 发起闪传上传任务失败!!');
|
||||
return uploadResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载闪传文件集
|
||||
* @param fileSetId
|
||||
*/
|
||||
async downloadFileSetBySetId (fileSetId: string): Promise<GeneralCallResult & {
|
||||
extraInfo: unknown;
|
||||
}> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const result = await flashService.startFileSetDownload(fileSetId, 1, { isIncludeCompressInnerFiles: false }); // 为了方便,暂时硬编码
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('[Flash] 成功开始下载文件集');
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 尝试下载文件集失败!');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取闪传的外链分享
|
||||
* @param fileSetId
|
||||
*/
|
||||
async getShareLinkBySetId (fileSetId: string): Promise<GeneralCallResult & {
|
||||
shareLink: string;
|
||||
expireTimestamp: string;
|
||||
}> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const result = await flashService.getShareLinkReq(fileSetId);
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('[Flash] 获取闪传外链分享成功:', result.shareLink);
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 获取闪传外链失败!!');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从分享外链获取文件集id
|
||||
* @param shareCode
|
||||
*/
|
||||
async fromShareLinkFindSetId (shareCode: string): Promise<GeneralCallResult & {
|
||||
fileSetId: string;
|
||||
}> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const result = await flashService.getFileSetIdByCode(shareCode);
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('[Flash] 获取shareCode的文件集Id成功!');
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 获取文件集ID失败!!');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取fileSet的文件结构信息 (未来可能需要深度遍历)
|
||||
* == 注意返回结构和其它的不同,没有GeneralCallResult!!! ==
|
||||
* @param fileSetId
|
||||
*/
|
||||
async getFileListBySetId (fileSetId: string): Promise<FileListResponse> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const requestArg = {
|
||||
seq: 0,
|
||||
fileSetId,
|
||||
isUseCache: false,
|
||||
sceneType: 1, // 硬编码
|
||||
reqInfos: [
|
||||
{
|
||||
count: 18, // 18 ??
|
||||
paginationInfo: {},
|
||||
parentId: '',
|
||||
reqIndexPath: '',
|
||||
reqDepth: 1,
|
||||
filterCondition: {
|
||||
fileCategory: 0,
|
||||
filterType: 0,
|
||||
},
|
||||
sortConditions: [
|
||||
{
|
||||
sortField: 0,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
isNeedPhysicalInfoReady: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await flashService.getFileList(requestArg);
|
||||
if (result.rsp.result === 0) {
|
||||
this.context.logger.log('[Flash] 获取fileSet文件信息成功!');
|
||||
return result.rsp;
|
||||
} else {
|
||||
this.context.logger.logError(`[Flash] 获取文件信息失败:ErrMsg: ${result.rsp.errMs}`);
|
||||
return result.rsp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取闪传文件集合信息
|
||||
* @param fileSetId
|
||||
*/
|
||||
async getFileSetIndoBySetId (fileSetId: string): Promise<GeneralCallResult & {
|
||||
seq: number;
|
||||
isCache: boolean;
|
||||
fileSet: FlashFileSetInfo;
|
||||
}> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const requestArg = {
|
||||
fileSetId,
|
||||
};
|
||||
|
||||
const result = await flashService.getFileSet(requestArg);
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('[Flash] 获取闪传文件集信息成功!');
|
||||
} else {
|
||||
this.context.logger.logError('[Flash] 获取闪传文件信息失败!!');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送闪传消息(私聊/群聊)
|
||||
* @param fileSetId
|
||||
* @param peer
|
||||
*/
|
||||
async sendFlashMessage (fileSetId: string, peer: Peer): Promise<{
|
||||
errCode: number,
|
||||
errMsg: string,
|
||||
rsp: {
|
||||
sendStatus: SendStatus[];
|
||||
};
|
||||
}> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
|
||||
const target = {
|
||||
destUid: peer.peerUid,
|
||||
destType: peer.chatType,
|
||||
// destUin: peer.peerUin,
|
||||
};
|
||||
|
||||
const requestsArg = {
|
||||
fileSetId,
|
||||
targets: [target],
|
||||
};
|
||||
|
||||
const result = await flashService.sendFlashTransferMsg(requestsArg);
|
||||
if (result.errCode === 0) {
|
||||
this.context.logger.log('[Flash] 消息发送成功');
|
||||
} else {
|
||||
this.context.logger.logError(`[Flash] 消息发送失败!!原因:${result.errMsg}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取闪传文件集中某个文件的下载URL(外链)
|
||||
* @param fileSetId
|
||||
* @param options
|
||||
*/
|
||||
async getFileTransUrl (fileSetId: string, options: { fileName?: string; fileIndex?: number; }): Promise<GeneralCallResult & {
|
||||
transferUrl: string;
|
||||
}> {
|
||||
const flashService = this.context.session.getFlashTransferService();
|
||||
const result = await this.getFileListBySetId(fileSetId);
|
||||
|
||||
const { fileName, fileIndex } = options;
|
||||
|
||||
let targetFile: any;
|
||||
let file: any;
|
||||
|
||||
const allFolder = result.fileLists;
|
||||
|
||||
// eslint-disable-next-line no-labels
|
||||
searchLoop: for (const folder of allFolder) {
|
||||
const fileList = folder.fileList;
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
file = fileList[i];
|
||||
|
||||
if (fileName !== undefined && file.name === fileName) {
|
||||
targetFile = file;
|
||||
// eslint-disable-next-line no-labels
|
||||
break searchLoop;
|
||||
}
|
||||
|
||||
if (fileIndex !== undefined && i === fileIndex) {
|
||||
targetFile = file;
|
||||
// eslint-disable-next-line no-labels
|
||||
break searchLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetFile === undefined) {
|
||||
this.context.logger.logError('[Flash] 未找到对应文件!!');
|
||||
return {
|
||||
result: -1,
|
||||
errMsg: '未找到对应文件',
|
||||
transferUrl: '',
|
||||
};
|
||||
} else {
|
||||
this.context.logger.log('[Flash] 找到对应文件,准备尝试获取传输链接');
|
||||
const res = await flashService.startFileTransferUrl(targetFile);
|
||||
return {
|
||||
result: 0,
|
||||
errMsg: '',
|
||||
transferUrl: res.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async createFileThumbnail (filePath: string): Promise<any> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const savePath = msgService.getFileThumbSavePathForSend(750, true);
|
||||
|
||||
const result = await this.core.util.createThumbnailImage(
|
||||
'flashtransfer',
|
||||
filePath,
|
||||
savePath,
|
||||
{
|
||||
width: 520,
|
||||
height: 520,
|
||||
},
|
||||
'jpeg',
|
||||
null
|
||||
);
|
||||
if (result.result === 0) {
|
||||
this.context.logger.log('获取缩略图成功!!');
|
||||
result.targetPath = savePath;
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export class NTQQFriendApi {
|
||||
async getBuddyV2SimpleInfoMap () {
|
||||
const buddyService = this.context.session.getBuddyService();
|
||||
let uids: string[] = [];
|
||||
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
|
||||
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
|
||||
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
|
||||
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
|
||||
} else {
|
||||
@@ -43,7 +43,7 @@ export class NTQQFriendApi {
|
||||
return retMap;
|
||||
}
|
||||
|
||||
async delBuudy (uid: string, tempBlock = false, tempBothDel = false) {
|
||||
async delBuddy (uid: string, tempBlock = false, tempBothDel = false) {
|
||||
return this.context.session.getBuddyService().delBuddy({
|
||||
friendUid: uid,
|
||||
tempBlock,
|
||||
@@ -55,7 +55,7 @@ export class NTQQFriendApi {
|
||||
const buddyService = this.context.session.getBuddyService();
|
||||
let uids: string[] = [];
|
||||
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
|
||||
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
|
||||
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
|
||||
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
|
||||
uids = buddyListV2.flatMap(item => item.buddyUids);
|
||||
} else {
|
||||
|
||||
@@ -7,3 +7,5 @@ export * from './webapi';
|
||||
export * from './system';
|
||||
export * from './packet';
|
||||
export * from './file';
|
||||
export * from './online';
|
||||
export * from './flash';
|
||||
|
||||
@@ -32,9 +32,9 @@ export class NTQQMsgApi {
|
||||
return this.context.session.getMsgService().getSourceOfReplyMsgV2(peer, clientSeq, time);
|
||||
}
|
||||
|
||||
async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, count: number = 20) {
|
||||
async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, cookie: string = '', count: number = 20) {
|
||||
// 注意此处emojiType 可选值一般为1-2 2好像是unicode表情dec值 大部分情况 Taged Mlikiowa
|
||||
return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, '', false, count);
|
||||
return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, cookie, false, count);
|
||||
}
|
||||
|
||||
async setEmojiLike (peer: Peer, msgSeq: string, emojiId: string, set: boolean = true) {
|
||||
|
||||
241
packages/napcat-core/apis/online.ts
Normal file
241
packages/napcat-core/apis/online.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { InstanceContext, NapCatCore } from '@/napcat-core';
|
||||
import { Peer } from '@/napcat-core/types';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { GeneralCallResultStatus } from '@/napcat-core/services/common';
|
||||
import { sleep } from '@/napcat-common/src/helper';
|
||||
|
||||
const normalizePath = (p: string) => path.normalize(p).toLowerCase();
|
||||
|
||||
export class NTQQOnlineApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
|
||||
constructor (context: InstanceContext, core: NapCatCore) {
|
||||
this.context = context;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这里不等待node返回,因为the fuck wrapper.node 根本不返回(会卡死不知道为什么)!!! 只能手动查询判断死活
|
||||
* @param peer
|
||||
* @param filePath
|
||||
* @param fileName
|
||||
*/
|
||||
async sendOnlineFile (peer: Peer, filePath: string, fileName: string): Promise<any> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`[NapCat] 文件不存在: ${filePath}`);
|
||||
}
|
||||
const actualFileName = fileName || path.basename(filePath);
|
||||
const fileSize = fs.statSync(filePath).size.toString();
|
||||
|
||||
const fileElementToSend = [{
|
||||
elementType: 23,
|
||||
elementId: '',
|
||||
fileElement: {
|
||||
fileName: actualFileName,
|
||||
filePath,
|
||||
fileSize,
|
||||
},
|
||||
}];
|
||||
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const startTime = Math.floor(Date.now() / 1000) - 2; // 容错时间窗口
|
||||
|
||||
msgService.sendMsg('0', peer, fileElementToSend, new Map()).catch((_e: any) => {
|
||||
});
|
||||
|
||||
const maxRetries = 10;
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
await sleep(1000);
|
||||
retryCount++;
|
||||
|
||||
try {
|
||||
const msgListResult = await msgService.getOnlineFileMsgs(peer);
|
||||
|
||||
const msgs = msgListResult?.msgList || [];
|
||||
|
||||
const foundMsg = msgs.find((msg: any) => {
|
||||
if (parseInt(msg.msgTime) < startTime) return false;
|
||||
|
||||
const validElement = msg.elements.find((el: any) => {
|
||||
if (el.elementType !== 23 || !el.fileElement) return false;
|
||||
|
||||
const isNameMatch = el.fileElement.fileName === actualFileName;
|
||||
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(filePath);
|
||||
|
||||
return isNameMatch && isPathMatch;
|
||||
});
|
||||
|
||||
return !!validElement;
|
||||
});
|
||||
|
||||
if (foundMsg) {
|
||||
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 23);
|
||||
this.context.logger.log('[OnlineFile] 在线文件发送成功!');
|
||||
return {
|
||||
result: GeneralCallResultStatus.OK,
|
||||
errMsg: '',
|
||||
msgId: foundMsg.msgId,
|
||||
elementId: targetElement?.elementId || '',
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
}
|
||||
}
|
||||
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
|
||||
return {
|
||||
result: GeneralCallResultStatus.ERROR,
|
||||
errMsg: '[NapCat] Send Online File Timeout: Message not found in history.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送在线文件夹
|
||||
* @param peer
|
||||
* @param folderPath
|
||||
* @param folderName
|
||||
*/
|
||||
async sendOnlineFolder (peer: Peer, folderPath: string, folderName?: string): Promise<any> {
|
||||
const actualFolderName = folderName || path.basename(folderPath);
|
||||
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
return { result: GeneralCallResultStatus.ERROR, errMsg: `Folder not found: ${folderPath}` };
|
||||
}
|
||||
|
||||
if (!fs.statSync(folderPath).isDirectory()) {
|
||||
return { result: GeneralCallResultStatus.ERROR, errMsg: `Path is not a directory: ${folderPath}` };
|
||||
}
|
||||
const folderElementItem = {
|
||||
elementType: 30,
|
||||
elementId: '',
|
||||
fileElement: {
|
||||
fileName: actualFolderName,
|
||||
filePath: folderPath,
|
||||
fileSize: "",
|
||||
},
|
||||
};
|
||||
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const startTime = Math.floor(Date.now() / 1000) - 2;
|
||||
msgService.sendMsg('0', peer, [folderElementItem], new Map()).catch((_e: any) => {
|
||||
|
||||
});
|
||||
|
||||
const maxRetries = 10;
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
await sleep(1000);
|
||||
retryCount++;
|
||||
|
||||
try {
|
||||
const msgListResult = await msgService.getOnlineFileMsgs(peer);
|
||||
const msgs = msgListResult?.msgList || [];
|
||||
|
||||
const foundMsg = msgs.find((msg: any) => {
|
||||
if (parseInt(msg.msgTime) < startTime) return false;
|
||||
|
||||
const validElement = msg.elements.find((el: any) => {
|
||||
if (el.elementType !== 30 || !el.fileElement) return false;
|
||||
|
||||
const isNameMatch = el.fileElement.fileName === actualFolderName;
|
||||
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(folderPath);
|
||||
|
||||
return isNameMatch && isPathMatch;
|
||||
});
|
||||
return !!validElement;
|
||||
});
|
||||
|
||||
if (foundMsg) {
|
||||
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 30);
|
||||
this.context.logger.log('[OnlineFile] 在线文件夹发送成功!');
|
||||
return {
|
||||
result: GeneralCallResultStatus.OK,
|
||||
errMsg: '',
|
||||
msgId: foundMsg.msgId,
|
||||
elementId: targetElement?.elementId || '',
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
|
||||
}
|
||||
}
|
||||
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
|
||||
return {
|
||||
result: GeneralCallResultStatus.ERROR,
|
||||
errMsg: '[NapCat] Send Online Folder Timeout: Message not found in history.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取好友的在线文件消息
|
||||
* @param peer
|
||||
*/
|
||||
async getOnlineFileMsg (peer: Peer): Promise<any> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
return await msgService.getOnlineFileMsgs(peer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消在线文件的发送
|
||||
* @param peer
|
||||
* @param msgId
|
||||
*/
|
||||
async cancelMyOnlineFileMsg (peer: Peer, msgId: string): Promise<void> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
await msgService.cancelSendMsg(peer, msgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝接收在线文件
|
||||
* @param peer
|
||||
* @param msgId
|
||||
* @param elementId
|
||||
*/
|
||||
async refuseOnlineFileMsg (peer: Peer, msgId: string, elementId: string): Promise<void> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const arrToSend = {
|
||||
msgId,
|
||||
peerUid: peer.peerUid,
|
||||
chatType: 1,
|
||||
elementId,
|
||||
downloadType: 1,
|
||||
downSourceType: 1,
|
||||
};
|
||||
|
||||
await msgService.refuseGetRichMediaElement(arrToSend);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收在线文件/文件夹
|
||||
* @param peer
|
||||
* @param msgId
|
||||
* @param elementId
|
||||
* @constructor
|
||||
*/
|
||||
async receiveOnlineFileOrFolder (peer: Peer, msgId: string, elementId: string): Promise<any> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
const arrToSend = {
|
||||
msgId,
|
||||
peerUid: peer.peerUid,
|
||||
chatType: 1,
|
||||
elementId,
|
||||
downSourceType: 1,
|
||||
downloadType: 1,
|
||||
};
|
||||
return await msgService.getRichMediaElement(arrToSend);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线文件/文件夹转离线
|
||||
* @param peer
|
||||
* @param msgId
|
||||
*/
|
||||
async switchFileToOffline (peer: Peer, msgId: string): Promise<void> {
|
||||
const msgService = this.context.session.getMsgService();
|
||||
await msgService.switchToOfflineSendMsg(peer, msgId);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,17 @@ import { createHash } from 'node:crypto';
|
||||
import { basename } from 'node:path';
|
||||
import { qunAlbumControl } from '../data/webapi';
|
||||
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
|
||||
export interface SetNoticeRetSuccess {
|
||||
ec: number;
|
||||
em: string;
|
||||
id: number;
|
||||
ltsm: number;
|
||||
new_fid: string;
|
||||
read_only: number;
|
||||
role: number;
|
||||
srv_code: number;
|
||||
}
|
||||
|
||||
export class NTQQWebApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
@@ -25,12 +36,12 @@ export class NTQQWebApi {
|
||||
async shareDigest (groupCode: string, msgSeq: string, msgRandom: string, targetGroupCode: string) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const url = `https://qun.qq.com/cgi-bin/group_digest/share_digest?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
group_code: groupCode,
|
||||
msg_seq: msgSeq,
|
||||
msg_random: msgRandom,
|
||||
target_group_code: targetGroupCode,
|
||||
}).toString()}`;
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
group_code: groupCode,
|
||||
msg_seq: msgSeq,
|
||||
msg_random: msgRandom,
|
||||
target_group_code: targetGroupCode,
|
||||
}).toString()}`;
|
||||
try {
|
||||
return RequestUtil.HttpGetText(url, 'GET', '', { Cookie: this.cookieToString(cookieObject) });
|
||||
} catch {
|
||||
@@ -52,11 +63,11 @@ export class NTQQWebApi {
|
||||
async getGroupEssenceMsg (GroupCode: string, page_start: number = 0, page_limit: number = 50) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
page_start: page_start.toString(),
|
||||
page_limit: page_limit.toString(),
|
||||
group_code: GroupCode,
|
||||
}).toString()}`;
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
page_start: page_start.toString(),
|
||||
page_limit: page_limit.toString(),
|
||||
group_code: GroupCode,
|
||||
}).toString()}`;
|
||||
try {
|
||||
const ret = await RequestUtil.HttpGetJson<GroupEssenceMsgRet>(
|
||||
url,
|
||||
@@ -76,16 +87,16 @@ export class NTQQWebApi {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const retList: Promise<WebApiGroupMemberRet>[] = [];
|
||||
const fastRet = await RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: '0',
|
||||
end: '40',
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: '0',
|
||||
end: '40',
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
if (!fastRet?.count || fastRet?.errcode !== 0 || !fastRet?.mems) {
|
||||
return [];
|
||||
@@ -101,16 +112,16 @@ export class NTQQWebApi {
|
||||
// 遍历批量请求
|
||||
for (let i = 2; i <= PageNum; i++) {
|
||||
const ret = RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: ((i - 1) * 40).toString(),
|
||||
end: (i * 40).toString(),
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: ((i - 1) * 40).toString(),
|
||||
end: (i * 40).toString(),
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
retList.push(ret);
|
||||
}
|
||||
@@ -153,16 +164,7 @@ export class NTQQWebApi {
|
||||
imgWidth: number = 540,
|
||||
imgHeight: number = 300
|
||||
) {
|
||||
interface SetNoticeRetSuccess {
|
||||
ec: number;
|
||||
em: string;
|
||||
id: number;
|
||||
ltsm: number;
|
||||
new_fid: string;
|
||||
read_only: number;
|
||||
role: number;
|
||||
srv_code: number;
|
||||
}
|
||||
|
||||
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
|
||||
@@ -178,18 +180,18 @@ export class NTQQWebApi {
|
||||
imgHeight: imgHeight.toString(),
|
||||
};
|
||||
const ret: SetNoticeRetSuccess = await RequestUtil.HttpGetJson<SetNoticeRetSuccess>(
|
||||
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
text: Content,
|
||||
pinned: pinned.toString(),
|
||||
type: type.toString(),
|
||||
settings,
|
||||
...(picId === '' ? {} : externalParam),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
text: Content,
|
||||
pinned: pinned.toString(),
|
||||
type: type.toString(),
|
||||
settings,
|
||||
...(picId === '' ? {} : externalParam),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
return ret;
|
||||
} catch {
|
||||
@@ -201,20 +203,20 @@ export class NTQQWebApi {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
try {
|
||||
const ret = await RequestUtil.HttpGetJson<WebApiGroupNoticeRet>(
|
||||
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
ft: '23',
|
||||
ni: '1',
|
||||
n: '1',
|
||||
i: '1',
|
||||
log_read: '1',
|
||||
platform: '1',
|
||||
s: '-1',
|
||||
}).toString()}&n=20`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
ft: '23',
|
||||
ni: '1',
|
||||
n: '1',
|
||||
i: '1',
|
||||
log_read: '1',
|
||||
platform: '1',
|
||||
s: '-1',
|
||||
}).toString()}&n=20`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
return ret?.ec === 0 ? ret : undefined;
|
||||
} catch {
|
||||
@@ -222,17 +224,17 @@ export class NTQQWebApi {
|
||||
}
|
||||
}
|
||||
|
||||
private async getDataInternal (cookieObject: { [key: string]: string }, groupCode: string, type: number) {
|
||||
private async getDataInternal (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
|
||||
let resJson;
|
||||
try {
|
||||
const res = await RequestUtil.HttpGetText(
|
||||
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
|
||||
gc: groupCode,
|
||||
type: type.toString(),
|
||||
}).toString()}`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
|
||||
gc: groupCode,
|
||||
type: type.toString(),
|
||||
}).toString()}`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
|
||||
if (match?.[1]) {
|
||||
@@ -245,7 +247,7 @@ export class NTQQWebApi {
|
||||
}
|
||||
}
|
||||
|
||||
private async getHonorList (cookieObject: { [key: string]: string }, groupCode: string, type: number) {
|
||||
private async getHonorList (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
|
||||
const data = await this.getDataInternal(cookieObject, groupCode, type);
|
||||
if (!data) {
|
||||
this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`);
|
||||
@@ -304,11 +306,11 @@ export class NTQQWebApi {
|
||||
return HonorInfo;
|
||||
}
|
||||
|
||||
private cookieToString (cookieObject: { [key: string]: string }) {
|
||||
private cookieToString (cookieObject: { [key: string]: string; }) {
|
||||
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ');
|
||||
}
|
||||
|
||||
public getBknFromCookie (cookieObject: { [key: string]: string }) {
|
||||
public getBknFromCookie (cookieObject: { [key: string]: string; }) {
|
||||
const sKey = cookieObject['skey'] as string;
|
||||
|
||||
let hash = 5381;
|
||||
@@ -361,7 +363,7 @@ export class NTQQWebApi {
|
||||
uin,
|
||||
getMemberRole: '0',
|
||||
});
|
||||
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string }> } }>(api + params.toString(), 'GET', '', {
|
||||
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string; }>; }; }>(api + params.toString(), 'GET', '', {
|
||||
Cookie: cookies,
|
||||
});
|
||||
return response.data.album;
|
||||
@@ -384,7 +386,7 @@ export class NTQQWebApi {
|
||||
sAlbumID,
|
||||
});
|
||||
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
|
||||
const post = await RequestUtil.HttpGetJson<{ data: { session: string }, ret: number, msg: string }>(api, 'POST', body, {
|
||||
const post = await RequestUtil.HttpGetJson<{ data: { session: string; }, ret: number, msg: string; }>(api, 'POST', body, {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
@@ -430,7 +432,7 @@ export class NTQQWebApi {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const post = await response.json() as { ret: number, msg: string }; if (post.ret !== 0) {
|
||||
const post = await response.json() as { ret: number, msg: string; }; if (post.ret !== 0) {
|
||||
throw new Error(`分片 ${seq} 上传失败: ${post.msg}`);
|
||||
}
|
||||
offset += chunk.length;
|
||||
@@ -475,10 +477,10 @@ export class NTQQWebApi {
|
||||
const client_key = Date.now() * 1000;
|
||||
return await this.context.session.getAlbumService().doQunComment(
|
||||
random_seq, {
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
},
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
},
|
||||
qunId,
|
||||
2,
|
||||
createAlbumMediaFeed(uin, albumId, lloc),
|
||||
@@ -509,13 +511,13 @@ export class NTQQWebApi {
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
return await this.context.session.getAlbumService().doQunLike(
|
||||
random_seq, {
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
}, {
|
||||
id,
|
||||
status: 1,
|
||||
},
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
}, {
|
||||
id,
|
||||
status: 1,
|
||||
},
|
||||
createAlbumFeedPublish(qunId, uin, albumId, lloc)
|
||||
);
|
||||
}
|
||||
|
||||
358
packages/napcat-core/data/flash.ts
Normal file
358
packages/napcat-core/data/flash.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
export interface FlashBaseRequest {
|
||||
fileSetId: string;
|
||||
}
|
||||
|
||||
export interface UploaderInfo {
|
||||
uin: string,
|
||||
nickname: string,
|
||||
uid: string,
|
||||
sendEntrance: string, // ""
|
||||
}
|
||||
|
||||
export interface thumbnailInfo {
|
||||
id: string,
|
||||
url: {
|
||||
spec: number,
|
||||
uri: string,
|
||||
}[],
|
||||
localCachePath: string,
|
||||
}
|
||||
|
||||
export interface SendTarget {
|
||||
destType: number; // 1私聊
|
||||
destUin?: string,
|
||||
destUid: string,
|
||||
}
|
||||
|
||||
export interface SendTargetRequests {
|
||||
fileSetId: string;
|
||||
targets: SendTarget[];
|
||||
}
|
||||
|
||||
export interface DownloadStatusInfo {
|
||||
result: number; // 0
|
||||
fileSetId: string;
|
||||
status: number;
|
||||
info: {
|
||||
curDownLoadFailFileNum: number,
|
||||
curDownLoadedPauseFileNum: number,
|
||||
curDownLoadedFileNum: number,
|
||||
curRealDownLoadedFileNum: number,
|
||||
curDownloadingFileNum: number,
|
||||
totalDownLoadedFileNum: number,
|
||||
curDownLoadedBytes: string, // "0"
|
||||
totalDownLoadedBytes: string,
|
||||
curSpeedBps: number,
|
||||
avgSpeedBps: number,
|
||||
maxSpeedBps: number,
|
||||
remainDownLoadSeconds: number,
|
||||
failFileIdList: [],
|
||||
allFileIdList: [],
|
||||
hasNormalFileDownloading: boolean,
|
||||
onlyCompressInnerFileDownloading: boolean,
|
||||
isAllFileAlreadyDownloaded: boolean,
|
||||
saveFileSetDir: string,
|
||||
allWaitingStatusTask: boolean,
|
||||
downloadSceneType: DownloadSceneType,
|
||||
retryCount: number,
|
||||
statisticInfo: {
|
||||
downloadTaskId: string,
|
||||
downloadFilesetName: string,
|
||||
downloadFileTypeDistribution: string,
|
||||
downloadFileSizeDistribution: string;
|
||||
},
|
||||
albumStorageFailImageNum: number,
|
||||
albumStorageFailVideoNum: number,
|
||||
albumStorageFailFileIdList: [],
|
||||
albumStorageSucImageNum: number,
|
||||
albumStorageSucVideoNum: number,
|
||||
albumStorageSucFileIdList: [],
|
||||
albumStorageFileNum: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface physicalInfo {
|
||||
id: string,
|
||||
url: string,
|
||||
status: number, // 2 已下载
|
||||
processing: string,
|
||||
localPath: string,
|
||||
width: 0,
|
||||
height: 0,
|
||||
time: number,
|
||||
}
|
||||
|
||||
export interface downloadInfo {
|
||||
status: number,
|
||||
curDownLoadBytes: string,
|
||||
totalFileBytes: string,
|
||||
errorCode: number,
|
||||
}
|
||||
|
||||
export interface uploadInfo {
|
||||
uploadedBytes: string,
|
||||
errorCode: number,
|
||||
svrRrrCode: number,
|
||||
errMsg: string,
|
||||
isNeedDelDeviceInfo: boolean,
|
||||
thumbnailUploadState: number;
|
||||
isSecondHit: boolean,
|
||||
hasModifiedErr: boolean,
|
||||
}
|
||||
|
||||
export interface folderUploadInfo {
|
||||
totalUploadedFileSize: string;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
export interface folderDownloadInfo {
|
||||
totalDownloadedFileSize: string;
|
||||
totalFileSize: string;
|
||||
totalDownloadFileCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
pausedCount: number;
|
||||
cancelCount: number;
|
||||
downloadingCount: number;
|
||||
partialDownloadCount: number;
|
||||
curLevelDownloadedFileCount: number;
|
||||
curLevelUnDownloadedFileCount: number;
|
||||
}
|
||||
|
||||
export interface compressFileFolderInfo {
|
||||
downloadStatus: number;
|
||||
saveFileDirPath: string;
|
||||
totalFileCount: string;
|
||||
totalFileSize: string;
|
||||
}
|
||||
|
||||
export interface albumStorgeInfo {
|
||||
status: number;
|
||||
localIdentifier: string;
|
||||
errorCode: number;
|
||||
timeCost: number;
|
||||
}
|
||||
|
||||
export interface FlashOneFileInfo {
|
||||
fileSetId: string;
|
||||
cliFileId: string; // client?? 或许可以换取url
|
||||
compressedFileFolderId: string;
|
||||
archiveIndex: 0;
|
||||
indexPath: string;
|
||||
isDir: boolean; // 文件或者文件夹!!
|
||||
parentId: string;
|
||||
depth: number; // 1
|
||||
cliFileIndex: number;
|
||||
fileType: number; // 枚举!! 已完成枚举!!
|
||||
name: string;
|
||||
namePinyin: string;
|
||||
isCover: boolean;
|
||||
isCoverOriginal: boolean;
|
||||
fileSize: string;
|
||||
fileCount: number;
|
||||
thumbnail: thumbnailInfo;
|
||||
physical: physicalInfo;
|
||||
srvFileId: string; // service?? 服务器上面的id吗?
|
||||
srvParentFileId: string;
|
||||
svrLastUpdateTimestamp: string;
|
||||
downloadInfo: downloadInfo;
|
||||
saveFilePath: string;
|
||||
search_relative_path: string;
|
||||
disk_relative_path: string;
|
||||
uploadInfo: uploadInfo;
|
||||
status: number;
|
||||
uploadStatus: number; // 3已上传成功
|
||||
downloadStatus: number; // 0未下载
|
||||
folderUploadInfo: folderUploadInfo;
|
||||
folderDownloadInfo: folderDownloadInfo;
|
||||
sha1: string;
|
||||
bookmark: string;
|
||||
compressFileFolderInfo: compressFileFolderInfo;
|
||||
uploadPauseReason: string;
|
||||
downloadPauseReason: string;
|
||||
filePhysicalSize: string;
|
||||
thumbnail_sha1: string | null;
|
||||
thumbnail_size: string | null;
|
||||
needAlbumStorage: boolean;
|
||||
albumStorageInfo: albumStorgeInfo;
|
||||
}
|
||||
|
||||
export interface fileListsInfo {
|
||||
parentId: string,
|
||||
depth: number, // 1
|
||||
fileList: FlashOneFileInfo[],
|
||||
paginationInfo: {};
|
||||
isEnd: boolean,
|
||||
isCache: boolean,
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
seq: number,
|
||||
result: number,
|
||||
errMs: string,
|
||||
fileLists: fileListsInfo[],
|
||||
}
|
||||
|
||||
export interface createFlashTransferResult {
|
||||
fileSetId: string,
|
||||
shareLink: string,
|
||||
expireTime: string,
|
||||
expireLeftTime: string,
|
||||
}
|
||||
export enum UploadSceneType {
|
||||
KUPLOADSCENEUNKNOWN,
|
||||
KUPLOADSCENEFLOATWINDOWRIGHTCLICKMENU,
|
||||
KUPLOADSCENEFLOATWINDOWDRAG,
|
||||
KUPLOADSCENEFLOATWINDOWFILESELECTOR,
|
||||
KUPLOADSCENEFLOATWINDOWSHORTCUTKEYCTRLCV,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTRIGHTCLICKMENU,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTDRAG,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTFILESELECTOR,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTSHORTCUTKEYCTRLCV,
|
||||
KUPLOADSCENEAIODRAG,
|
||||
KUPLOADSCENEAIOFILESELECTOR,
|
||||
KUPLOADSCENEAIOSHORTCUTKEYCTRLCV
|
||||
}
|
||||
export interface StartFlashTaskRequests {
|
||||
screen: number; // 1 PC-QQ
|
||||
name?: string;
|
||||
uploaders: UploaderInfo[];
|
||||
permission?: {};
|
||||
coverPath?: string;
|
||||
paths: string[]; // 文件的绝对路径,可以是文件夹
|
||||
excludePaths?: string[];
|
||||
expireLeftTime?: number, // 0
|
||||
isNeedDelDeviceInfo: boolean,
|
||||
isNeedDelLocation: boolean,
|
||||
coverOriginalInfos?: {
|
||||
path: string,
|
||||
thumbnailPath: string,
|
||||
}[],
|
||||
uploadSceneType: UploadSceneType, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
|
||||
detectPrivacyInfoResult: {
|
||||
exists: boolean,
|
||||
allDetectResults: {};
|
||||
};
|
||||
}
|
||||
export enum BusiScene {
|
||||
KBUSISCENEINVALID,
|
||||
KBUSISCENEFLASHSCENE
|
||||
}
|
||||
export interface FileListInfoRequests {
|
||||
seq: number, // 0
|
||||
fileSetId: string,
|
||||
isUseCache: boolean,
|
||||
sceneType: BusiScene, // 1
|
||||
reqInfos: {
|
||||
count: number, // 18 ?? 硬编码吧 不懂
|
||||
paginationInfo: {},
|
||||
parentId: string,
|
||||
reqIndexPath: string,
|
||||
reqDepth: number, // 1
|
||||
filterCondition: {
|
||||
fileCategory: number,
|
||||
filterType: number,
|
||||
}, // 0
|
||||
sortConditions: {
|
||||
sortField: number,
|
||||
sortOrder: number,
|
||||
}[],
|
||||
isNeedPhysicalInfoReady: boolean;
|
||||
}[];
|
||||
}
|
||||
export enum DownloadSceneType {
|
||||
KDOWNLOADSCENEUNKNOWN,
|
||||
KDOWNLOADSCENEARKC2C,
|
||||
KDOWNLOADSCENEARKC2CDETAILPAGE,
|
||||
KDOWNLOADSCENEARKGROUP,
|
||||
KDOWNLOADSCENEARKGROUPDETAILPAGE,
|
||||
KDOWNLOADSCENELINKC2C,
|
||||
KDOWNLOADSCENELINKGROUP,
|
||||
KDOWNLOADSCENELINKCHANNEL,
|
||||
KDOWNLOADSCENELINKTEMPCHAT,
|
||||
KDOWNLOADSCENELINKOTHERINQQ,
|
||||
KDOWNLOADSCENESCANQRCODE,
|
||||
KDWONLOADSCENEFLASHTRANSFERCENTERCLIENT,
|
||||
KDWONLOADSCENEFLASHTRANSFERCENTERSCHEMA
|
||||
}
|
||||
export interface FlashFileSetInfo {
|
||||
fileSetId: string,
|
||||
name: string,
|
||||
namePinyin: string,
|
||||
totalFileCount: number,
|
||||
totalFileSize: number,
|
||||
permission: {},
|
||||
shareInfo: {
|
||||
shareLink: string,
|
||||
extractionCode: string,
|
||||
},
|
||||
cover: {
|
||||
id: string,
|
||||
urls: [
|
||||
{
|
||||
spec: number, // 2
|
||||
url: string;
|
||||
}
|
||||
],
|
||||
localCachePath: string;
|
||||
},
|
||||
uploaders: [
|
||||
{
|
||||
uin: string,
|
||||
nickname: string,
|
||||
uid: string,
|
||||
sendEntrance: string;
|
||||
}
|
||||
],
|
||||
expireLeftTime: number,
|
||||
aiClusteringStatus: {
|
||||
firstClusteringList: [],
|
||||
shouldPull: boolean;
|
||||
},
|
||||
createTime: number,
|
||||
expireTime: number,
|
||||
firstLevelItemCount: 1,
|
||||
svrLastUpdateTimestamp: 0,
|
||||
taskId: string, // 同 fileSetId
|
||||
uploadInfo: {
|
||||
totalUploadedFileSize: number,
|
||||
successCount: number,
|
||||
failedCount: number;
|
||||
},
|
||||
downloadInfo: {
|
||||
totalDownloadedFileSize: 0,
|
||||
totalFileSize: 0,
|
||||
totalDownloadFileCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
cancelCount: 0,
|
||||
status: 0,
|
||||
curLevelDownloadedFileCount: number,
|
||||
curLevelUnDownloadedFileCount: 0;
|
||||
},
|
||||
transferType: number,
|
||||
isLocalCreate: true,
|
||||
status: number, // todo 枚举全部状态
|
||||
uploadStatus: number, // todo 同上
|
||||
uploadPauseReason: 0,
|
||||
downloadStatus: 0,
|
||||
downloadPauseReason: 0,
|
||||
saveFileSetDir: string,
|
||||
uploadSceneType: UploadSceneType,
|
||||
downloadSceneType: DownloadSceneType, // 0 PC-QQ 103 web
|
||||
retryCount: number,
|
||||
isMergeShareUpload: 0,
|
||||
isRemoveDeviceInfo: boolean,
|
||||
isRemoveLocation: boolean;
|
||||
}
|
||||
|
||||
export interface SendStatus {
|
||||
result: number,
|
||||
msg: string,
|
||||
target: {
|
||||
destType: number,
|
||||
destUid: string,
|
||||
};
|
||||
}
|
||||
28
packages/napcat-core/external/appid.json
vendored
28
packages/napcat-core/external/appid.json
vendored
@@ -498,5 +498,33 @@
|
||||
"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,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44498_GW_B"
|
||||
},
|
||||
"9.9.26-44725": {
|
||||
"appid": 537337569,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
||||
},
|
||||
"9.9.27-45627": {
|
||||
"appid": 537340060,
|
||||
"qua": "V1_WIN_NQ_9.9.27_45627_GW_B"
|
||||
},
|
||||
"6.9.88-44725": {
|
||||
"appid": 537337594,
|
||||
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
|
||||
}
|
||||
}
|
||||
40
packages/napcat-core/external/napi2native.json
vendored
40
packages/napcat-core/external/napi2native.json
vendored
@@ -87,6 +87,10 @@
|
||||
"send": "23B0330",
|
||||
"recv": "0957648"
|
||||
},
|
||||
"3.2.21-42086-arm64": {
|
||||
"send": "3D6D98C",
|
||||
"recv": "14797C8"
|
||||
},
|
||||
"3.2.21-42086-x64": {
|
||||
"send": "5B42CF0",
|
||||
"recv": "2FDA6F0"
|
||||
@@ -126,5 +130,41 @@
|
||||
"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"
|
||||
},
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "0A18D0C",
|
||||
"recv": "1D4BF0D"
|
||||
},
|
||||
"9.9.27-45627-x64": {
|
||||
"send": "0A697CC",
|
||||
"recv": "1E86AC1"
|
||||
},
|
||||
"6.9.88-44725-x64": {
|
||||
"send": "2756EF6",
|
||||
"recv": "0A36152"
|
||||
},
|
||||
"6.9.88-44725-arm64": {
|
||||
"send": "2313C68",
|
||||
"recv": "09693E4"
|
||||
}
|
||||
}
|
||||
36
packages/napcat-core/external/packet.json
vendored
36
packages/napcat-core/external/packet.json
vendored
@@ -638,5 +638,41 @@
|
||||
"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"
|
||||
},
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "2CEBB20",
|
||||
"recv": "2CEF0A0"
|
||||
},
|
||||
"9.9.27-45627-x64": {
|
||||
"send": "2E59CC0",
|
||||
"recv": "2E5D240"
|
||||
},
|
||||
"6.9.88-44725-x64": {
|
||||
"send": "451FE90",
|
||||
"recv": "4522A40"
|
||||
},
|
||||
"6.9.88-44725-arm64": {
|
||||
"send": "3D79168",
|
||||
"recv": "3D7BA78"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 格式
|
||||
*/
|
||||
|
||||
@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string
|
||||
app: string;
|
||||
config: ForwardMsgJsonConfig,
|
||||
desc: string,
|
||||
extra: ForwardMsgJsonExtra,
|
||||
meta: ForwardMsgJsonMeta,
|
||||
prompt: string,
|
||||
ver: string,
|
||||
view: string
|
||||
view: string;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonConfig {
|
||||
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
|
||||
forward: number,
|
||||
round: number,
|
||||
type: string,
|
||||
width: number
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonExtra {
|
||||
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMeta {
|
||||
detail: ForwardMsgJsonMetaDetail
|
||||
detail: ForwardMsgJsonMetaDetail;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMetaDetail {
|
||||
news: {
|
||||
text: string
|
||||
text: string;
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string
|
||||
uniseq: string;
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsg {
|
||||
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
|
||||
}
|
||||
|
||||
export class ForwardMsgBuilder {
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
const id = crypto.randomUUID();
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
const id = uuid ?? crypto.randomUUID();
|
||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||
if (!source) {
|
||||
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||
@@ -104,13 +104,19 @@ export class ForwardMsgBuilder {
|
||||
return this.build(resId, []);
|
||||
}
|
||||
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
return this.build(resId, packetMsg.map(msg => ({
|
||||
senderName: msg.senderName,
|
||||
isGroupMsg: msg.groupId !== undefined,
|
||||
msg: msg.msg.map(m => ({
|
||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||
})),
|
||||
})), source, news, summary, prompt);
|
||||
})),
|
||||
source,
|
||||
news,
|
||||
summary,
|
||||
prompt,
|
||||
uuid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
||||
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/napcat-core/index';
|
||||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||
import EventEmitter from 'node:events';
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
@@ -263,7 +264,13 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
|
||||
}
|
||||
|
||||
if (element.fileElement) {
|
||||
return `[文件 ${element.fileElement.fileName}]`;
|
||||
if (element.fileElement.fileUuid) {
|
||||
return `[文件 ${element.fileElement.fileName}]`;
|
||||
} else if (element.elementType === ElementType.TOFURECORD) {
|
||||
return `[在线文件 ${element.fileElement.fileName}]`;
|
||||
} else if (element.elementType === ElementType.ONLINEFOLDER) {
|
||||
return `[在线文件夹 ${element.fileElement.fileName}/]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (element.videoElement) {
|
||||
@@ -287,7 +294,11 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
|
||||
}
|
||||
|
||||
if (element.markdownElement) {
|
||||
return '[Markdown 消息]';
|
||||
if (element.markdownElement?.mdSummary) {
|
||||
return element.markdownElement.mdSummary;
|
||||
} else {
|
||||
return '[Markdown 消息]';
|
||||
}
|
||||
}
|
||||
|
||||
if (element.multiForwardMsgElement) {
|
||||
@@ -296,6 +307,8 @@ function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLe
|
||||
|
||||
if (element.elementType === ElementType.GreyTip) {
|
||||
return '[灰条消息]';
|
||||
} else if (element.elementType === ElementType.FILE) {
|
||||
return '[文件发送中]';
|
||||
}
|
||||
|
||||
return `[未实现 (ElementType = ${element.elementType})]`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import AppidTable from '@/napcat-core/external/appid.json';
|
||||
import { LogWrapper } from './log';
|
||||
import { getMajorPath } from '@/napcat-core/index';
|
||||
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from 'napcat-common/src/types';
|
||||
import path from 'node:path';
|
||||
|
||||
export class QQBasicInfoWrapper {
|
||||
QQMainPath: string | undefined;
|
||||
@@ -21,6 +22,10 @@ export class QQBasicInfoWrapper {
|
||||
// 基础目录获取
|
||||
this.context = context;
|
||||
this.QQMainPath = process.execPath;
|
||||
if (process.platform === 'darwin' && path.basename(this.QQMainPath) === 'QQ Helper') {
|
||||
// 实用进程特殊处理 实用进程目录和QQ差远了
|
||||
this.QQMainPath = path.resolve(path.dirname(this.QQMainPath), '../../../../', 'MacOS', 'QQ');
|
||||
}
|
||||
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
|
||||
|
||||
// 基础信息获取 无快更则启用默认模板填充
|
||||
@@ -99,7 +104,10 @@ export class QQBasicInfoWrapper {
|
||||
}
|
||||
|
||||
getAppidV2ByMajor (QQVersion: string) {
|
||||
const majorPath = getMajorPath(QQVersion);
|
||||
if (!this.QQMainPath) {
|
||||
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
|
||||
}
|
||||
const majorPath = getMajorPath(QQVersion, this.QQMainPath);
|
||||
const appid = parseAppidFromMajor(majorPath);
|
||||
return appid;
|
||||
}
|
||||
|
||||
140
packages/napcat-core/helper/session-proxy.ts
Normal file
140
packages/napcat-core/helper/session-proxy.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||
import { ServiceNamingMapping } from '@/napcat-core/services/index';
|
||||
import { NTEventWrapper } from './event';
|
||||
|
||||
/**
|
||||
* 创建 Service 方法的代理
|
||||
* 拦截所有方法调用,通过 EventWrapper 进行调用
|
||||
*/
|
||||
function createServiceMethodProxy<S extends keyof ServiceNamingMapping>(
|
||||
serviceName: S,
|
||||
originalService: ServiceNamingMapping[S],
|
||||
eventWrapper: NTEventWrapper
|
||||
): ServiceNamingMapping[S] {
|
||||
return new Proxy(originalService as object, {
|
||||
get(target, prop, receiver) {
|
||||
const originalValue = Reflect.get(target, prop, receiver);
|
||||
|
||||
// 如果不是函数,直接返回原始值
|
||||
if (typeof originalValue !== 'function') {
|
||||
return originalValue;
|
||||
}
|
||||
|
||||
const methodName = prop as string;
|
||||
|
||||
// 返回一个包装函数,通过 EventWrapper 调用
|
||||
return function (this: unknown, ...args: unknown[]) {
|
||||
// 构造 EventWrapper 需要的路径格式: ServiceName/MethodName
|
||||
const eventPath = `${serviceName}/${methodName}`;
|
||||
|
||||
// 尝试通过 EventWrapper 调用
|
||||
try {
|
||||
// 使用 callNoListenerEvent 的底层实现逻辑
|
||||
const eventFunc = (eventWrapper as any).createEventFunction(eventPath);
|
||||
if (eventFunc) {
|
||||
return eventFunc(...args);
|
||||
}
|
||||
} catch {
|
||||
// 如果 EventWrapper 调用失败,回退到原始调用
|
||||
}
|
||||
|
||||
// 回退到原始方法调用
|
||||
return originalValue.apply(originalService, args);
|
||||
};
|
||||
},
|
||||
}) as ServiceNamingMapping[S];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Session 的双层代理
|
||||
* 第一层:拦截 getXXXService 方法
|
||||
* 第二层:拦截 Service 上的具体方法调用
|
||||
*/
|
||||
export function createSessionProxy(
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
// 缓存已代理的 Service,避免重复创建
|
||||
const serviceProxyCache = new Map<string, unknown>();
|
||||
|
||||
return new Proxy(session, {
|
||||
get(target, prop, receiver) {
|
||||
const propName = prop as string;
|
||||
|
||||
// 检查是否是 getXXXService 方法
|
||||
if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) {
|
||||
// 提取 Service 名称: getMsgService -> NodeIKernelMsgService
|
||||
const servicePart = propName.slice(3); // 移除 'get' 前缀
|
||||
const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping;
|
||||
|
||||
// 返回一个函数,该函数返回代理后的 Service
|
||||
return function () {
|
||||
// 检查缓存
|
||||
if (serviceProxyCache.has(serviceName)) {
|
||||
return serviceProxyCache.get(serviceName);
|
||||
}
|
||||
|
||||
// 获取原始 Service
|
||||
const originalGetter = Reflect.get(target, prop, receiver) as () => unknown;
|
||||
const originalService = originalGetter.call(target);
|
||||
|
||||
// 检查是否在 ServiceNamingMapping 中定义
|
||||
if (isKnownService(serviceName)) {
|
||||
// 创建 Service 方法代理
|
||||
const proxiedService = createServiceMethodProxy(
|
||||
serviceName,
|
||||
originalService as ServiceNamingMapping[typeof serviceName],
|
||||
eventWrapper
|
||||
);
|
||||
serviceProxyCache.set(serviceName, proxiedService);
|
||||
return proxiedService;
|
||||
}
|
||||
|
||||
// 未知的 Service,直接返回原始对象
|
||||
serviceProxyCache.set(serviceName, originalService);
|
||||
return originalService;
|
||||
};
|
||||
}
|
||||
|
||||
// 非 getXXXService 方法,直接返回原始值
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Service 名称是否在已知的映射中
|
||||
*/
|
||||
function isKnownService(serviceName: string): serviceName is keyof ServiceNamingMapping {
|
||||
const knownServices: string[] = [
|
||||
'NodeIKernelAvatarService',
|
||||
'NodeIKernelBuddyService',
|
||||
'NodeIKernelFileAssistantService',
|
||||
'NodeIKernelGroupService',
|
||||
'NodeIKernelLoginService',
|
||||
'NodeIKernelMsgService',
|
||||
'NodeIKernelOnlineStatusService',
|
||||
'NodeIKernelProfileLikeService',
|
||||
'NodeIKernelProfileService',
|
||||
'NodeIKernelTicketService',
|
||||
'NodeIKernelStorageCleanService',
|
||||
'NodeIKernelRobotService',
|
||||
'NodeIKernelRichMediaService',
|
||||
'NodeIKernelDbToolsService',
|
||||
'NodeIKernelTipOffService',
|
||||
'NodeIKernelSearchService',
|
||||
'NodeIKernelCollectionService',
|
||||
];
|
||||
return knownServices.includes(serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有 EventWrapper 集成的 InstanceContext
|
||||
* 这是推荐的使用方式,在创建 context 时自动代理 session
|
||||
*/
|
||||
export function createProxiedSession(
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
return createSessionProxy(session, eventWrapper);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
NTQQSystemApi,
|
||||
NTQQUserApi,
|
||||
NTQQWebApi,
|
||||
NTQQFlashApi,
|
||||
NTQQOnlineApi,
|
||||
} from '@/napcat-core/apis';
|
||||
import { NTQQCollectionApi } from '@/napcat-core/apis/collection';
|
||||
import {
|
||||
@@ -17,14 +19,14 @@ 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';
|
||||
import fs from 'node:fs';
|
||||
import { hostname, systemName, systemVersion } from 'napcat-common/src/system';
|
||||
import { NTEventWrapper } from '@/napcat-core/helper/event';
|
||||
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
|
||||
import { createSessionProxy } from '@/napcat-core/helper/session-proxy';
|
||||
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
|
||||
import { NapCatConfigLoader, NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
import os from 'node:os';
|
||||
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/napcat-core/listeners';
|
||||
@@ -38,6 +40,14 @@ export * from './wrapper';
|
||||
export * from './types/index';
|
||||
export * from './services/index';
|
||||
export * from './listeners/index';
|
||||
export * from './apis/index';
|
||||
export * from './helper/log';
|
||||
export * from './helper/qq-basic-info';
|
||||
export * from './helper/event';
|
||||
export * from './helper/config';
|
||||
export * from './helper/config-base';
|
||||
export * from './helper/proxy-handler';
|
||||
export * from './helper/session-proxy';
|
||||
|
||||
export enum NapCatCoreWorkingEnv {
|
||||
Unknown = 0,
|
||||
@@ -45,20 +55,23 @@ export enum NapCatCoreWorkingEnv {
|
||||
Framework = 2,
|
||||
}
|
||||
|
||||
export function loadQQWrapper (QQVersion: string): WrapperNodeApi {
|
||||
export function loadQQWrapper (execPath: string | undefined, QQVersion: string): WrapperNodeApi {
|
||||
if (process.env['NAPCAT_WRAPPER_PATH']) {
|
||||
const wrapperPath = process.env['NAPCAT_WRAPPER_PATH'];
|
||||
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
|
||||
process.dlopen(nativemodule, wrapperPath);
|
||||
return nativemodule.exports;
|
||||
}
|
||||
if (!execPath) {
|
||||
throw new Error('无法加载Wrapper,execPath未定义');
|
||||
}
|
||||
let appPath;
|
||||
if (os.platform() === 'darwin') {
|
||||
appPath = path.resolve(path.dirname(process.execPath), '../Resources/app');
|
||||
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
|
||||
} else if (os.platform() === 'linux') {
|
||||
appPath = path.resolve(path.dirname(process.execPath), './resources/app');
|
||||
appPath = path.resolve(path.dirname(execPath), './resources/app');
|
||||
} else {
|
||||
appPath = path.resolve(path.dirname(process.execPath), `./versions/${QQVersion}/`);
|
||||
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
|
||||
}
|
||||
let wrapperNodePath = path.resolve(appPath, 'wrapper.node');
|
||||
if (!fs.existsSync(wrapperNodePath)) {
|
||||
@@ -66,21 +79,22 @@ export function loadQQWrapper (QQVersion: string): WrapperNodeApi {
|
||||
}
|
||||
// 老版本兼容 未来去掉
|
||||
if (!fs.existsSync(wrapperNodePath)) {
|
||||
wrapperNodePath = path.join(path.dirname(process.execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
|
||||
wrapperNodePath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
|
||||
}
|
||||
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
|
||||
process.dlopen(nativemodule, wrapperNodePath);
|
||||
process.env['NAPCAT_WRAPPER_PATH'] = wrapperNodePath;
|
||||
return nativemodule.exports;
|
||||
}
|
||||
export function getMajorPath (QQVersion: string): string {
|
||||
export function getMajorPath (execPath: string, QQVersion: string): string {
|
||||
// major.node
|
||||
let appPath;
|
||||
if (os.platform() === 'darwin') {
|
||||
appPath = path.resolve(path.dirname(process.execPath), '../Resources/app');
|
||||
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
|
||||
} else if (os.platform() === 'linux') {
|
||||
appPath = path.resolve(path.dirname(process.execPath), './resources/app');
|
||||
appPath = path.resolve(path.dirname(execPath), './resources/app');
|
||||
} else {
|
||||
appPath = path.resolve(path.dirname(process.execPath), `./versions/${QQVersion}/`);
|
||||
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
|
||||
}
|
||||
let majorPath = path.resolve(appPath, 'major.node');
|
||||
if (!fs.existsSync(majorPath)) {
|
||||
@@ -88,7 +102,7 @@ export function getMajorPath (QQVersion: string): string {
|
||||
}
|
||||
// 老版本兼容 未来去掉
|
||||
if (!fs.existsSync(majorPath)) {
|
||||
majorPath = path.join(path.dirname(process.execPath), `./resources/app/versions/${QQVersion}/major.node`);
|
||||
majorPath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/major.node`);
|
||||
}
|
||||
return majorPath;
|
||||
}
|
||||
@@ -107,9 +121,19 @@ export class NapCatCore {
|
||||
// 通过构造器递过去的 runtime info 应该尽量少
|
||||
constructor (context: InstanceContext, selfInfo: SelfInfo) {
|
||||
this.selfInfo = selfInfo;
|
||||
this.context = context;
|
||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||
// 先用原始 session 创建 eventWrapper
|
||||
this.eventWrapper = new NTEventWrapper(context.session);
|
||||
// 通过环境变量 NAPCAT_SESSION_PROXY 开启 session 代理
|
||||
if (process.env['NAPCAT_SESSION_PROXY'] === '1') {
|
||||
const proxiedSession = createSessionProxy(context.session, this.eventWrapper);
|
||||
this.context = {
|
||||
...context,
|
||||
session: proxiedSession,
|
||||
};
|
||||
} else {
|
||||
this.context = context;
|
||||
}
|
||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath, NapcatConfigSchema);
|
||||
this.apis = {
|
||||
FileApi: new NTQQFileApi(this.context, this),
|
||||
@@ -121,12 +145,14 @@ export class NapCatCore {
|
||||
MsgApi: new NTQQMsgApi(this.context, this),
|
||||
UserApi: new NTQQUserApi(this.context, this),
|
||||
GroupApi: new NTQQGroupApi(this.context, this),
|
||||
FlashApi: new NTQQFlashApi(this.context, this),
|
||||
OnlineApi: new NTQQOnlineApi(this.context, this),
|
||||
};
|
||||
container.bind(NapCatCore).toConstantValue(this);
|
||||
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
||||
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
||||
container.bind(ServiceClass).toSelf();
|
||||
//console.log(`Registering service handler for: ${serviceName}`);
|
||||
// console.log(`Registering service handler for: ${serviceName}`);
|
||||
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
|
||||
const serviceInstance = container.get(ServiceClass);
|
||||
return serviceInstance.handler(seq, hex_data);
|
||||
@@ -176,10 +202,17 @@ export class NapCatCore {
|
||||
async initNapCatCoreListeners () {
|
||||
const msgListener = new NodeIKernelMsgListener();
|
||||
|
||||
// 在线文件/文件夹消息
|
||||
msgListener.onRecvOnlineFileMsg = (msgs: RawMessage[]) => {
|
||||
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
|
||||
};
|
||||
|
||||
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
|
||||
// 下线通知
|
||||
this.context.logger.logError('[KickedOffLine] [' + Info.tipsTitle + '] ' + Info.tipsDesc);
|
||||
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`;
|
||||
this.context.logger.logError(tips);
|
||||
this.selfInfo.online = false;
|
||||
this.event.emit('KickedOffLine', tips);
|
||||
};
|
||||
msgListener.onRecvMsg = (msgs) => {
|
||||
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
|
||||
@@ -278,7 +311,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;
|
||||
@@ -294,4 +326,6 @@ export interface StableNTApiWrapper {
|
||||
MsgApi: NTQQMsgApi,
|
||||
UserApi: NTQQUserApi,
|
||||
GroupApi: NTQQGroupApi;
|
||||
FlashApi: NTQQFlashApi,
|
||||
OnlineApi: NTQQOnlineApi,
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export class NodeIKernelLoginListener {
|
||||
|
||||
onLoginState (..._args: any[]): any {
|
||||
}
|
||||
onLoginRecordUpdate (..._args: any[]): any {
|
||||
}
|
||||
}
|
||||
|
||||
export interface QRCodeLoginSucceedResult {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
|
||||
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
||||
import { gunzipSync } from 'zlib';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export class PacketOperationContext {
|
||||
private readonly context: PacketContext;
|
||||
@@ -26,7 +27,7 @@ export class PacketOperationContext {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
return await this.context.client.sendOidbPacket(pkt, rsp);
|
||||
}
|
||||
|
||||
@@ -94,12 +95,15 @@ export class PacketOperationContext {
|
||||
.filter(Boolean)
|
||||
);
|
||||
const res = await Promise.allSettled(reqList);
|
||||
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}个`);
|
||||
res.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||
}
|
||||
});
|
||||
const failedCount = res.filter((r) => r.status === 'rejected').length;
|
||||
if (failedCount > 0) {
|
||||
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}个`);
|
||||
res.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async UploadImage (img: PacketMsgPicElement) {
|
||||
@@ -224,7 +228,15 @@ export class PacketOperationContext {
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
|
||||
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
|
||||
//await this.SendPreprocess(msg, groupUin);
|
||||
// 遍历上传资源
|
||||
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
|
||||
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
async MoveGroupFile (
|
||||
groupUin: number,
|
||||
fileUUID: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TypedEventEmitter } from './typeEvent';
|
||||
|
||||
export interface AppEvents {
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number };
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; };
|
||||
KickedOffLine: string;
|
||||
}
|
||||
export const appEvent = new TypedEventEmitter<AppEvents>();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import zlib from 'node:zlib';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/napcat-core/packet/transformer/base';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
export interface UploadForwardMsgParams {
|
||||
actionCommand: string;
|
||||
actionMsg: PacketMsg[];
|
||||
}
|
||||
class UploadForwardMsgV2 extends PacketTransformer<typeof proto.SendLongMsgResp> {
|
||||
build (selfUid: string, msg: UploadForwardMsgParams[], groupUin: number = 0): OidbPacket {
|
||||
const reqdata = msg.map((item) => ({
|
||||
actionCommand: item.actionCommand,
|
||||
actionData: {
|
||||
msgBody: this.msgBuilder.buildFakeMsg(selfUid, item.actionMsg),
|
||||
}
|
||||
}));
|
||||
const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode(
|
||||
{
|
||||
action: reqdata,
|
||||
}
|
||||
);
|
||||
const payload = zlib.gzipSync(Buffer.from(longMsgResultData));
|
||||
const req = new NapProtoMsg(proto.SendLongMsgReq).encode(
|
||||
{
|
||||
info: {
|
||||
type: groupUin === 0 ? 1 : 3,
|
||||
uid: {
|
||||
uid: groupUin === 0 ? selfUid : groupUin.toString(),
|
||||
},
|
||||
groupUin,
|
||||
payload,
|
||||
},
|
||||
settings: {
|
||||
field1: 4, field2: 1, field3: 7, field4: 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
|
||||
data: PacketBufBuilder(req),
|
||||
};
|
||||
}
|
||||
|
||||
parse (data: Buffer) {
|
||||
return new NapProtoMsg(proto.SendLongMsgResp).decode(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new UploadForwardMsgV2();
|
||||
@@ -2,3 +2,4 @@ export { default as UploadForwardMsg } from './UploadForwardMsg';
|
||||
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
||||
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';
|
||||
303
packages/napcat-core/services/NodeIKernelFlashTransferService.ts
Normal file
303
packages/napcat-core/services/NodeIKernelFlashTransferService.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { GeneralCallResult } from './common';
|
||||
import {
|
||||
SendStatus,
|
||||
StartFlashTaskRequests,
|
||||
createFlashTransferResult,
|
||||
FlashBaseRequest,
|
||||
FlashFileSetInfo,
|
||||
FileListInfoRequests,
|
||||
FileListResponse,
|
||||
DownloadStatusInfo,
|
||||
SendTargetRequests,
|
||||
FlashOneFileInfo,
|
||||
DownloadSceneType,
|
||||
} from '../data/flash';
|
||||
|
||||
export interface NodeIKernelFlashTransferService {
|
||||
/**
|
||||
* 开始闪传服务 并上传文件/文件夹(可以多选,非常好用)
|
||||
* @param timestamp
|
||||
* @param fileInfo
|
||||
*/
|
||||
createFlashTransferUploadTask (timestamp: number, fileInfo: StartFlashTaskRequests): Promise<GeneralCallResult & {
|
||||
createFlashTransferResult: createFlashTransferResult;
|
||||
seq: number;
|
||||
}>; // 2 arg 重点 // 自动上传
|
||||
|
||||
createMergeShareTask (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
updateFlashTransfer (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
getFileSetList (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
getFileSetListCount (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
/**
|
||||
* 获取file set 的信息
|
||||
* @param fileSetIdDict
|
||||
*/
|
||||
getFileSet (fileSetIdDict: FlashBaseRequest): Promise<GeneralCallResult & {
|
||||
seq: number;
|
||||
isCache: boolean;
|
||||
fileSet: FlashFileSetInfo;
|
||||
}>; // 1 arg
|
||||
|
||||
/**
|
||||
* 获取file set 里面的文件信息(文件夹结构)
|
||||
* @param requestArgs
|
||||
*/
|
||||
getFileList (requestArgs: FileListInfoRequests): Promise<{
|
||||
rsp: FileListResponse;
|
||||
}>; // 1 arg 这个方法QQ有bug??? 并没有,是我参数有问题
|
||||
|
||||
getDownloadedFileCount (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
getLocalFileList (...args: unknown[]): unknown; // 3 arg
|
||||
|
||||
batchRemoveUserFileSetHistory (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
/**
|
||||
* 获取分享链接
|
||||
* @param fileSetId
|
||||
*/
|
||||
getShareLinkReq (fileSetId: string): Promise<GeneralCallResult & {
|
||||
shareLink: string;
|
||||
expireTimestamp: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 由分享链接到fileSetId
|
||||
* @param shareCode
|
||||
*/
|
||||
getFileSetIdByCode (shareCode: string): Promise<GeneralCallResult & {
|
||||
fileSetId: string;
|
||||
}>; // 1 arg code == share code
|
||||
|
||||
batchRemoveFile (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
checkUploadPathValid (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
cleanFailedFiles (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
/**
|
||||
* 暂停所有的任务
|
||||
*/
|
||||
resumeAllUnfinishedTasks (): unknown; // 0 arg !!
|
||||
|
||||
addFileSetUploadListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetUploadListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
/**
|
||||
* 开始上传任务 适用于已暂停的
|
||||
* @param fileSetId
|
||||
*/
|
||||
startFileSetUpload (fileSetId: string): void; // 1 arg 并不是新建任务,应该是暂停后的启动
|
||||
|
||||
/**
|
||||
* 结束,无法再次启动
|
||||
* @param fileSetId
|
||||
*/
|
||||
stopFileSetUpload (fileSetId: string): void; // 1 arg stop 后start无效
|
||||
|
||||
/**
|
||||
* 暂停上传
|
||||
* @param fileSetId
|
||||
*/
|
||||
pauseFileSetUpload (fileSetId: string): void; // 1 arg 暂停上传
|
||||
|
||||
/**
|
||||
* 继续上传
|
||||
* @param args
|
||||
*/
|
||||
resumeFileSetUpload (...args: unknown[]): unknown; // 1 arg 继续
|
||||
|
||||
pauseFileUpload (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
resumeFileUpload (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
stopFileUpload (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
asyncGetThumbnailPath (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
setDownLoadDefaultFileDir (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
setFileSetDownloadDir (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
getFileSetDownloadDir (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
setFlashTransferDir (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
addFileSetDownloadListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetDownloadListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
/**
|
||||
* 开始下载file set的函数 同开始上传
|
||||
* @param fileSetId
|
||||
* @param downloadSceneType 下载类型 //因为没有peer,其实可以硬编码为1 (好友私聊)
|
||||
* @param arg // 默认为false
|
||||
*/
|
||||
startFileSetDownload (fileSetId: string, downloadSceneType: DownloadSceneType, downloadOptionParams: { isIncludeCompressInnerFiles: boolean; }): Promise<GeneralCallResult & {
|
||||
extraInfo: 0;
|
||||
}>; // 3 arg
|
||||
|
||||
stopFileSetDownload (fileSetId: string, downloadOptionParams: { isIncludeCompressInnerFiles: boolean; }): Promise<GeneralCallResult & {
|
||||
extraInfo: 0;
|
||||
}>; // 2 arg 结束不可重启!!
|
||||
|
||||
pauseFileSetDownload (fileSetId: string, downloadOptionParams: { isIncludeCompressInnerFiles: boolean; }): Promise<GeneralCallResult & {
|
||||
extraInfo: 0;
|
||||
}>; // 2 arg
|
||||
|
||||
resumeFileSetDownload (fileSetId: string, downloadOptionParams: { isIncludeCompressInnerFiles: boolean; }): Promise<GeneralCallResult & {
|
||||
extraInfo: 0;
|
||||
}>; // 2 arg
|
||||
|
||||
startFileListDownLoad (...args: unknown[]): unknown; // 4 arg // 大概率是选择set里面的部分文件进行下载,没必要,不想写
|
||||
|
||||
pauseFileListDownLoad (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
resumeFileListDownLoad (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
stopFileListDownLoad (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
startThumbnailListDownload (fileSetId: string): Promise<GeneralCallResult>; // 1 arg // 缩略图下载
|
||||
|
||||
stopThumbnailListDownload (fileSetId: string): Promise<GeneralCallResult>; // 1 arg
|
||||
|
||||
asyncRequestDownLoadStatus (fileSetId: string): Promise<DownloadStatusInfo>; // 1 arg
|
||||
|
||||
startFileTransferUrl (fileInfo: FlashOneFileInfo): Promise<{
|
||||
ret: number,
|
||||
url: string,
|
||||
expireTimestampSeconds: string;
|
||||
}>; // 1 arg
|
||||
|
||||
startFileListDownLoadBySessionId (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
addFileSetSimpleStatusListener (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
addFileSetSimpleStatusMonitoring (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
removeFileSetSimpleStatusMonitoring (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
removeFileSetSimpleStatusListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addDesktopFileSetSimpleStatusListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addDesktopFileSetSimpleStatusMonitoring (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeDesktopFileSetSimpleStatusMonitoring (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeDesktopFileSetSimpleStatusListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addFileSetSimpleUploadInfoListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addFileSetSimpleUploadInfoMonitoring (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetSimpleUploadInfoMonitoring (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetSimpleUploadInfoListener (...args: unknown[]): unknown; // 1 arg
|
||||
/**
|
||||
* 发送闪传消息
|
||||
* @param sendArgs
|
||||
*/
|
||||
sendFlashTransferMsg (sendArgs: SendTargetRequests): Promise<{
|
||||
errCode: number,
|
||||
errMsg: string,
|
||||
rsp: {
|
||||
sendStatus: SendStatus[];
|
||||
};
|
||||
}>; // 1 arg 估计是file set id
|
||||
|
||||
addFlashTransferTaskInfoListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFlashTransferTaskInfoListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
retrieveLocalLastFailedSetTasksInfo (): unknown; // 0 arg
|
||||
|
||||
getFailedFileList (fileSetId: string): Promise<{
|
||||
rsp: {
|
||||
seq: number;
|
||||
result: number;
|
||||
errMs: string;
|
||||
fileSetId: string;
|
||||
fileList: [];
|
||||
};
|
||||
}>; // 1 arg
|
||||
|
||||
getLocalFileListByStatuses (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addTransferStateListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeTransferStateListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
getFileSetFirstClusteringList (...args: unknown[]): unknown; // 3 arg
|
||||
|
||||
getFileSetClusteringList (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addFileSetClusteringListListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFileSetClusteringListListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
getFileSetClusteringDetail (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
doAIOFlashTransferBubbleActionWithStatus (...args: unknown[]): unknown; // 4 arg
|
||||
|
||||
getFilesTransferProgress (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
pollFilesTransferProgress (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
cancelPollFilesTransferProgress (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
checkDownloadStatusBeforeLocalFileOper (...args: unknown[]): unknown; // 3 arg
|
||||
|
||||
getCompressedFileFolder (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
addFolderListener (...args: unknown[]): unknown; // 1 arg
|
||||
|
||||
removeFolderListener (...args: unknown[]): unknown;
|
||||
|
||||
addCompressedFileListener (...args: unknown[]): unknown;
|
||||
|
||||
removeCompressedFileListener (...args: unknown[]): unknown;
|
||||
|
||||
getFileCategoryList (...args: unknown[]): unknown;
|
||||
|
||||
addDeviceStatusListener (...args: unknown[]): unknown;
|
||||
|
||||
removeDeviceStatusListener (...args: unknown[]): unknown;
|
||||
|
||||
checkDeviceStatus (...args: unknown[]): unknown;
|
||||
|
||||
pauseAllTasks (...args: unknown[]): unknown; // 2 arg
|
||||
|
||||
resumePausedTasksAfterDeviceStatus (...args: unknown[]): unknown;
|
||||
|
||||
onSystemGoingToSleep (...args: unknown[]): unknown;
|
||||
|
||||
onSystemWokeUp (...args: unknown[]): unknown;
|
||||
|
||||
getFileMetas (...args: unknown[]): unknown;
|
||||
|
||||
addDownloadCntStatisticsListener (...args: unknown[]): unknown;
|
||||
|
||||
removeDownloadCntStatisticsListener (...args: unknown[]): unknown;
|
||||
|
||||
detectPrivacyInfoInPaths (...args: unknown[]): unknown;
|
||||
|
||||
getFileThumbnailUrl (...args: unknown[]): unknown;
|
||||
|
||||
handleDownloadFinishAfterSaveToAlbum (...args: unknown[]): unknown;
|
||||
|
||||
checkBatchFilesDownloadStatus (...args: unknown[]): unknown;
|
||||
|
||||
onCheckAlbumStorageStatusResult (...args: unknown[]): unknown;
|
||||
|
||||
addFileAlbumStorageListener (...args: unknown[]): unknown;
|
||||
|
||||
removeFileAlbumStorageListener (...args: unknown[]): unknown;
|
||||
|
||||
refreshFolderStatus (...args: unknown[]): unknown;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface LoginInitConfig {
|
||||
commonPath: string;
|
||||
clientVer: string;
|
||||
hostName: string;
|
||||
externalVersion: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordLoginRetType {
|
||||
@@ -21,7 +22,7 @@ export interface PasswordLoginRetType {
|
||||
jumpWord: string;
|
||||
tipsTitle: string;
|
||||
tipsContent: string;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface PasswordLoginArgType {
|
||||
@@ -55,37 +56,37 @@ export interface QuickLoginResult {
|
||||
jumpUrl: string,
|
||||
jumpWord: string,
|
||||
tipsTitle: string,
|
||||
tipsContent: string
|
||||
tipsContent: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeIKernelLoginService {
|
||||
getMsfStatus: () => number;
|
||||
|
||||
setLoginMiscData(arg0: string, value: string): unknown;
|
||||
setLoginMiscData (arg0: string, value: string): unknown;
|
||||
|
||||
getMachineGuid(): string;
|
||||
getMachineGuid (): string;
|
||||
|
||||
get(): NodeIKernelLoginService;
|
||||
get (): NodeIKernelLoginService;
|
||||
|
||||
connect(): boolean;
|
||||
connect (): boolean;
|
||||
|
||||
addKernelLoginListener(listener: NodeIKernelLoginListener): number;
|
||||
addKernelLoginListener (listener: NodeIKernelLoginListener): number;
|
||||
|
||||
removeKernelLoginListener(listener: number): void;
|
||||
removeKernelLoginListener (listener: number): void;
|
||||
|
||||
initConfig(config: LoginInitConfig): void;
|
||||
initConfig (config: LoginInitConfig): void;
|
||||
|
||||
getLoginMiscData(data: string): Promise<GeneralCallResult & { value: string }>;
|
||||
getLoginMiscData (data: string): Promise<GeneralCallResult & { value: string; }>;
|
||||
|
||||
getLoginList(): Promise<{
|
||||
getLoginList (): Promise<{
|
||||
result: number, // 0是ok
|
||||
LocalLoginInfoList: LoginListItem[]
|
||||
LocalLoginInfoList: LoginListItem[];
|
||||
}>;
|
||||
|
||||
quickLoginWithUin(uin: string): Promise<QuickLoginResult>;
|
||||
quickLoginWithUin (uin: string): Promise<QuickLoginResult>;
|
||||
|
||||
passwordLogin(param: PasswordLoginArgType): Promise<unknown>;
|
||||
passwordLogin (param: PasswordLoginArgType): Promise<QuickLoginResult>;
|
||||
|
||||
getQRCodePicture(): boolean;
|
||||
getQRCodePicture (): boolean;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
||||
import { GeneralCallResult } from './common';
|
||||
|
||||
export interface NodeIKernelNodeMiscService {
|
||||
writeVersionToRegistry(version: string): void;
|
||||
writeVersionToRegistry (version: string): void;
|
||||
|
||||
getMiniAppPath(): unknown;
|
||||
getMiniAppPath (): unknown;
|
||||
|
||||
setMiniAppVersion(version: string): unknown;
|
||||
setMiniAppVersion (version: string): unknown;
|
||||
|
||||
wantWinScreenOCR(imagepath: string): Promise<GeneralCallResult>;
|
||||
wantWinScreenOCR (imagepath: string): Promise<GeneralCallResult>;
|
||||
|
||||
SendMiniAppMsg(arg1: string, arg2: string, arg3: string): unknown;
|
||||
SendMiniAppMsg (arg1: string, arg2: string, arg3: string): unknown;
|
||||
|
||||
startNewMiniApp(appfile: string, params: string): unknown;
|
||||
startNewMiniApp (appfile: string, params: string): unknown;
|
||||
|
||||
getQimei36WithNewSdk (): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum GeneralCallResultStatus {
|
||||
OK = 0,
|
||||
ERROR = -1,
|
||||
}
|
||||
|
||||
export interface GeneralCallResult {
|
||||
|
||||
21
packages/napcat-core/types/flashfile.ts
Normal file
21
packages/napcat-core/types/flashfile.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export enum fileType {
|
||||
MP3 = 1,
|
||||
VIDEO = 2,
|
||||
DOC = 3,
|
||||
ZIP = 4,
|
||||
XLS = 6,
|
||||
PPT = 7,
|
||||
CODE = 8,
|
||||
PDF = 9,
|
||||
TXT = 10,
|
||||
UNKNOW = 11,
|
||||
FOLDER = 25,
|
||||
IMG = 26,
|
||||
}
|
||||
|
||||
export enum FileStatus {
|
||||
UPLOADING = 0,
|
||||
// DOWNLOADED = 1, ??? 不太清楚
|
||||
OK = 2,
|
||||
STOP = 3,
|
||||
}
|
||||
@@ -11,3 +11,7 @@ export * from './constant';
|
||||
export * from './graytip';
|
||||
export * from './emoji';
|
||||
export * from './service';
|
||||
export * from './adapter';
|
||||
export * from './contact';
|
||||
export * from './file';
|
||||
export * from './flashfile';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NTGroupMemberRole } from '@/napcat-core/index';
|
||||
import { NTGroupMemberRole } from './group';
|
||||
import { ActionBarElement, ArkElement, AvRecordElement, CalendarElement, FaceBubbleElement, FaceElement, FileElement, GiphyElement, GrayTipElement, MarketFaceElement, PicElement, PttElement, RecommendedMsgElement, ReplyElement, ShareLocationElement, StructLongMsgElement, TaskTopMsgElement, TextElement, TofuRecordElement, VideoElement, YoloGameResultElement } from './element';
|
||||
|
||||
/*
|
||||
@@ -66,13 +66,14 @@ export enum ElementType {
|
||||
YOLOGAMERESULT = 20,
|
||||
AVRECORD = 21,
|
||||
FEED = 22,
|
||||
TOFURECORD = 23,
|
||||
TOFURECORD = 23, // tofu record?? 在线文件的id是这个
|
||||
ACEBUBBLE = 24,
|
||||
ACTIVITY = 25,
|
||||
TOFU = 26,
|
||||
FACEBUBBLE = 27,
|
||||
SHARELOCATION = 28,
|
||||
TASKTOPMSG = 29,
|
||||
ONLINEFOLDER = 30, // 在线文件夹
|
||||
RECOMMENDEDMSG = 43,
|
||||
ACTIONBAR = 44,
|
||||
}
|
||||
@@ -181,7 +182,7 @@ export interface MessageElement {
|
||||
tofuRecordElement?: TofuRecordElement,
|
||||
taskTopMsgElement?: TaskTopMsgElement,
|
||||
recommendedMsgElement?: RecommendedMsgElement,
|
||||
actionBarElement?: ActionBarElement
|
||||
actionBarElement?: ActionBarElement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,11 +304,40 @@ export enum NTVideoType {
|
||||
VIDEO_FORMAT_WMV = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* 闪传图标
|
||||
*/
|
||||
export interface FlashTransferIcon {
|
||||
spec: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 闪传文件信息
|
||||
*/
|
||||
export interface FlashTransferInfo {
|
||||
filesetId: string;
|
||||
name: string;
|
||||
fileSize: string;
|
||||
thnumbnail: {
|
||||
id: string;
|
||||
urls: FlashTransferIcon[];
|
||||
localCachePath: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown元素接口
|
||||
*/
|
||||
export interface MarkdownElement {
|
||||
content: string;
|
||||
style?: {};
|
||||
processMsg?: string;
|
||||
mdSummary?: string;
|
||||
mdExtType?: number;
|
||||
mdExtInfo?: {
|
||||
flashTransferInfo: FlashTransferInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,7 +367,7 @@ export interface InlineKeyboardElementRowButton {
|
||||
*/
|
||||
export interface InlineKeyboardElement {
|
||||
rows: [{
|
||||
buttons: InlineKeyboardElementRowButton[]
|
||||
buttons: InlineKeyboardElementRowButton[];
|
||||
}],
|
||||
botAppid: string;
|
||||
}
|
||||
@@ -441,14 +471,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 +528,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 +539,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 +585,7 @@ export interface MsgReqType {
|
||||
queryOrder: boolean,
|
||||
includeSelf: boolean,
|
||||
includeDeleteMsg: boolean,
|
||||
extraCnt: number
|
||||
extraCnt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,24 +57,24 @@ export interface BaseInfo {
|
||||
}
|
||||
|
||||
// 音乐信息
|
||||
interface MusicInfo {
|
||||
export interface MusicInfo {
|
||||
buf: string;
|
||||
}
|
||||
|
||||
// 视频业务信息
|
||||
interface VideoBizInfo {
|
||||
export interface VideoBizInfo {
|
||||
cid: string;
|
||||
tvUrl: string;
|
||||
synchType: string;
|
||||
}
|
||||
|
||||
// 视频信息
|
||||
interface VideoInfo {
|
||||
export interface VideoInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 扩展在线业务信息
|
||||
interface ExtOnlineBusinessInfo {
|
||||
export interface ExtOnlineBusinessInfo {
|
||||
buf: string;
|
||||
customStatus: unknown;
|
||||
videoBizInfo: VideoBizInfo;
|
||||
@@ -82,12 +82,12 @@ interface ExtOnlineBusinessInfo {
|
||||
}
|
||||
|
||||
// 扩展缓冲区
|
||||
interface ExtBuffer {
|
||||
export interface ExtBuffer {
|
||||
buf: string;
|
||||
}
|
||||
|
||||
// 用户状态
|
||||
interface UserStatus {
|
||||
export interface UserStatus {
|
||||
uid: string;
|
||||
uin: string;
|
||||
status: number;
|
||||
@@ -109,14 +109,14 @@ interface UserStatus {
|
||||
}
|
||||
|
||||
// 特权图标
|
||||
interface PrivilegeIcon {
|
||||
export interface PrivilegeIcon {
|
||||
jumpUrl: string;
|
||||
openIconList: unknown[];
|
||||
closeIconList: unknown[];
|
||||
}
|
||||
|
||||
// 增值服务信息
|
||||
interface VasInfo {
|
||||
export interface VasInfo {
|
||||
vipFlag: boolean;
|
||||
yearVipFlag: boolean;
|
||||
svipFlag: boolean;
|
||||
@@ -149,7 +149,7 @@ interface VasInfo {
|
||||
}
|
||||
|
||||
// 关系标志
|
||||
interface RelationFlags {
|
||||
export interface RelationFlags {
|
||||
topTime: string;
|
||||
isBlock: boolean;
|
||||
isMsgDisturb: boolean;
|
||||
@@ -167,7 +167,7 @@ interface RelationFlags {
|
||||
}
|
||||
|
||||
// 通用扩展信息
|
||||
interface CommonExt {
|
||||
export interface CommonExt {
|
||||
constellation: number;
|
||||
shengXiao: number;
|
||||
kBloodType: number;
|
||||
@@ -193,14 +193,14 @@ export enum BuddyListReqType {
|
||||
}
|
||||
|
||||
// 图片信息
|
||||
interface Pic {
|
||||
export interface Pic {
|
||||
picId: string;
|
||||
picTime: number;
|
||||
picUrlMap: Record<string, string>;
|
||||
}
|
||||
|
||||
// 照片墙
|
||||
interface PhotoWall {
|
||||
export interface PhotoWall {
|
||||
picList: Pic[];
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export interface ModifyProfileParams {
|
||||
nick: string;
|
||||
longNick: string;
|
||||
sex: NTSex;
|
||||
birthday: { birthday_year: string, birthday_month: string, birthday_day: string };
|
||||
birthday: { birthday_year: string, birthday_month: string, birthday_day: string; };
|
||||
location: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NodeIDependsAdapter, NodeIDispatcherAdapter, NodeIGlobalAdapter } from './adapters';
|
||||
import {
|
||||
GeneralCallResult,
|
||||
NodeIKernelAvatarService,
|
||||
NodeIKernelBuddyService,
|
||||
NodeIKernelGroupService,
|
||||
@@ -27,77 +28,78 @@ import { NodeIKernelMSFService } from './services/NodeIKernelMSFService';
|
||||
import { NodeIkernelTestPerformanceService } from './services/NodeIkernelTestPerformanceService';
|
||||
import { NodeIKernelECDHService } from './services/NodeIKernelECDHService';
|
||||
import { NodeIO3MiscService } from './services/NodeIO3MiscService';
|
||||
import { NodeIKernelFlashTransferService } from './services/NodeIKernelFlashTransferService';
|
||||
|
||||
export interface NodeQQNTWrapperUtil {
|
||||
get(): NodeQQNTWrapperUtil;
|
||||
get (): NodeQQNTWrapperUtil;
|
||||
|
||||
getNTUserDataInfoConfig(): string;
|
||||
getNTUserDataInfoConfig (): string;
|
||||
|
||||
emptyWorkingSet(n: number): void;
|
||||
emptyWorkingSet (n: number): void;
|
||||
|
||||
getSsoCmdOfOidbReq(arg1: number, arg2: number): unknown;
|
||||
getSsoCmdOfOidbReq (arg1: number, arg2: number): unknown;
|
||||
|
||||
getSsoBufferOfOidbReq(...args: unknown[]): unknown; // 有点看不懂参数定义 待补充 好像是三个参数
|
||||
getSsoBufferOfOidbReq (...args: unknown[]): unknown; // 有点看不懂参数定义 待补充 好像是三个参数
|
||||
|
||||
getOidbRspInfo(arg: string): unknown; // 可能是错的
|
||||
getOidbRspInfo (arg: string): unknown; // 可能是错的
|
||||
|
||||
getFileSize(path: string): Promise<number>; // 直接的猜测
|
||||
getFileSize (path: string): Promise<number>; // 直接的猜测
|
||||
|
||||
genFileMd5Buf(arg: string): unknown; // 可能是错的
|
||||
genFileMd5Buf (arg: string): unknown; // 可能是错的
|
||||
|
||||
genFileMd5Hex(path: string): unknown; // 直接的猜测
|
||||
genFileMd5Hex (path: string): unknown; // 直接的猜测
|
||||
|
||||
genFileShaBuf(path: string): unknown; // 直接的猜测
|
||||
genFileShaBuf (path: string): unknown; // 直接的猜测
|
||||
|
||||
genFileCumulateSha1(path: string): unknown; // 直接的猜测
|
||||
genFileCumulateSha1 (path: string): unknown; // 直接的猜测
|
||||
|
||||
genFileShaHex(path: string): unknown; // 直接的猜测
|
||||
genFileShaHex (path: string): unknown; // 直接的猜测
|
||||
|
||||
fileIsExist(path: string): unknown;
|
||||
fileIsExist (path: string): unknown;
|
||||
|
||||
startTrace(path: string): unknown; // 可能是错的
|
||||
startTrace (path: string): unknown; // 可能是错的
|
||||
|
||||
copyFile(src: string, dst: string): unknown;
|
||||
copyFile (src: string, dst: string): unknown;
|
||||
|
||||
genFileShaAndMd5Hex(path: string, unknown: number): unknown; // 可能是错的
|
||||
genFileShaAndMd5Hex (path: string, unknown: number): unknown; // 可能是错的
|
||||
|
||||
setTraceInfo(unknown: unknown): unknown;
|
||||
setTraceInfo (unknown: unknown): unknown;
|
||||
|
||||
encodeOffLine(unknown: unknown): unknown;
|
||||
encodeOffLine (unknown: unknown): unknown;
|
||||
|
||||
decodeOffLine(arg: string): unknown; // 可能是错的 传递hex
|
||||
decodeOffLine (arg: string): unknown; // 可能是错的 传递hex
|
||||
|
||||
DecoderRecentInfo(arg: string): unknown; // 可能是错的 传递hex
|
||||
DecoderRecentInfo (arg: string): unknown; // 可能是错的 传递hex
|
||||
|
||||
getPinyin(arg0: string, arg1: boolean): unknown;
|
||||
getPinyin (arg0: string, arg1: boolean): unknown;
|
||||
|
||||
matchInPinyin(arg0: unknown[], arg1: string): unknown; // 参数特复杂 arg0是个复杂数据类型
|
||||
matchInPinyin (arg0: unknown[], arg1: string): unknown; // 参数特复杂 arg0是个复杂数据类型
|
||||
|
||||
makeDirByPath(arg0: string): unknown;
|
||||
makeDirByPath (arg0: string): unknown;
|
||||
|
||||
emptyWorkingSet(arg0: number): unknown; // 参数是UINT32
|
||||
emptyWorkingSet (arg0: number): unknown; // 参数是UINT32
|
||||
|
||||
runProcess(arg0: string, arg1: boolean): unknown;
|
||||
runProcess (arg0: string, arg1: boolean): unknown;
|
||||
|
||||
runProcessArgs(arg0: string, arg1: { [key: string]: string }, arg2: boolean): unknown;
|
||||
runProcessArgs (arg0: string, arg1: { [key: string]: string; }, arg2: boolean): unknown;
|
||||
|
||||
calcThumbSize(arg0: number, arg1: number, arg2: unknown): unknown;
|
||||
calcThumbSize (arg0: number, arg1: number, arg2: unknown): unknown;
|
||||
|
||||
fullWordToHalfWord(word: string): unknown;
|
||||
fullWordToHalfWord (word: string): unknown;
|
||||
|
||||
getNTUserDataInfoConfig(): unknown;
|
||||
getNTUserDataInfoConfig (): Promise<string>;
|
||||
|
||||
pathIsReadableAndWriteable(path: string): unknown; // 直接的猜测
|
||||
pathIsReadableAndWriteable (path: string, type: number): Promise<number>; // type 2 , result 0 成功
|
||||
|
||||
resetUserDataSavePathToDocument(): unknown;
|
||||
resetUserDataSavePathToDocument (): unknown;
|
||||
|
||||
getSoBuildInfo(): unknown; // 例如 0[0]_d491dc01e0a_0
|
||||
getSoBuildInfo (): unknown; // 例如 0[0]_d491dc01e0a_0
|
||||
|
||||
registerCountInstruments(arg0: string, arg1: string[], arg2: number, arg3: number): unknown;
|
||||
registerCountInstruments (arg0: string, arg1: string[], arg2: number, arg3: number): unknown;
|
||||
|
||||
registerValueInstruments(arg0: string, arg1: string[], arg2: number, arg3: number): unknown;
|
||||
registerValueInstruments (arg0: string, arg1: string[], arg2: number, arg3: number): unknown;
|
||||
|
||||
registerValueInstrumentsWithBoundary(
|
||||
registerValueInstrumentsWithBoundary (
|
||||
arg0: string,
|
||||
arg1: unknown,
|
||||
arg2: unknown,
|
||||
@@ -105,7 +107,7 @@ export interface NodeQQNTWrapperUtil {
|
||||
arg4: number,
|
||||
): unknown;
|
||||
|
||||
reportCountIndicators(
|
||||
reportCountIndicators (
|
||||
arg0: string,
|
||||
arg1: Map<unknown, unknown>,
|
||||
arg2: string,
|
||||
@@ -113,7 +115,7 @@ export interface NodeQQNTWrapperUtil {
|
||||
arg4: boolean,
|
||||
): unknown;
|
||||
|
||||
reportValueIndicators(
|
||||
reportValueIndicators (
|
||||
arg0: string,
|
||||
arg1: Map<unknown, unknown>,
|
||||
arg2: string,
|
||||
@@ -121,140 +123,154 @@ export interface NodeQQNTWrapperUtil {
|
||||
arg4: number,
|
||||
): unknown;
|
||||
|
||||
checkNewUserDataSaveDirAvailable(arg0: string): unknown;
|
||||
checkNewUserDataSaveDirAvailable (arg0: string): unknown;
|
||||
|
||||
copyUserData(arg0: string, arg1: string): Promise<unknown>;
|
||||
copyUserData (arg0: string, arg1: string): Promise<unknown>;
|
||||
|
||||
setUserDataSaveDirectory(arg0: string): Promise<unknown>;
|
||||
setUserDataSaveDirectory (arg0: string): Promise<unknown>;
|
||||
|
||||
hasOtherRunningQQProcess(): boolean;
|
||||
hasOtherRunningQQProcess (): boolean;
|
||||
|
||||
quitAllRunningQQProcess(arg: boolean): unknown;
|
||||
quitAllRunningQQProcess (arg: boolean): unknown;
|
||||
|
||||
checkNvidiaConfig(): unknown;
|
||||
checkNvidiaConfig (): unknown;
|
||||
|
||||
repairNvidiaConfig(): unknown;
|
||||
repairNvidiaConfig (): unknown;
|
||||
|
||||
getNvidiaDriverVersion(): unknown;
|
||||
getNvidiaDriverVersion (): unknown;
|
||||
|
||||
isNull(): unknown;
|
||||
isNull (): unknown;
|
||||
|
||||
createThumbnailImage (
|
||||
serviceName: string,
|
||||
filePath: string,
|
||||
targetPath: string,
|
||||
imgSize: {
|
||||
width: number,
|
||||
height: number;
|
||||
},
|
||||
fileFormat: string,
|
||||
arg: number | null | undefined, // null undefined都行
|
||||
): Promise<GeneralCallResult & { targetPath?: string; }>;
|
||||
}
|
||||
export interface NodeIQQNTStartupSessionWrapper {
|
||||
create(): NodeIQQNTStartupSessionWrapper;
|
||||
stop(): void;
|
||||
start(): void;
|
||||
createWithModuleList(uk: unknown): unknown;
|
||||
getSessionIdList(): unknown;
|
||||
create (): NodeIQQNTStartupSessionWrapper;
|
||||
stop (): void;
|
||||
start (): void;
|
||||
createWithModuleList (uk: unknown): unknown;
|
||||
getSessionIdList (): Promise<Map<unknown, unknown>>;
|
||||
}
|
||||
export interface NodeIQQNTWrapperSession {
|
||||
getNTWrapperSession(str: string): NodeIQQNTWrapperSession;
|
||||
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;
|
||||
|
||||
get(): NodeIQQNTWrapperSession;
|
||||
get (): NodeIQQNTWrapperSession;
|
||||
|
||||
new(): NodeIQQNTWrapperSession;
|
||||
|
||||
create(): NodeIQQNTWrapperSession;
|
||||
create (): NodeIQQNTWrapperSession;
|
||||
|
||||
init(
|
||||
init (
|
||||
wrapperSessionInitConfig: WrapperSessionInitConfig,
|
||||
nodeIDependsAdapter: NodeIDependsAdapter,
|
||||
nodeIDispatcherAdapter: NodeIDispatcherAdapter,
|
||||
nodeIKernelSessionListener: NodeIKernelSessionListener,
|
||||
): void;
|
||||
|
||||
startNT(session: number): void;
|
||||
startNT (session: number): void;
|
||||
|
||||
startNT(): void;
|
||||
startNT (): void;
|
||||
|
||||
getBdhUploadService(): unknown;
|
||||
getBdhUploadService (): unknown;
|
||||
|
||||
getECDHService(): NodeIKernelECDHService;
|
||||
getECDHService (): NodeIKernelECDHService;
|
||||
|
||||
getMsgService(): NodeIKernelMsgService;
|
||||
getMsgService (): NodeIKernelMsgService;
|
||||
|
||||
getProfileService(): NodeIKernelProfileService;
|
||||
getProfileService (): NodeIKernelProfileService;
|
||||
|
||||
getProfileLikeService(): NodeIKernelProfileLikeService;
|
||||
getProfileLikeService (): NodeIKernelProfileLikeService;
|
||||
|
||||
getGroupService(): NodeIKernelGroupService;
|
||||
getGroupService (): NodeIKernelGroupService;
|
||||
|
||||
getStorageCleanService(): NodeIKernelStorageCleanService;
|
||||
getStorageCleanService (): NodeIKernelStorageCleanService;
|
||||
|
||||
getBuddyService(): NodeIKernelBuddyService;
|
||||
getBuddyService (): NodeIKernelBuddyService;
|
||||
|
||||
getRobotService(): NodeIKernelRobotService;
|
||||
getRobotService (): NodeIKernelRobotService;
|
||||
|
||||
getTicketService(): NodeIKernelTicketService;
|
||||
getTicketService (): NodeIKernelTicketService;
|
||||
|
||||
getTipOffService(): NodeIKernelTipOffService;
|
||||
getTipOffService (): NodeIKernelTipOffService;
|
||||
|
||||
getNodeMiscService(): NodeIKernelNodeMiscService;
|
||||
getNodeMiscService (): NodeIKernelNodeMiscService;
|
||||
|
||||
getRichMediaService(): NodeIKernelRichMediaService;
|
||||
getRichMediaService (): NodeIKernelRichMediaService;
|
||||
|
||||
getMsgBackupService(): NodeIKernelMsgBackupService;
|
||||
getMsgBackupService (): NodeIKernelMsgBackupService;
|
||||
|
||||
getAlbumService(): NodeIKernelAlbumService;
|
||||
getAlbumService (): NodeIKernelAlbumService;
|
||||
|
||||
getTianShuService(): NodeIKernelTianShuService;
|
||||
getTianShuService (): NodeIKernelTianShuService;
|
||||
|
||||
getUnitedConfigService(): NodeIKernelUnitedConfigService;
|
||||
getUnitedConfigService (): NodeIKernelUnitedConfigService;
|
||||
|
||||
getSearchService(): NodeIKernelSearchService;
|
||||
getSearchService (): NodeIKernelSearchService;
|
||||
|
||||
getDirectSessionService(): unknown;
|
||||
getFlashTransferService (): NodeIKernelFlashTransferService;
|
||||
|
||||
getRDeliveryService(): unknown;
|
||||
getDirectSessionService (): unknown;
|
||||
|
||||
getAvatarService(): NodeIKernelAvatarService;
|
||||
getRDeliveryService (): unknown;
|
||||
|
||||
getFeedChannelService(): unknown;
|
||||
getAvatarService (): NodeIKernelAvatarService;
|
||||
|
||||
getYellowFaceService(): unknown;
|
||||
getFeedChannelService (): unknown;
|
||||
|
||||
getCollectionService(): NodeIKernelCollectionService;
|
||||
getYellowFaceService (): unknown;
|
||||
|
||||
getSettingService(): unknown;
|
||||
getCollectionService (): NodeIKernelCollectionService;
|
||||
|
||||
getQiDianService(): unknown;
|
||||
getSettingService (): unknown;
|
||||
|
||||
getFileAssistantService(): unknown;
|
||||
getQiDianService (): unknown;
|
||||
|
||||
getGuildService(): unknown;
|
||||
getFileAssistantService (): unknown;
|
||||
|
||||
getSkinService(): unknown;
|
||||
getGuildService (): unknown;
|
||||
|
||||
getTestPerformanceService(): NodeIkernelTestPerformanceService;
|
||||
getSkinService (): unknown;
|
||||
|
||||
getQQPlayService(): unknown;
|
||||
getTestPerformanceService (): NodeIkernelTestPerformanceService;
|
||||
|
||||
getDbToolsService(): unknown;
|
||||
getQQPlayService (): unknown;
|
||||
|
||||
getUixConvertService(): NodeIKernelUixConvertService;
|
||||
getDbToolsService (): unknown;
|
||||
|
||||
getOnlineStatusService(): unknown;
|
||||
getUixConvertService (): NodeIKernelUixConvertService;
|
||||
|
||||
getRemotingService(): unknown;
|
||||
getOnlineStatusService (): unknown;
|
||||
|
||||
getGroupTabService(): unknown;
|
||||
getRemotingService (): unknown;
|
||||
|
||||
getGroupSchoolService(): unknown;
|
||||
getGroupTabService (): unknown;
|
||||
|
||||
getLiteBusinessService(): unknown;
|
||||
getGroupSchoolService (): unknown;
|
||||
|
||||
getGuildMsgService(): unknown;
|
||||
getLiteBusinessService (): unknown;
|
||||
|
||||
getLockService(): unknown;
|
||||
getGuildMsgService (): unknown;
|
||||
|
||||
getMSFService(): NodeIKernelMSFService;
|
||||
getLockService (): unknown;
|
||||
|
||||
getGuildHotUpdateService(): unknown;
|
||||
getMSFService (): NodeIKernelMSFService;
|
||||
|
||||
getAVSDKService(): unknown;
|
||||
getGuildHotUpdateService (): unknown;
|
||||
|
||||
getRecentContactService(): NodeIKernelRecentContactService;
|
||||
getAVSDKService (): unknown;
|
||||
|
||||
getConfigMgrService(): unknown;
|
||||
getRecentContactService (): NodeIKernelRecentContactService;
|
||||
|
||||
getConfigMgrService (): unknown;
|
||||
}
|
||||
|
||||
export interface EnginInitDesktopConfig {
|
||||
@@ -268,20 +284,20 @@ export interface EnginInitDesktopConfig {
|
||||
global_path_config: {
|
||||
desktopGlobalPath: string;
|
||||
};
|
||||
thumb_config: { maxSide: 324; minSide: 48; longLimit: 6; density: 2 };
|
||||
thumb_config: { maxSide: 324; minSide: 48; longLimit: 6; density: 2; };
|
||||
}
|
||||
|
||||
export interface NodeIQQNTWrapperEngine {
|
||||
get(): NodeIQQNTWrapperEngine;
|
||||
get (): NodeIQQNTWrapperEngine;
|
||||
|
||||
initWithDeskTopConfig(config: EnginInitDesktopConfig, nodeIGlobalAdapter: NodeIGlobalAdapter): void;
|
||||
initWithDeskTopConfig (config: EnginInitDesktopConfig, nodeIGlobalAdapter: NodeIGlobalAdapter): void;
|
||||
}
|
||||
|
||||
export interface WrapperNodeApi {
|
||||
NodeIO3MiscService: NodeIO3MiscService;
|
||||
NodeQQNTWrapperUtil: NodeQQNTWrapperUtil;
|
||||
NodeIQQNTWrapperSession: NodeIQQNTWrapperSession;
|
||||
NodeIQQNTStartupSessionWrapper: NodeIQQNTStartupSessionWrapper
|
||||
NodeIQQNTStartupSessionWrapper: NodeIQQNTStartupSessionWrapper;
|
||||
NodeIQQNTWrapperEngine: NodeIQQNTWrapperEngine;
|
||||
NodeIKernelLoginService: NodeIKernelLoginService;
|
||||
|
||||
|
||||
4
packages/napcat-develop/config/.env.example
Normal file
4
packages/napcat-develop/config/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
NAPCAT_DISABLE_PIPE=1
|
||||
NAPCAT_DISABLE_MULTI_PROCESS=1
|
||||
NAPCAT_WEBUI_JWT_SECRET_KEY=napcat_dev_secret_key
|
||||
NAPCAT_WEBUI_SECRET_KEY=napcatqq
|
||||
39
packages/napcat-develop/config/onebot11.json
Normal file
39
packages/napcat-develop/config/onebot11.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"network": {
|
||||
"httpServers": [
|
||||
{
|
||||
"enable": true,
|
||||
"name": "HTTP",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3000,
|
||||
"enableCors": true,
|
||||
"enableWebsocket": false,
|
||||
"messagePostFormat": "array",
|
||||
"token": "",
|
||||
"debug": false
|
||||
}
|
||||
],
|
||||
"httpSseServers": [],
|
||||
"httpClients": [],
|
||||
"websocketServers": [
|
||||
{
|
||||
"enable": true,
|
||||
"name": "WebSocket",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3001,
|
||||
"reportSelfMessage": false,
|
||||
"enableForcePushEvent": true,
|
||||
"messagePostFormat": "array",
|
||||
"token": "",
|
||||
"debug": false,
|
||||
"heartInterval": 30000
|
||||
}
|
||||
],
|
||||
"websocketClients": [],
|
||||
"plugins": []
|
||||
},
|
||||
"musicSignUrl": "",
|
||||
"enableLocalFile2Url": false,
|
||||
"parseMultMsg": false,
|
||||
"imageDownloadProxy": ""
|
||||
}
|
||||
@@ -32,6 +32,7 @@ if (versionFolders.length === 0) {
|
||||
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
|
||||
console.log(`BASE_DIR: ${BASE_DIR}`);
|
||||
const TARGET_DIR = path.join(__dirname, 'dist');
|
||||
const TARGET_WIN64_DIR = path.join(__dirname, 'dist', 'win64');
|
||||
const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
|
||||
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
|
||||
|
||||
@@ -46,6 +47,12 @@ const itemsToCopy = [
|
||||
'package.json',
|
||||
'QBar.dll',
|
||||
'wrapper.node',
|
||||
'LightQuic.dll'
|
||||
];
|
||||
|
||||
const win64ItemsToCopy = [
|
||||
'SSOShareInfoHelper64.dll',
|
||||
'parent-ipc-core-x64.dll'
|
||||
];
|
||||
|
||||
async function copyAll () {
|
||||
@@ -53,13 +60,23 @@ async function copyAll () {
|
||||
const configPath = path.join(TARGET_DIR, 'config.json');
|
||||
const allItemsExist = await fs.pathExists(qqntDllPath) &&
|
||||
await fs.pathExists(configPath) &&
|
||||
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists);
|
||||
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists) &&
|
||||
(await Promise.all(win64ItemsToCopy.map(item => fs.pathExists(path.join(TARGET_WIN64_DIR, item))))).every(exists => exists);
|
||||
|
||||
if (!allItemsExist) {
|
||||
console.log('Copying required files...');
|
||||
await fs.ensureDir(TARGET_DIR);
|
||||
await fs.ensureDir(TARGET_WIN64_DIR);
|
||||
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
|
||||
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
|
||||
|
||||
// 复制 win64 目录下的文件
|
||||
await Promise.all(win64ItemsToCopy.map(async (item) => {
|
||||
await fs.copy(path.join(BASE_DIR, 'win64', item), path.join(TARGET_WIN64_DIR, item), { overwrite: true });
|
||||
console.log(`Copied ${item} to win64`);
|
||||
}));
|
||||
|
||||
// 复制根目录下的文件
|
||||
await Promise.all(itemsToCopy.map(async (item) => {
|
||||
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
|
||||
console.log(`Copied ${item}`);
|
||||
@@ -73,10 +90,12 @@ async function copyAll () {
|
||||
process.env.NAPCAT_QQ_PACKAGE_INFO_PATH = path.join(TARGET_DIR, 'package.json');
|
||||
process.env.NAPCAT_QQ_VERSION_CONFIG_PATH = path.join(TARGET_DIR, 'config.json');
|
||||
process.env.NAPCAT_DISABLE_PIPE = '1';
|
||||
// 禁用重启和多进程功能
|
||||
process.env.NAPCAT_DISABLE_MULTI_PROCESS = '1';
|
||||
process.env.NAPCAT_WORKDIR = TARGET_DIR;
|
||||
// 开发环境使用固定密钥
|
||||
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';
|
||||
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcat';
|
||||
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcatqq';
|
||||
console.log('Loading NapCat module...');
|
||||
await import(pathToFileURL(NAPCAT_MJS_PATH).href);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
{
|
||||
"name": "napcat-develop",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "powershell ./nodeTest.ps1"
|
||||
"name": "napcat-develop",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "powershell ./nodeTest.ps1",
|
||||
"copy-env": "xcopy config ..\\napcat-shell\\dist\\config /E /I /Y"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./index.js"
|
||||
},
|
||||
"./*": {
|
||||
"require": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"fs-extra": "^11.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"require": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"fs-extra": "^11.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/index';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
|
||||
@@ -34,10 +34,11 @@ 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 wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
// nativePacketHandler.onAll((packet) => {
|
||||
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
|
||||
@@ -72,17 +73,20 @@ export async function NCoreInitFramework (
|
||||
// 过早进入会导致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();
|
||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||
const adapterManager = new NapCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
await adapterManager.initAdapters();
|
||||
// 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用
|
||||
const oneBotAdapter = adapterManager.getOneBotAdapter();
|
||||
if (oneBotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
}
|
||||
}
|
||||
|
||||
export class NapCatFramework {
|
||||
@@ -93,7 +97,6 @@ export class NapCatFramework {
|
||||
wrapper: WrapperNodeApi,
|
||||
session: NodeIQQNTWrapperSession,
|
||||
logger: LogWrapper,
|
||||
loginService: NodeIKernelLoginService,
|
||||
selfInfo: SelfInfo,
|
||||
basicInfoWrapper: QQBasicInfoWrapper,
|
||||
pathWrapper: NapCatPathWrapper,
|
||||
@@ -105,7 +108,6 @@ export class NapCatFramework {
|
||||
wrapper,
|
||||
session,
|
||||
logger,
|
||||
loginService,
|
||||
basicInfoWrapper,
|
||||
pathWrapper,
|
||||
};
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"name": "napcat-framework",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-framework",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
@@ -50,7 +49,9 @@ const FrameworkBaseConfig = () =>
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/image-size': resolve(__dirname, '../image-size'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
@@ -60,7 +61,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'],
|
||||
|
||||
BIN
packages/napcat-image-size/resource/test-20x20.jpg
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/napcat-image-size/resource/test-20x20.png
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/napcat-image-size/resource/test-20x20.tiff
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.tiff
Normal file
Binary file not shown.
BIN
packages/napcat-image-size/resource/test-20x20.webp
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 962 B |
BIN
packages/napcat-image-size/resource/test-490x498.gif
Normal file
BIN
packages/napcat-image-size/resource/test-490x498.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -1,5 +1,12 @@
|
||||
import { BmpParser } from '@/napcat-image-size/src/parser/BmpParser';
|
||||
import { GifParser } from '@/napcat-image-size/src/parser/GifParser';
|
||||
import { JpegParser } from '@/napcat-image-size/src/parser/JpegParser';
|
||||
import { PngParser } from '@/napcat-image-size/src/parser/PngParser';
|
||||
import { TiffParser } from '@/napcat-image-size/src/parser/TiffParser';
|
||||
import { WebpParser } from '@/napcat-image-size/src/parser/WebpParser';
|
||||
import * as fs from 'fs';
|
||||
import { ReadStream } from 'fs';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface ImageSize {
|
||||
width: number;
|
||||
@@ -12,17 +19,18 @@ export enum ImageType {
|
||||
BMP = 'bmp',
|
||||
GIF = 'gif',
|
||||
WEBP = 'webp',
|
||||
TIFF = 'tiff',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
interface ImageParser {
|
||||
export interface ImageParser {
|
||||
readonly type: ImageType;
|
||||
canParse(buffer: Buffer): boolean;
|
||||
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
|
||||
canParse (buffer: Buffer): boolean;
|
||||
parseSize (stream: ReadStream): Promise<ImageSize | undefined>;
|
||||
}
|
||||
|
||||
// 魔术匹配
|
||||
function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||
export function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||
if (buffer.length < offset + magic.length) {
|
||||
return false;
|
||||
}
|
||||
@@ -35,316 +43,39 @@ function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PNG解析器
|
||||
class PngParser implements ImageParser {
|
||||
readonly type = ImageType.PNG;
|
||||
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
|
||||
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
// 所有解析器实例
|
||||
const parserInstances = {
|
||||
png: new PngParser(),
|
||||
jpeg: new JpegParser(),
|
||||
bmp: new BmpParser(),
|
||||
gif: new GifParser(),
|
||||
webp: new WebpParser(),
|
||||
tiff: new TiffParser(),
|
||||
};
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.PNG_SIGNATURE);
|
||||
}
|
||||
// 首字节到可能的图片类型映射,用于快速筛选
|
||||
const firstByteMap = new Map<number, ImageType[]>([
|
||||
[0x42, [ImageType.BMP]], // 'B' - BMP
|
||||
[0x47, [ImageType.GIF]], // 'G' - GIF
|
||||
[0x49, [ImageType.TIFF]], // 'I' - TIFF (II - little endian)
|
||||
[0x4D, [ImageType.TIFF]], // 'M' - TIFF (MM - big endian)
|
||||
[0x52, [ImageType.WEBP]], // 'R' - RIFF (WebP)
|
||||
[0x89, [ImageType.PNG]], // PNG signature
|
||||
[0xFF, [ImageType.JPEG]], // JPEG SOI
|
||||
]);
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(24) as Buffer;
|
||||
if (!buf || buf.length < 24) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt32BE(16);
|
||||
const height = buf.readUInt32BE(20);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// 类型到解析器的映射
|
||||
const typeToParser = new Map<ImageType, ImageParser>([
|
||||
[ImageType.PNG, parserInstances.png],
|
||||
[ImageType.JPEG, parserInstances.jpeg],
|
||||
[ImageType.BMP, parserInstances.bmp],
|
||||
[ImageType.GIF, parserInstances.gif],
|
||||
[ImageType.WEBP, parserInstances.webp],
|
||||
[ImageType.TIFF, parserInstances.tiff],
|
||||
]);
|
||||
|
||||
// JPEG解析器
|
||||
class JpegParser implements ImageParser {
|
||||
readonly type = ImageType.JPEG;
|
||||
// JPEG 魔术头:FF D8
|
||||
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
|
||||
|
||||
// JPEG标记常量
|
||||
private readonly SOF_MARKERS = {
|
||||
SOF0: 0xC0, // 基线DCT
|
||||
SOF1: 0xC1, // 扩展顺序DCT
|
||||
SOF2: 0xC2, // 渐进式DCT
|
||||
SOF3: 0xC3, // 无损
|
||||
} as const;
|
||||
|
||||
// 非SOF标记
|
||||
private readonly NON_SOF_MARKERS: number[] = [
|
||||
0xC4, // DHT
|
||||
0xC8, // JPEG扩展
|
||||
0xCC, // DAC
|
||||
] as const;
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.JPEG_SIGNATURE);
|
||||
}
|
||||
|
||||
isSOFMarker (marker: number): boolean {
|
||||
return (
|
||||
marker === this.SOF_MARKERS.SOF0 ||
|
||||
marker === this.SOF_MARKERS.SOF1 ||
|
||||
marker === this.SOF_MARKERS.SOF2 ||
|
||||
marker === this.SOF_MARKERS.SOF3
|
||||
);
|
||||
}
|
||||
|
||||
isNonSOFMarker (marker: number): boolean {
|
||||
return this.NON_SOF_MARKERS.includes(marker);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise<ImageSize | undefined>((resolve, reject) => {
|
||||
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
|
||||
let buffer = Buffer.alloc(0);
|
||||
let offset = 0;
|
||||
let found = false;
|
||||
|
||||
// 处理错误
|
||||
stream.on('error', (err) => {
|
||||
stream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 处理数据块
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
// 追加新数据到缓冲区
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
|
||||
offset = 0;
|
||||
|
||||
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
|
||||
const bufferSize = buffer.length;
|
||||
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
|
||||
|
||||
// 从JPEG头部后开始扫描
|
||||
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
|
||||
// 寻找FF标记
|
||||
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
|
||||
const marker = buffer[offset + 1];
|
||||
if (!marker) {
|
||||
break;
|
||||
}
|
||||
// 跳过非SOF标记
|
||||
if (this.isNonSOFMarker(marker)) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理SOF标记 (包含尺寸信息)
|
||||
if (this.isSOFMarker(marker)) {
|
||||
// 确保缓冲区中有足够数据读取尺寸
|
||||
if (offset + 9 < bufferSize) {
|
||||
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
|
||||
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
|
||||
found = true;
|
||||
stream.destroy();
|
||||
resolve({ width, height });
|
||||
return;
|
||||
} else {
|
||||
// 如果缓冲区内数据不够,保留当前位置等待更多数据
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset++;
|
||||
}
|
||||
|
||||
// 缓冲区管理: 如果处理了许多数据但没找到标记,
|
||||
// 保留最后N字节用于跨块匹配,丢弃之前的数据
|
||||
if (offset > BUFFER_SIZE) {
|
||||
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
|
||||
if (offset > KEEP_BYTES) {
|
||||
buffer = buffer.subarray(offset - KEEP_BYTES);
|
||||
offset = KEEP_BYTES;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理流结束
|
||||
stream.on('end', () => {
|
||||
if (!found) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// BMP解析器
|
||||
class BmpParser implements ImageParser {
|
||||
readonly type = ImageType.BMP;
|
||||
// BMP 魔术头:42 4D (BM)
|
||||
private readonly BMP_SIGNATURE = [0x42, 0x4D];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.BMP_SIGNATURE);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(26) as Buffer;
|
||||
if (!buf || buf.length < 26) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt32LE(18);
|
||||
const height = buf.readUInt32LE(22);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// GIF解析器
|
||||
class GifParser implements ImageParser {
|
||||
readonly type = ImageType.GIF;
|
||||
// GIF87a 魔术头:47 49 46 38 37 61
|
||||
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
||||
// GIF89a 魔术头:47 49 46 38 39 61
|
||||
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
|
||||
matchMagic(buffer, this.GIF89A_SIGNATURE)
|
||||
);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(10) as Buffer;
|
||||
if (!buf || buf.length < 10) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt16LE(6);
|
||||
const height = buf.readUInt16LE(8);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
||||
class WebpParser implements ImageParser {
|
||||
readonly type = ImageType.WEBP;
|
||||
// WEBP RIFF 头:52 49 46 46 (RIFF)
|
||||
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
|
||||
// WEBP 魔术头:57 45 42 50 (WEBP)
|
||||
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
|
||||
|
||||
// WEBP 块头
|
||||
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
|
||||
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
|
||||
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= 12 &&
|
||||
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
|
||||
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
|
||||
);
|
||||
}
|
||||
|
||||
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
|
||||
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 需要读取足够的字节来检测所有三种格式
|
||||
const MAX_HEADER_SIZE = 32;
|
||||
let totalBytes = 0;
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
stream.on('error', reject);
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
buffer = Buffer.concat([buffer, chunkBuffer]);
|
||||
totalBytes += chunk.length;
|
||||
|
||||
// 检查是否有足够的字节进行格式检测
|
||||
if (totalBytes >= MAX_HEADER_SIZE) {
|
||||
stream.destroy();
|
||||
|
||||
// 检查基本的WEBP签名
|
||||
if (!this.canParse(buffer)) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
|
||||
// 检查chunk头部,位于字节12-15
|
||||
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
|
||||
// VP8格式 - 标准WebP
|
||||
// 宽度和高度在帧头中
|
||||
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
||||
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
||||
return resolve({ width, height });
|
||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
|
||||
// VP8L格式 - 无损WebP
|
||||
// 1字节标记后是14位宽度和14位高度
|
||||
const bits = buffer.readUInt32LE(21);
|
||||
const width = 1 + (bits & 0x3FFF);
|
||||
const height = 1 + ((bits >> 14) & 0x3FFF);
|
||||
return resolve({ width, height });
|
||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
|
||||
// VP8X格式 - 扩展WebP
|
||||
// 24位宽度和高度(减去1)
|
||||
if (!buffer[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
const width = 1 + ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xFFFFFF);
|
||||
const height = 1 + ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xFFFFFF);
|
||||
return resolve({ width, height });
|
||||
} else {
|
||||
// 未知的WebP子格式
|
||||
return resolve(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
// 如果没有读到足够的字节
|
||||
if (totalBytes < MAX_HEADER_SIZE) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parsers: ReadonlyArray<ImageParser> = [
|
||||
new PngParser(),
|
||||
new JpegParser(),
|
||||
new BmpParser(),
|
||||
new GifParser(),
|
||||
new WebpParser(),
|
||||
];
|
||||
// 所有解析器列表(用于回退)
|
||||
const parsers: ReadonlyArray<ImageParser> = Object.values(parserInstances);
|
||||
|
||||
export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -354,18 +85,22 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
end: 63,
|
||||
});
|
||||
|
||||
let buffer: Buffer | null = null;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
stream.once('error', (err) => {
|
||||
stream.on('error', (err) => {
|
||||
stream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.once('readable', () => {
|
||||
buffer = stream.read(64) as Buffer;
|
||||
stream.destroy();
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
chunks.push(chunkBuffer);
|
||||
});
|
||||
|
||||
if (!buffer) {
|
||||
stream.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (buffer.length === 0) {
|
||||
return resolve(ImageType.UNKNOWN);
|
||||
}
|
||||
|
||||
@@ -377,12 +112,6 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
|
||||
resolve(ImageType.UNKNOWN);
|
||||
});
|
||||
|
||||
stream.once('end', () => {
|
||||
if (!buffer) {
|
||||
resolve(ImageType.UNKNOWN);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,7 +119,7 @@ export async function imageSizeFromFile (filePath: string): Promise<ImageSize |
|
||||
try {
|
||||
// 先检测类型
|
||||
const type = await detectImageType(filePath);
|
||||
const parser = parsers.find(p => p.type === type);
|
||||
const parser = typeToParser.get(type);
|
||||
if (!parser) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -422,3 +151,71 @@ export async function imageSizeFallBack (
|
||||
): Promise<ImageSize> {
|
||||
return await imageSizeFromFile(filePath) ?? fallback;
|
||||
}
|
||||
|
||||
// 从 Buffer 创建可读流
|
||||
function bufferToReadStream (buffer: Buffer): ReadStream {
|
||||
const readable = new Readable({
|
||||
read () {
|
||||
this.push(buffer);
|
||||
this.push(null);
|
||||
}
|
||||
});
|
||||
return readable as unknown as ReadStream;
|
||||
}
|
||||
|
||||
// 从 Buffer 检测图片类型(使用首字节快速筛选)
|
||||
export function detectImageTypeFromBuffer (buffer: Buffer): ImageType {
|
||||
if (buffer.length === 0) {
|
||||
return ImageType.UNKNOWN;
|
||||
}
|
||||
|
||||
const firstByte = buffer[0]!;
|
||||
const possibleTypes = firstByteMap.get(firstByte);
|
||||
|
||||
if (possibleTypes) {
|
||||
// 根据首字节快速筛选可能的类型
|
||||
for (const type of possibleTypes) {
|
||||
const parser = typeToParser.get(type);
|
||||
if (parser && parser.canParse(buffer)) {
|
||||
return parser.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:遍历所有解析器
|
||||
for (const parser of parsers) {
|
||||
if (parser.canParse(buffer)) {
|
||||
return parser.type;
|
||||
}
|
||||
}
|
||||
|
||||
return ImageType.UNKNOWN;
|
||||
}
|
||||
|
||||
// 从 Buffer 解析图片尺寸
|
||||
export async function imageSizeFromBuffer (buffer: Buffer): Promise<ImageSize | undefined> {
|
||||
const type = detectImageTypeFromBuffer(buffer);
|
||||
const parser = typeToParser.get(type);
|
||||
if (!parser) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = bufferToReadStream(buffer);
|
||||
return await parser.parseSize(stream);
|
||||
} catch (err) {
|
||||
console.error(`解析图片尺寸出错: ${err}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 从 Buffer 解析图片尺寸,带回退值
|
||||
export async function imageSizeFromBufferFallBack (
|
||||
buffer: Buffer,
|
||||
fallback: ImageSize = {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}
|
||||
): Promise<ImageSize> {
|
||||
return await imageSizeFromBuffer(buffer) ?? fallback;
|
||||
}
|
||||
|
||||
32
packages/napcat-image-size/src/parser/BmpParser.ts
Normal file
32
packages/napcat-image-size/src/parser/BmpParser.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// BMP解析器
|
||||
export class BmpParser implements ImageParser {
|
||||
readonly type = ImageType.BMP;
|
||||
// BMP 魔术头:42 4D (BM)
|
||||
private readonly BMP_SIGNATURE = [0x42, 0x4D];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.BMP_SIGNATURE);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(26) as Buffer;
|
||||
if (!buf || buf.length < 26) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt32LE(18);
|
||||
const height = buf.readUInt32LE(22);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
37
packages/napcat-image-size/src/parser/GifParser.ts
Normal file
37
packages/napcat-image-size/src/parser/GifParser.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// GIF解析器
|
||||
export class GifParser implements ImageParser {
|
||||
readonly type = ImageType.GIF;
|
||||
// GIF87a 魔术头:47 49 46 38 37 61
|
||||
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
||||
// GIF89a 魔术头:47 49 46 38 39 61
|
||||
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
|
||||
matchMagic(buffer, this.GIF89A_SIGNATURE)
|
||||
);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(10) as Buffer;
|
||||
if (!buf || buf.length < 10) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt16LE(6);
|
||||
const height = buf.readUInt16LE(8);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
123
packages/napcat-image-size/src/parser/JpegParser.ts
Normal file
123
packages/napcat-image-size/src/parser/JpegParser.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// JPEG解析器
|
||||
export class JpegParser implements ImageParser {
|
||||
readonly type = ImageType.JPEG;
|
||||
// JPEG 魔术头:FF D8
|
||||
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
|
||||
|
||||
// JPEG标记常量
|
||||
private readonly SOF_MARKERS = {
|
||||
SOF0: 0xC0, // 基线DCT
|
||||
SOF1: 0xC1, // 扩展顺序DCT
|
||||
SOF2: 0xC2, // 渐进式DCT
|
||||
SOF3: 0xC3, // 无损
|
||||
} as const;
|
||||
|
||||
// 非SOF标记
|
||||
private readonly NON_SOF_MARKERS: number[] = [
|
||||
0xC4, // DHT
|
||||
0xC8, // JPEG扩展
|
||||
0xCC, // DAC
|
||||
] as const;
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.JPEG_SIGNATURE);
|
||||
}
|
||||
|
||||
isSOFMarker (marker: number): boolean {
|
||||
return (
|
||||
marker === this.SOF_MARKERS.SOF0 ||
|
||||
marker === this.SOF_MARKERS.SOF1 ||
|
||||
marker === this.SOF_MARKERS.SOF2 ||
|
||||
marker === this.SOF_MARKERS.SOF3
|
||||
);
|
||||
}
|
||||
|
||||
isNonSOFMarker (marker: number): boolean {
|
||||
return this.NON_SOF_MARKERS.includes(marker);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise<ImageSize | undefined>((resolve, reject) => {
|
||||
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
|
||||
let buffer = Buffer.alloc(0);
|
||||
let offset = 0;
|
||||
let found = false;
|
||||
|
||||
// 处理错误
|
||||
stream.on('error', (err) => {
|
||||
stream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 处理数据块
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
// 追加新数据到缓冲区
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
|
||||
offset = 0;
|
||||
|
||||
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
|
||||
const bufferSize = buffer.length;
|
||||
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
|
||||
|
||||
|
||||
// 从JPEG头部后开始扫描
|
||||
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
|
||||
// 寻找FF标记
|
||||
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
|
||||
const marker = buffer[offset + 1];
|
||||
if (!marker) {
|
||||
break;
|
||||
}
|
||||
// 跳过非SOF标记
|
||||
if (this.isNonSOFMarker(marker)) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理SOF标记 (包含尺寸信息)
|
||||
if (this.isSOFMarker(marker)) {
|
||||
// 确保缓冲区中有足够数据读取尺寸
|
||||
if (offset + 9 < bufferSize) {
|
||||
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
|
||||
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
|
||||
found = true;
|
||||
stream.destroy();
|
||||
resolve({ width, height });
|
||||
return;
|
||||
} else {
|
||||
// 如果缓冲区内数据不够,保留当前位置等待更多数据
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset++;
|
||||
}
|
||||
|
||||
// 缓冲区管理: 如果处理了许多数据但没找到标记,
|
||||
// 保留最后N字节用于跨块匹配,丢弃之前的数据
|
||||
if (offset > BUFFER_SIZE) {
|
||||
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
|
||||
if (offset > KEEP_BYTES) {
|
||||
buffer = buffer.subarray(offset - KEEP_BYTES);
|
||||
offset = KEEP_BYTES;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理流结束
|
||||
stream.on('end', () => {
|
||||
if (!found) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
32
packages/napcat-image-size/src/parser/PngParser.ts
Normal file
32
packages/napcat-image-size/src/parser/PngParser.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// PNG解析器
|
||||
export class PngParser implements ImageParser {
|
||||
readonly type = ImageType.PNG;
|
||||
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
|
||||
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return matchMagic(buffer, this.PNG_SIGNATURE);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
stream.once('readable', () => {
|
||||
const buf = stream.read(24) as Buffer;
|
||||
if (!buf || buf.length < 24) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
if (this.canParse(buf)) {
|
||||
const width = buf.readUInt32BE(16);
|
||||
const height = buf.readUInt32BE(20);
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
124
packages/napcat-image-size/src/parser/TiffParser.ts
Normal file
124
packages/napcat-image-size/src/parser/TiffParser.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// TIFF解析器
|
||||
export class TiffParser implements ImageParser {
|
||||
readonly type = ImageType.TIFF;
|
||||
// TIFF Little Endian 魔术头:49 49 2A 00 (II)
|
||||
private readonly TIFF_LE_SIGNATURE = [0x49, 0x49, 0x2A, 0x00];
|
||||
// TIFF Big Endian 魔术头:4D 4D 00 2A (MM)
|
||||
private readonly TIFF_BE_SIGNATURE = [0x4D, 0x4D, 0x00, 0x2A];
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
matchMagic(buffer, this.TIFF_LE_SIGNATURE) ||
|
||||
matchMagic(buffer, this.TIFF_BE_SIGNATURE)
|
||||
);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
const MAX_BYTES = 64 * 1024; // 最多读取 64KB
|
||||
|
||||
stream.on('error', reject);
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
chunks.push(chunkBuffer);
|
||||
totalBytes += chunkBuffer.length;
|
||||
|
||||
if (totalBytes >= MAX_BYTES) {
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const size = this.parseTiffSize(buffer);
|
||||
resolve(size);
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
if (chunks.length > 0) {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const size = this.parseTiffSize(buffer);
|
||||
resolve(size);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private parseTiffSize (buffer: Buffer): ImageSize | undefined {
|
||||
if (buffer.length < 8) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 判断字节序
|
||||
const isLittleEndian = buffer[0] === 0x49; // 'I'
|
||||
|
||||
const readUInt16 = isLittleEndian
|
||||
? (offset: number) => buffer.readUInt16LE(offset)
|
||||
: (offset: number) => buffer.readUInt16BE(offset);
|
||||
|
||||
const readUInt32 = isLittleEndian
|
||||
? (offset: number) => buffer.readUInt32LE(offset)
|
||||
: (offset: number) => buffer.readUInt32BE(offset);
|
||||
|
||||
// 获取第一个 IFD 的偏移量
|
||||
const ifdOffset = readUInt32(4);
|
||||
if (ifdOffset + 2 > buffer.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 读取 IFD 条目数量
|
||||
const numEntries = readUInt16(ifdOffset);
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
|
||||
// TIFF 标签
|
||||
const TAG_IMAGE_WIDTH = 0x0100;
|
||||
const TAG_IMAGE_HEIGHT = 0x0101;
|
||||
|
||||
// 遍历 IFD 条目
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryOffset = ifdOffset + 2 + i * 12;
|
||||
if (entryOffset + 12 > buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tag = readUInt16(entryOffset);
|
||||
const type = readUInt16(entryOffset + 2);
|
||||
// const count = readUInt32(entryOffset + 4);
|
||||
|
||||
// 根据类型读取值
|
||||
let value: number;
|
||||
if (type === 3) {
|
||||
// SHORT (2 bytes)
|
||||
value = readUInt16(entryOffset + 8);
|
||||
} else if (type === 4) {
|
||||
// LONG (4 bytes)
|
||||
value = readUInt32(entryOffset + 8);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag === TAG_IMAGE_WIDTH) {
|
||||
width = value;
|
||||
} else if (tag === TAG_IMAGE_HEIGHT) {
|
||||
height = value;
|
||||
}
|
||||
|
||||
if (width !== undefined && height !== undefined) {
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
if (width !== undefined && height !== undefined) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
90
packages/napcat-image-size/src/parser/WebpParser.ts
Normal file
90
packages/napcat-image-size/src/parser/WebpParser.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||
import { ReadStream } from 'fs';
|
||||
|
||||
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
||||
export class WebpParser implements ImageParser {
|
||||
readonly type = ImageType.WEBP;
|
||||
// WEBP RIFF 头:52 49 46 46 (RIFF)
|
||||
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
|
||||
// WEBP 魔术头:57 45 42 50 (WEBP)
|
||||
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
|
||||
|
||||
// WEBP 块头
|
||||
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
|
||||
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
|
||||
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
|
||||
|
||||
canParse (buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= 12 &&
|
||||
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
|
||||
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
|
||||
);
|
||||
}
|
||||
|
||||
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
|
||||
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
|
||||
}
|
||||
|
||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 需要读取足够的字节来检测所有三种格式
|
||||
const MAX_HEADER_SIZE = 32;
|
||||
let totalBytes = 0;
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
stream.on('error', reject);
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
buffer = Buffer.concat([buffer, chunkBuffer]);
|
||||
totalBytes += chunk.length;
|
||||
|
||||
// 检查是否有足够的字节进行格式检测
|
||||
if (totalBytes >= MAX_HEADER_SIZE) {
|
||||
stream.destroy();
|
||||
|
||||
// 检查基本的WEBP签名
|
||||
if (!this.canParse(buffer)) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
|
||||
// 检查chunk头部,位于字节12-15
|
||||
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
|
||||
// VP8格式 - 标准WebP
|
||||
// 宽度和高度在帧头中
|
||||
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
||||
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
||||
return resolve({ width, height });
|
||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
|
||||
// VP8L格式 - 无损WebP
|
||||
// 1字节标记后是14位宽度和14位高度
|
||||
const bits = buffer.readUInt32LE(21);
|
||||
const width = 1 + (bits & 0x3FFF);
|
||||
const height = 1 + ((bits >> 14) & 0x3FFF);
|
||||
return resolve({ width, height });
|
||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
|
||||
// VP8X格式 - 扩展WebP
|
||||
// 24位宽度和高度(减去1)
|
||||
if (buffer.length < 30) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xFFFFFF);
|
||||
const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xFFFFFF);
|
||||
return resolve({ width, height });
|
||||
} else {
|
||||
// 未知的WebP子格式
|
||||
return resolve(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
// 如果没有读到足够的字节
|
||||
if (totalBytes < MAX_HEADER_SIZE) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,9 +5,18 @@ import { NapCatOneBot11Adapter, OB11Return } from '@/napcat-onebot/index';
|
||||
import { NetworkAdapterConfig } from '../config/config';
|
||||
import { TSchema } from '@sinclair/typebox';
|
||||
import { StreamPacket, StreamPacketBasic, StreamStatus } from './stream/StreamBasic';
|
||||
export const ActionExamples = {
|
||||
Common: {
|
||||
errors: [
|
||||
{ code: 1400, description: '请求参数错误或业务逻辑执行失败' },
|
||||
{ code: 1401, description: '权限不足' },
|
||||
{ code: 1404, description: '资源不存在' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export class OB11Response {
|
||||
private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
private static createResponse<T> (data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
return {
|
||||
status,
|
||||
retcode,
|
||||
@@ -19,11 +28,11 @@ export class OB11Response {
|
||||
};
|
||||
}
|
||||
|
||||
static res<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
static res<T> (data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
return this.createResponse(data, status, retcode, message, echo, useStream);
|
||||
}
|
||||
|
||||
static ok<T>(data: T, echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
static ok<T> (data: T, echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
return this.createResponse(data, 'ok', 0, '', echo, useStream);
|
||||
}
|
||||
|
||||
@@ -32,15 +41,22 @@ export class OB11Response {
|
||||
}
|
||||
}
|
||||
export abstract class OneBotRequestToolkit {
|
||||
abstract send<T>(packet: StreamPacket<T>): Promise<void>;
|
||||
abstract send<T> (packet: StreamPacket<T>): Promise<void>;
|
||||
}
|
||||
export abstract class OneBotAction<PayloadType, ReturnDataType> {
|
||||
actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown;
|
||||
core: NapCatCore;
|
||||
private validate?: ValidateFunction<unknown> = undefined;
|
||||
payloadSchema?: TSchema = undefined;
|
||||
returnSchema?: TSchema = undefined;
|
||||
payloadExample?: unknown = undefined;
|
||||
returnExample?: unknown = undefined;
|
||||
actionSummary: string = '';
|
||||
actionDescription: string = '';
|
||||
actionTags: string[] = [];
|
||||
obContext: NapCatOneBot11Adapter;
|
||||
useStream: boolean = false;
|
||||
errorExamples: Array<{ code: number, description: string; }> = ActionExamples.Common.errors;
|
||||
|
||||
constructor (obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||
this.obContext = obContext;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export const ExtendsActionsExamples = {
|
||||
OCRImage: {
|
||||
payload: { image: 'image_id_123' },
|
||||
response: { texts: [{ text: '识别内容', coordinates: [] }] },
|
||||
},
|
||||
GetAiCharacters: {
|
||||
payload: { group_id: '123456' },
|
||||
response: [
|
||||
{
|
||||
type: 'string',
|
||||
characters: [
|
||||
{ character_id: 'id', character_name: 'name', preview_url: 'url' }
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
GetClientkey: {
|
||||
payload: {},
|
||||
response: { clientkey: 'abcdef123456' },
|
||||
},
|
||||
SetQQAvatar: {
|
||||
payload: { file: 'base64://...' },
|
||||
response: null,
|
||||
},
|
||||
SetGroupKickMembers: {
|
||||
payload: { group_id: '123456', user_id: ['123456789'], reject_add_request: false },
|
||||
response: null,
|
||||
},
|
||||
TranslateEnWordToZn: {
|
||||
payload: { words: ['hello'] },
|
||||
response: { words: ['你好'] },
|
||||
},
|
||||
GetRkey: {
|
||||
payload: {},
|
||||
response: { rkey: '...' },
|
||||
},
|
||||
SetLongNick: {
|
||||
payload: { longNick: '个性签名' },
|
||||
response: null,
|
||||
},
|
||||
SetSpecialTitle: {
|
||||
payload: { group_id: '123456', user_id: '123456789', special_title: '头衔' },
|
||||
response: null,
|
||||
},
|
||||
};
|
||||
22
packages/napcat-onebot/action/example/FileActionsExamples.ts
Normal file
22
packages/napcat-onebot/action/example/FileActionsExamples.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const FileActionsExamples = {
|
||||
GetFile: {
|
||||
payload: { file: 'file_id_123' },
|
||||
response: { file: '/path/to/file', url: 'http://...', file_size: 1024, file_name: 'test.jpg' },
|
||||
},
|
||||
GetGroupFileUrl: {
|
||||
payload: { group_id: '123456', file_id: 'file_id_123', busid: 102 },
|
||||
response: { url: 'http://...' },
|
||||
},
|
||||
GetImage: {
|
||||
payload: { file: 'image_id_123' },
|
||||
response: { file: '/path/to/image', url: 'http://...' },
|
||||
},
|
||||
GetPrivateFileUrl: {
|
||||
payload: { user_id: '123456789', file_id: 'file_id_123' },
|
||||
response: { url: 'http://...' },
|
||||
},
|
||||
GetRecord: {
|
||||
payload: { file: 'record_id_123', out_format: 'mp3' },
|
||||
response: { file: '/path/to/record', url: 'http://...' },
|
||||
},
|
||||
};
|
||||
102
packages/napcat-onebot/action/example/GoCQHTTPActionsExamples.ts
Normal file
102
packages/napcat-onebot/action/example/GoCQHTTPActionsExamples.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export const GoCQHTTPActionsExamples = {
|
||||
GetStrangerInfo: {
|
||||
payload: { user_id: '123456789' },
|
||||
response: { user_id: 123456789, nickname: '昵称', sex: 'unknown' },
|
||||
},
|
||||
GetGroupHonorInfo: {
|
||||
payload: { group_id: '123456', type: 'all' },
|
||||
response: { group_id: 123456, current_talkative: {}, talkative_list: [] },
|
||||
},
|
||||
GetForwardMsg: {
|
||||
payload: { message_id: '123456' },
|
||||
response: { messages: [] },
|
||||
},
|
||||
SendForwardMsg: {
|
||||
payload: { group_id: '123456', messages: [] },
|
||||
response: { message_id: 123456 },
|
||||
},
|
||||
GetGroupAtAllRemain: {
|
||||
payload: { group_id: '123456' },
|
||||
response: { can_at_all: true, remain_at_all_count_for_group: 10, remain_at_all_count_for_self: 10 },
|
||||
},
|
||||
CreateGroupFileFolder: {
|
||||
payload: { group_id: '123456', name: '测试目录' },
|
||||
response: { result: {}, groupItem: {} },
|
||||
},
|
||||
DeleteGroupFile: {
|
||||
payload: { group_id: '123456', file_id: 'file_uuid_123' },
|
||||
response: {},
|
||||
},
|
||||
DeleteGroupFileFolder: {
|
||||
payload: { group_id: '123456', folder_id: 'folder_uuid_123' },
|
||||
response: {},
|
||||
},
|
||||
DownloadFile: {
|
||||
payload: { url: 'https://example.com/file.png', thread_count: 1, headers: 'User-Agent: NapCat' },
|
||||
response: { file: '/path/to/downloaded/file' },
|
||||
},
|
||||
GetFriendMsgHistory: {
|
||||
payload: { user_id: '123456789', message_seq: 0, count: 20 },
|
||||
response: { messages: [] },
|
||||
},
|
||||
GetGroupFilesByFolder: {
|
||||
payload: { group_id: '123456', folder_id: 'folder_id' },
|
||||
response: { files: [], folders: [] },
|
||||
},
|
||||
GetGroupFileSystemInfo: {
|
||||
payload: { group_id: '123456' },
|
||||
response: { file_count: 10, limit_count: 10000, used_space: 1024, total_space: 10737418240 },
|
||||
},
|
||||
GetGroupMsgHistory: {
|
||||
payload: { group_id: '123456', message_seq: 0, count: 20 },
|
||||
response: { messages: [] },
|
||||
},
|
||||
GetGroupRootFiles: {
|
||||
payload: { group_id: '123456' },
|
||||
response: { files: [], folders: [] },
|
||||
},
|
||||
GetOnlineClient: {
|
||||
payload: { no_cache: false },
|
||||
response: [],
|
||||
},
|
||||
GoCQHTTPCheckUrlSafely: {
|
||||
payload: { url: 'https://example.com' },
|
||||
response: { level: 1 },
|
||||
},
|
||||
GoCQHTTPDeleteFriend: {
|
||||
payload: { user_id: '123456789' },
|
||||
response: {},
|
||||
},
|
||||
GoCQHTTPGetModelShow: {
|
||||
payload: { model: 'iPhone 13' },
|
||||
response: { variants: [] },
|
||||
},
|
||||
GoCQHTTPSetModelShow: {
|
||||
payload: { model: 'iPhone 13', model_show: 'iPhone 13' },
|
||||
response: {},
|
||||
},
|
||||
QuickAction: {
|
||||
payload: { context: {}, operation: {} },
|
||||
response: {},
|
||||
},
|
||||
SendGroupNotice: {
|
||||
payload: { group_id: '123456', content: '公告内容', image: 'base64://...' },
|
||||
response: {},
|
||||
},
|
||||
SetGroupPortrait: {
|
||||
payload: { group_id: '123456', file: 'base64://...' },
|
||||
response: { result: 0, errMsg: '' },
|
||||
},
|
||||
SetQQProfile: {
|
||||
payload: { nickname: '新昵称', personal_note: '个性签名' },
|
||||
response: {},
|
||||
},
|
||||
UploadGroupFile: {
|
||||
payload: { group_id: '123456', file: '/path/to/file', name: 'test.txt' },
|
||||
response: { file_id: 'file_uuid_123' },
|
||||
},
|
||||
UploadPrivateFile: {
|
||||
payload: { user_id: '123456789', file: '/path/to/file', name: 'test.txt' },
|
||||
response: { file_id: 'file_uuid_123' },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
export const GroupActionsExamples = {
|
||||
DelEssenceMsg: {
|
||||
payload: { message_id: 123456 },
|
||||
response: null,
|
||||
},
|
||||
DelGroupNotice: {
|
||||
payload: { group_id: '123456', notice_id: 'notice_123' },
|
||||
response: null,
|
||||
},
|
||||
GetGroupDetailInfo: {
|
||||
payload: { group_id: '123456' },
|
||||
response: { group_id: 123456, group_name: '测试群', member_count: 100, max_member_count: 500 },
|
||||
},
|
||||
GetGroupEssence: {
|
||||
payload: { group_id: '123456' },
|
||||
response: [{ message_id: 123456, sender_id: 123456, sender_nick: '昵称', operator_id: 123456, operator_nick: '昵称', operator_time: 1710000000, content: '精华内容' }],
|
||||
},
|
||||
GetGroupInfo: {
|
||||
payload: { group_id: '123456' },
|
||||
response: { group_id: 123456, group_name: '测试群', member_count: 100, max_member_count: 500 },
|
||||
},
|
||||
GetGroupList: {
|
||||
payload: {},
|
||||
response: [{ group_id: 123456, group_name: '测试群', member_count: 100, max_member_count: 500 }],
|
||||
},
|
||||
GetGroupMemberInfo: {
|
||||
payload: { group_id: '123456', user_id: '123456789' },
|
||||
response: { group_id: 123456, user_id: 123456789, nickname: '昵称', card: '名片', role: 'member' },
|
||||
},
|
||||
GetGroupMemberList: {
|
||||
payload: { group_id: '123456' },
|
||||
response: [{ group_id: 123456, user_id: 123456789, nickname: '昵称', card: '名片', role: 'member' }],
|
||||
},
|
||||
GetGroupNotice: {
|
||||
payload: { group_id: '123456' },
|
||||
response: [{ notice_id: 'notice_123', sender_id: 123456, publish_time: 1710000000, message: { text: '公告内容', image: [] } }],
|
||||
},
|
||||
SendGroupMsg: {
|
||||
payload: { group_id: '123456', message: 'hello' },
|
||||
response: { message_id: 123456 },
|
||||
},
|
||||
SetEssenceMsg: {
|
||||
payload: { message_id: 123456 },
|
||||
response: null,
|
||||
},
|
||||
SetGroupAddRequest: {
|
||||
payload: { flag: 'flag_123', sub_type: 'add', approve: true },
|
||||
response: null,
|
||||
},
|
||||
SetGroupAdmin: {
|
||||
payload: { group_id: '123456', user_id: '123456789', enable: true },
|
||||
response: null,
|
||||
},
|
||||
SetGroupBan: {
|
||||
payload: { group_id: '123456', user_id: '123456789', duration: 1800 },
|
||||
response: null,
|
||||
},
|
||||
SetGroupCard: {
|
||||
payload: { group_id: '123456', user_id: '123456789', card: '新名片' },
|
||||
response: null,
|
||||
},
|
||||
SetGroupKick: {
|
||||
payload: { group_id: '123456', user_id: '123456789', reject_add_request: false },
|
||||
response: null,
|
||||
},
|
||||
SetGroupLeave: {
|
||||
payload: { group_id: '123456', is_dismiss: false },
|
||||
response: null,
|
||||
},
|
||||
SetGroupName: {
|
||||
payload: { group_id: '123456', group_name: '新群名' },
|
||||
response: null,
|
||||
},
|
||||
SetGroupWholeBan: {
|
||||
payload: { group_id: '123456', enable: true },
|
||||
response: null,
|
||||
},
|
||||
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export const GuildActionsExamples = {
|
||||
GetGuildList: {
|
||||
payload: {},
|
||||
response: [{ guild_id: '123456', guild_name: '测试频道' }],
|
||||
},
|
||||
GetGuildProfile: {
|
||||
payload: { guild_id: '123456' },
|
||||
response: { guild_id: '123456', guild_name: '测试频道', guild_display_id: '123' },
|
||||
},
|
||||
};
|
||||
10
packages/napcat-onebot/action/example/NewActionsExamples.ts
Normal file
10
packages/napcat-onebot/action/example/NewActionsExamples.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const NewActionsExamples = {
|
||||
GetDoubtFriendsAddRequest: {
|
||||
payload: { count: 10 },
|
||||
response: [{ user_id: 123456789, nickname: '昵称', age: 20, sex: 'male', reason: '申请理由', flag: 'flag_123' }],
|
||||
},
|
||||
SetDoubtFriendsAddRequest: {
|
||||
payload: { flag: 'flag_123', approve: true },
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export const PacketActionsExamples = {
|
||||
GetPacketStatus: {
|
||||
payload: {},
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
SendPoke: {
|
||||
payload: { user_id: '123456789' },
|
||||
response: {},
|
||||
},
|
||||
SetGroupTodo: {
|
||||
payload: { group_id: '123456', message_id: '123456789' },
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
export const SystemActionsExamples = {
|
||||
CanSendImage: {
|
||||
payload: {},
|
||||
response: { yes: true },
|
||||
},
|
||||
CanSendRecord: {
|
||||
payload: {},
|
||||
response: { yes: true },
|
||||
},
|
||||
CleanCache: {
|
||||
payload: {},
|
||||
response: {},
|
||||
},
|
||||
GetCredentials: {
|
||||
payload: {},
|
||||
response: { cookies: '...', csrf_token: 123456789 },
|
||||
},
|
||||
GetCSRF: {
|
||||
payload: {},
|
||||
response: { token: 123456789 },
|
||||
},
|
||||
GetLoginInfo: {
|
||||
payload: {},
|
||||
response: { user_id: 123456789, nickname: '机器人' },
|
||||
},
|
||||
GetStatus: {
|
||||
payload: {},
|
||||
response: { online: true, good: true },
|
||||
},
|
||||
GetSystemMsg: {
|
||||
payload: {},
|
||||
response: { invited_requests: [], join_requests: [] },
|
||||
},
|
||||
GetVersionInfo: {
|
||||
payload: {},
|
||||
response: { app_name: 'NapCatQQ', app_version: '1.0.0', protocol_version: 'v11' },
|
||||
},
|
||||
SetRestart: {
|
||||
payload: { delay: 0 },
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user