Compare commits

...

221 Commits

Author SHA1 Message Date
手瓜一十雪
c50359c504 Update appid.json 2025-05-03 11:28:57 +08:00
手瓜一十雪
364500e16e Update package.json 2025-05-02 21:31:47 +08:00
手瓜一十雪
22144010ec Merge branch 'main' into protobuf-decode 2025-05-02 21:31:40 +08:00
手瓜一十雪
6421bb4f5c feat: normalize 2025-05-02 15:10:31 +08:00
Mlikiowa
3919743885 release: v4.7.45 2025-04-30 13:43:59 +00:00
pk5ls20
a5a57b9e20 fix: fxxking fake forward element
- close #972, #977, #666
2025-04-30 20:31:03 +08:00
手瓜一十雪
e31d2810ad fix 2025-04-29 22:06:01 +08:00
Mlikiowa
140e62fdcd release: v4.7.44 2025-04-28 14:04:40 +00:00
手瓜一十雪
014b4deb87 feat: 34740 2025-04-28 22:04:20 +08:00
Mlikiowa
956b6cd172 release: v4.7.43 2025-04-26 11:10:37 +00:00
手瓜一十雪
bbaca3f044 fix 2025-04-26 19:10:00 +08:00
Mlikiowa
bb8a44b918 release: v4.7.42 2025-04-26 11:02:25 +00:00
手瓜一十雪
b5574d5999 fix: #976 2025-04-26 19:00:31 +08:00
手瓜一十雪
06dde072da Merge pull request #975 from pohgxz/main
接口 _get_model_show 的 model 设置为可选属性
2025-04-26 18:31:10 +08:00
Nepenthe
8e92a81bb9 接口 _get_model_show 的 model 设置为可选属性 2025-04-26 14:48:35 +08:00
Nepenthe
2c7345ae88 Merge branch 'NapNeko:main' into main 2025-04-26 14:40:38 +08:00
Mlikiowa
33d4696155 release: v4.7.41 2025-04-24 09:43:32 +00:00
手瓜一十雪
7d2dcc10e5 fix 2025-04-24 17:43:13 +08:00
Mlikiowa
e82687454c release: v4.7.40 2025-04-24 07:57:16 +00:00
手瓜一十雪
84382caebc fix 2025-04-24 15:56:55 +08:00
Mlikiowa
662530e507 release: v4.7.36 2025-04-24 07:53:59 +00:00
手瓜一十雪
edf81d0a2e feat: 34606 2025-04-24 15:37:44 +08:00
手瓜一十雪
7cbae86941 Revert "fix: 私聊撤回"
This reverts commit 8ff7420a5e.
2025-04-24 11:34:07 +08:00
手瓜一十雪
8ff7420a5e fix: 私聊撤回 2025-04-24 11:33:11 +08:00
手瓜一十雪
7ae59b1419 Merge pull request #971 from Sn0wo2/main
fix: temp_source
2025-04-24 09:54:29 +08:00
手瓜一十雪
41036f8ee8 fix: 969 2025-04-24 09:50:26 +08:00
Me0wo
380777ca04 fix: #970 2025-04-24 04:11:31 +08:00
Mlikiowa
c658cd1096 release: v4.7.35 2025-04-23 08:52:43 +00:00
手瓜一十雪
c7b9946d2f feat: doubt friends支持 2025-04-23 16:46:09 +08:00
手瓜一十雪
0caca473d6 feat: 34566 2025-04-23 16:18:48 +08:00
手瓜一十雪
3e5d35957d fix 2025-04-23 16:12:56 +08:00
手瓜一十雪
6b8b14aba2 fix: #963 2025-04-23 11:47:58 +08:00
手瓜一十雪
5db7a90a24 feat: 301 302自动跟随下载 2025-04-21 18:43:44 +08:00
Mlikiowa
88b86611a3 release: v4.7.34 2025-04-20 14:12:47 +00:00
手瓜一十雪
886fe2052e feat: 避免危险信息 2025-04-20 22:12:12 +08:00
手瓜一十雪
e4dd194d4a fix: #960
神经设计
2025-04-20 22:10:24 +08:00
手瓜一十雪
a47af60f58 feat: disband 2025-04-20 19:28:35 +08:00
Mlikiowa
35f24eb806 release: v4.7.33 2025-04-19 12:17:18 +00:00
手瓜一十雪
36e3119d34 feat: 支持https 面板 2025-04-19 20:16:24 +08:00
手瓜一十雪
8ff3ad824e feat: 支持环境变量禁用ffmpeg下载支持 2025-04-19 20:03:00 +08:00
手瓜一十雪
556000c002 feat: 优雅的回车登录 2025-04-19 19:59:11 +08:00
手瓜一十雪
fda050d3fe feat: 加强安全性 传输过程使用salt sha256 2025-04-19 19:50:52 +08:00
手瓜一十雪
b1047309c9 feat: 消息context增强识别 2025-04-19 11:36:27 +08:00
Mlikiowa
d766c4945e release: v4.7.32 2025-04-19 03:17:47 +00:00
手瓜一十雪
43c98c45b9 fix 2025-04-19 11:13:02 +08:00
手瓜一十雪
f7556b5af3 fix 2025-04-19 11:10:04 +08:00
手瓜一十雪
cd781c4cf6 feat: 回归ajv 2025-04-19 11:07:01 +08:00
手瓜一十雪
cd8698b157 fix 2025-04-19 11:03:03 +08:00
手瓜一十雪
d921dcddf1 Revert "package->dev"
This reverts commit 45d6ebf084.
2025-04-19 11:01:45 +08:00
手瓜一十雪
9f318ddaef Revert "feat: 区分resId和普通消息Id"
This reverts commit 7ecdd63bef.
2025-04-19 11:01:12 +08:00
手瓜一十雪
5c35ea11c3 Revert "fix: 修掉漏掉的"
This reverts commit 7a42f8c26f.
2025-04-19 11:01:06 +08:00
手瓜一十雪
3b16effff0 Revert "fix"
This reverts commit 40b06daf1e.
2025-04-19 10:59:04 +08:00
手瓜一十雪
d3a27ad701 Revert "fix:coerce"
This reverts commit dd895d7c17.
2025-04-19 10:58:56 +08:00
手瓜一十雪
2a4589e268 Revert "fix: checker"
This reverts commit 941978b578.
2025-04-19 10:58:46 +08:00
手瓜一十雪
80a34c82b9 Revert "fix: zod boolean强制转换"
This reverts commit 17ef3231df.
2025-04-19 10:58:39 +08:00
手瓜一十雪
fca7a65ee0 Revert "fix"
This reverts commit 3f6249f39c.
2025-04-19 10:57:36 +08:00
手瓜一十雪
30a75bc581 Revert "fix"
This reverts commit 54e6d5c3f2.
2025-04-19 10:57:32 +08:00
手瓜一十雪
7b365367f7 Revert "fix"
This reverts commit 41dccd98a9.
2025-04-19 10:57:28 +08:00
手瓜一十雪
3ed5f543e2 Revert "fix"
This reverts commit bd3e06520f.
2025-04-19 10:57:25 +08:00
手瓜一十雪
ceea50b116 Revert "fix: coerce"
This reverts commit fb20b2e16c.
2025-04-19 10:53:29 +08:00
手瓜一十雪
a5455e27d1 feat: 34467 2025-04-19 09:44:00 +08:00
手瓜一十雪
6f83d01321 feat: 34362 2025-04-18 18:19:12 +08:00
手瓜一十雪
c453b82e9f feat: #954 2025-04-18 12:12:18 +08:00
Mlikiowa
b7da316447 release: v4.7.31 2025-04-17 14:17:57 +00:00
手瓜一十雪
fb20b2e16c fix: coerce 2025-04-17 22:17:35 +08:00
Mlikiowa
9df7c341a9 release: v4.7.30 2025-04-17 10:07:28 +00:00
手瓜一十雪
7c113d6e04 fix: 一些问题 2025-04-17 18:07:07 +08:00
Mlikiowa
a6f22167ff release: v4.7.29 2025-04-17 09:59:29 +00:00
手瓜一十雪
d49e69735a fix: 自动化验证环境变量的ffmpeg 2025-04-17 17:59:06 +08:00
Mlikiowa
eca73eae18 release: v4.7.28 2025-04-17 09:49:05 +00:00
手瓜一十雪
d3a34dfdf9 feat: 增强异常处理 2025-04-17 17:48:13 +08:00
手瓜一十雪
623188d884 feat: 34362 2025-04-17 17:07:09 +08:00
Mlikiowa
f093f52792 release: v4.7.27 2025-04-17 06:39:48 +00:00
手瓜一十雪
d53607a118 fix 2025-04-17 14:39:30 +08:00
Mlikiowa
5f637e064a release: v4.7.26 2025-04-17 06:29:11 +00:00
手瓜一十雪
e4b21e94f5 feat: ffmpeg download auto 2025-04-17 14:28:51 +08:00
手瓜一十雪
fc37288827 fix: ffmpeg 2025-04-17 13:55:31 +08:00
Mlikiowa
dad7245a3a release: v4.7.25 2025-04-17 05:28:19 +00:00
手瓜一十雪
4190831081 fix: 扬了ffmpeg.wasm 2025-04-17 13:26:24 +08:00
Mlikiowa
c509a01d7d release: v4.7.24 2025-04-17 01:57:25 +00:00
手瓜一十雪
6d259593fd Merge pull request #953 from NapNeko/fix-zod-boolean
fix: zod boolean强制转换
2025-04-17 09:56:48 +08:00
手瓜一十雪
bd3e06520f fix 2025-04-17 09:56:12 +08:00
手瓜一十雪
41dccd98a9 fix 2025-04-17 09:54:12 +08:00
手瓜一十雪
54e6d5c3f2 fix 2025-04-17 09:52:03 +08:00
手瓜一十雪
3f6249f39c fix 2025-04-17 09:48:59 +08:00
手瓜一十雪
a888714629 fix: napcat log 2025-04-17 09:47:39 +08:00
手瓜一十雪
17ef3231df fix: zod boolean强制转换 2025-04-17 09:38:38 +08:00
Mlikiowa
cc30b51d58 release: v4.7.23 2025-04-15 10:38:47 +00:00
手瓜一十雪
9d40eacc15 fix: 34231 linux arm64 docker问题 2025-04-15 18:38:24 +08:00
Mlikiowa
a0415c5f4e release: v4.7.22 2025-04-15 04:35:48 +00:00
手瓜一十雪
941978b578 fix: checker 2025-04-15 12:34:33 +08:00
Mlikiowa
06538b9122 release: v4.7.21 2025-04-15 04:25:59 +00:00
手瓜一十雪
dd895d7c17 fix:coerce 2025-04-15 12:25:37 +08:00
Mlikiowa
9257a6cfde release: v4.7.20 2025-04-14 14:37:43 +00:00
手瓜一十雪
f8260067ab Merge pull request #944 from clansty/fix/isReverseOrder
fix: isReverseOrder
2025-04-14 21:44:42 +08:00
手瓜一十雪
40b06daf1e fix 2025-04-14 21:44:25 +08:00
手瓜一十雪
e30915a06b Merge pull request #946 from NapNeko/fix-923
feat: 区分resId和普通消息Id
2025-04-14 19:05:01 +08:00
手瓜一十雪
2ab3898d28 readme: new 2025-04-14 13:25:00 +08:00
Clansty
6e38e748b8 fix: isReverseOrder 2025-04-14 05:40:11 +08:00
手瓜一十雪
7ecdd63bef feat: 区分resId和普通消息Id 2025-04-13 20:33:25 +08:00
手瓜一十雪
8056962203 Merge pull request #943 from NapNeko/zod-refactor
fix: 修掉漏掉的
2025-04-13 20:17:08 +08:00
手瓜一十雪
7a42f8c26f fix: 修掉漏掉的 2025-04-13 20:14:35 +08:00
手瓜一十雪
6c510e42e8 Merge pull request #942 from NapNeko/zod-refactor
迁移类型校验到zod
2025-04-13 20:10:43 +08:00
手瓜一十雪
45d6ebf084 package->dev 2025-04-13 20:06:30 +08:00
手瓜一十雪
2147c4ffee 迁移类型校验到zod 2025-04-13 20:05:11 +08:00
Mlikiowa
d4ab191f34 release: v4.7.19 2025-04-12 04:25:39 +00:00
手瓜一十雪
a5e53a713b feat: 增强win 输出可读性 2025-04-12 12:23:38 +08:00
Mlikiowa
e3f965a9d6 release: v4.7.18 2025-04-12 04:04:09 +00:00
手瓜一十雪
dd00d4c8a5 fix: 小问题 2025-04-12 11:59:29 +08:00
手瓜一十雪
4d292a75fa fix 2025-04-12 11:36:46 +08:00
pk5ls20
50b6733f57 Merge pull request #938 from Fahaxikiii/patch-2
Update README.md
2025-04-12 00:11:23 +08:00
万里
19479b4b3c Update README.md
换成美国vps
2025-04-12 00:09:19 +08:00
pk5ls20
540f58d8ec Merge pull request #937 from Fahaxikiii/patch-1 2025-04-11 23:18:46 +08:00
万里
749f1dfcf9 Update README.md
服务器到期了呜呜呜
2025-04-11 22:03:28 +08:00
Mlikiowa
176691bb96 release: v4.7.17 2025-04-11 07:12:53 +00:00
手瓜一十雪
b9ec8ac9b1 feat: LL Framework适配 2025-04-11 15:12:32 +08:00
手瓜一十雪
28d973b9cb Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-04-11 14:40:31 +08:00
手瓜一十雪
a6be54937c feat: 删掉不必要的启动脚本 2025-04-11 14:40:22 +08:00
Mlikiowa
0664b9af84 release: v4.7.16 2025-04-11 06:21:19 +00:00
手瓜一十雪
407d8d1fd2 feat: 34231 2025-04-11 14:20:27 +08:00
手瓜一十雪
108897f6ad feat: shell 能力 launcher-user-34231 2025-04-11 14:18:12 +08:00
手瓜一十雪
3d2decb0ec feat: 34231 2025-04-11 12:47:25 +08:00
Mlikiowa
386b884f1b release: v4.7.15 2025-04-10 11:00:12 +00:00
手瓜一十雪
ace4da2297 fix: rkey server部署 2025-04-10 18:59:49 +08:00
Mlikiowa
a8fb48fb50 release: v4.7.14 2025-04-10 10:55:24 +00:00
手瓜一十雪
61f065c0c6 feat: rkey标准化&rkey server增强&简化rkey端部署 2025-04-10 18:54:18 +08:00
手瓜一十雪
d6cf6d120a feat: group_all_shut 2025-04-10 09:00:00 +08:00
手瓜一十雪
c20c19d8e0 feat: 更新类型 fetchUserDetailInfo 2025-04-08 10:12:18 +08:00
手瓜一十雪
bd8bbf76ab feat: moveGroupFile 2025-04-08 09:42:28 +08:00
手瓜一十雪
faccff1834 Merge pull request #927 from NapNeko/dependabot/npm_and_yarn/vite-plugin-cp-6.0.0
chore(deps-dev): bump vite-plugin-cp from 4.0.8 to 6.0.0
2025-04-08 09:19:58 +08:00
手瓜一十雪
99d3c5a117 Merge pull request #930 from clansty/feat/gfs
增加更多群文件相关功能
2025-04-08 09:19:41 +08:00
Clansty
31eb09edef feat: 重命名群文件 2025-04-08 05:14:53 +08:00
Clansty
4180c2d754 feat: 群文件转存永久 2025-04-08 04:40:34 +08:00
Clansty
68f5deedff feat: 移动群文件 2025-04-08 02:48:48 +08:00
dependabot[bot]
9f72196414 chore(deps-dev): bump vite-plugin-cp from 4.0.8 to 6.0.0
Bumps [vite-plugin-cp](https://github.com/fengxinming/vite-plugins/tree/HEAD/packages/vite-plugin-cp) from 4.0.8 to 6.0.0.
- [Commits](https://github.com/fengxinming/vite-plugins/commits/HEAD/packages/vite-plugin-cp)

---
updated-dependencies:
- dependency-name: vite-plugin-cp
  dependency-version: 6.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 09:33:04 +00:00
手瓜一十雪
b32efa9131 fix: 更新逻辑 2025-04-05 11:45:21 +08:00
pk5ls20
3cf502fea3 chore: improve log output for protocol fetch with multiple messages 2025-04-04 01:59:31 +08:00
手瓜一十雪
863a953ae1 style: lint 2025-04-03 15:06:34 +08:00
手瓜一十雪
a44104d8f7 Update OneBotAction.ts 2025-04-03 15:03:00 +08:00
手瓜一十雪
f602bbb0cf fix: 启用类型强制转换 2025-04-03 14:52:33 +08:00
手瓜一十雪
2807ff5927 fix: 刷新群头衔缓存 2025-04-03 14:46:56 +08:00
手瓜一十雪
4fb8e6a4da fix: typo 2025-04-03 14:44:59 +08:00
手瓜一十雪
7ec61f089d fix 2025-04-02 21:40:10 +08:00
手瓜一十雪
f7a500a8cf feat: GroupMemberTitle 2025-04-02 21:30:25 +08:00
Mlikiowa
2b319bd694 release: v4.7.13 2025-04-02 04:05:02 +00:00
手瓜一十雪
24cf7c01f8 fix: 清理文件 2025-04-02 12:04:05 +08:00
手瓜一十雪
aa50e73909 Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-04-02 11:58:59 +08:00
手瓜一十雪
d9b33b5439 fix: file clean 2025-04-02 11:58:56 +08:00
Mlikiowa
da58c6bec0 release: v4.7.12 2025-04-02 03:54:37 +00:00
手瓜一十雪
c4cbac4331 fix: #918 2025-04-02 11:51:51 +08:00
手瓜一十雪
ac26a99143 fix: type 2025-04-02 10:49:48 +08:00
手瓜一十雪
640252d391 fix: 初始化后清理 2025-04-02 10:05:57 +08:00
Mlikiowa
258a1dda5e release: v4.7.11 2025-04-01 12:44:06 +00:00
手瓜一十雪
53a7ce2e46 feat: 优化webui快速登录&优化代码整体逻辑 2025-04-01 20:43:46 +08:00
手瓜一十雪
291e2fd8fd feat: 33800 2025-04-01 20:33:14 +08:00
手瓜一十雪
f180c7698f feat: 文件清理quene 2025-04-01 20:22:46 +08:00
手瓜一十雪
183d6f3011 feat: no_cache 2025-04-01 19:54:01 +08:00
bietiaop
ba71d7ad03 fix: #908 2025-03-29 21:26:34 +08:00
手瓜一十雪
26cfaac3bd fix: 增强异常处理 2025-03-29 10:50:27 +08:00
手瓜一十雪
556c8b24c0 fix 2025-03-28 16:55:19 +08:00
Mlikiowa
31f0f527b7 release: v4.7.10 2025-03-27 04:36:54 +00:00
手瓜一十雪
b2e0cab702 feat: 好友等级 2025-03-27 12:35:05 +08:00
Mlikiowa
3e3609e0f2 release: v4.7.9 2025-03-27 04:16:25 +00:00
手瓜一十雪
83e73d9842 fix: 修复一些数据问题 2025-03-27 12:15:53 +08:00
手瓜一十雪
673a175ddf fix: 烘焙raw 2025-03-26 21:51:51 +08:00
手瓜一十雪
8403b1c9c1 fix 2025-03-26 20:50:08 +08:00
手瓜一十雪
26593d593c fix: 移除不必要的 2025-03-26 12:38:39 +08:00
手瓜一十雪
4d26ec737b fix 2025-03-26 12:32:54 +08:00
手瓜一十雪
31fdffde36 fix 2025-03-26 12:27:43 +08:00
手瓜一十雪
bccf5894cd fix 2025-03-26 12:04:42 +08:00
手瓜一十雪
2754165ae5 fix 2025-03-25 16:53:48 +08:00
手瓜一十雪
b9a9438bf0 x 2025-03-23 16:53:06 +08:00
手瓜一十雪
d8b2ebc01e x 2025-03-23 11:49:49 +08:00
手瓜一十雪
a979a07d3d Merge branch 'main' into protobuf-decode 2025-03-23 11:45:37 +08:00
手瓜一十雪
12eacd3530 feat: 移除ws 2025-03-21 20:54:59 +08:00
手瓜一十雪
030f0551fd feat: 移除部分淘汰代码 2025-03-21 20:47:38 +08:00
Mlikiowa
47f5947410 release: v4.7.8 2025-03-21 12:43:58 +00:00
手瓜一十雪
aaefa2e83c fix: error 2025-03-21 20:43:41 +08:00
Mlikiowa
f8e92f7c8d release: v4.7.7 2025-03-21 11:48:29 +00:00
手瓜一十雪
b6430e6eb6 fix: 优化 2025-03-21 19:44:31 +08:00
手瓜一十雪
e60605c7bb feat: 简化代码逻辑 2025-03-21 19:40:47 +08:00
手瓜一十雪
d4d94e43d8 Merge branch 'main' into protobuf-decode 2025-03-20 12:25:54 +08:00
手瓜一十雪
58d2bd3c81 Revert "fix: #883"
This reverts commit 79aa1dc67f.
2025-03-20 10:57:57 +08:00
手瓜一十雪
6534d05b76 Revert "fix"
This reverts commit 2d7de174c5.
2025-03-20 10:57:54 +08:00
手瓜一十雪
2d7de174c5 fix 2025-03-20 10:32:30 +08:00
手瓜一十雪
79aa1dc67f fix: #883 2025-03-20 10:32:13 +08:00
Mlikiowa
7792ad9ea0 release: v4.7.6 2025-03-19 07:35:12 +00:00
手瓜一十雪
be6671923b feat: 33139 2025-03-19 15:34:33 +08:00
手瓜一十雪
0fa1b3f044 feat: 33139 2025-03-19 15:26:55 +08:00
手瓜一十雪
3deb7788ae Merge branch 'main' into protobuf-decode 2025-03-19 12:13:26 +08:00
手瓜一十雪
4ab751696b Revert "fix: image size"
This reverts commit 2759a34d96.
2025-03-19 11:58:19 +08:00
手瓜一十雪
dce4eedf7d Revert "fix: moduleResolution"
This reverts commit 9ab776d53a.
2025-03-19 11:58:16 +08:00
手瓜一十雪
129b67b751 Revert "chore(deps-dev): bump image-size from 1.2.0 to 2.0.1"
This reverts commit e3feb6a73c.
2025-03-19 11:57:55 +08:00
手瓜一十雪
9ab776d53a fix: moduleResolution 2025-03-19 11:54:29 +08:00
手瓜一十雪
a790303ebe x 2025-03-19 11:53:53 +08:00
手瓜一十雪
99bfe69752 Merge branch 'main' into protobuf-decode 2025-03-19 11:10:23 +08:00
手瓜一十雪
2759a34d96 fix: image size 2025-03-19 11:09:57 +08:00
手瓜一十雪
1c8cf9538d Merge branch 'main' into protobuf-decode 2025-03-19 10:56:29 +08:00
手瓜一十雪
2f9f42750e Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-03-19 10:45:59 +08:00
手瓜一十雪
30abd1f904 fix: #884 2025-03-19 10:45:56 +08:00
手瓜一十雪
008075466e Merge pull request #887 from NapNeko/dependabot/npm_and_yarn/eslint-import-resolver-typescript-4.0.0
chore(deps-dev): bump eslint-import-resolver-typescript from 3.9.1 to 4.0.0
2025-03-19 10:39:33 +08:00
手瓜一十雪
5b4035c320 Merge pull request #888 from NapNeko/dependabot/npm_and_yarn/image-size-2.0.1
chore(deps-dev): bump image-size from 1.2.0 to 2.0.1
2025-03-19 10:39:21 +08:00
dependabot[bot]
e3feb6a73c chore(deps-dev): bump image-size from 1.2.0 to 2.0.1
Bumps [image-size](https://github.com/image-size/image-size) from 1.2.0 to 2.0.1.
- [Release notes](https://github.com/image-size/image-size/releases)
- [Commits](https://github.com/image-size/image-size/compare/v1.2.0...v2.0.1)

---
updated-dependencies:
- dependency-name: image-size
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-17 09:23:32 +00:00
dependabot[bot]
40fe73317d chore(deps-dev): bump eslint-import-resolver-typescript
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.9.1 to 4.0.0.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.9.1...v4.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-17 09:21:48 +00:00
pk5ls20
073745030c fix: #843
- maybe a temporary solution
2025-03-16 20:07:47 +08:00
Mlikiowa
c523437506 release: v4.7.5 2025-03-16 08:08:15 +00:00
手瓜一十雪
9eef570d37 fix: network prepare 2025-03-16 16:07:51 +08:00
Mlikiowa
be37b8cbbd release: v4.7.4 2025-03-16 07:57:45 +00:00
手瓜一十雪
c635496677 fix: msf Status 2025-03-16 15:57:27 +08:00
Mlikiowa
8753ecfd92 release: v4.7.3 2025-03-16 03:57:48 +00:00
手瓜一十雪
5eda1f2870 fix: quick login 2025-03-16 11:57:28 +08:00
Mlikiowa
d5a60074f7 release: v4.7.2 2025-03-16 03:55:17 +00:00
手瓜一十雪
91df57d932 fix: async error 2025-03-16 11:55:00 +08:00
Mlikiowa
e27d4c4302 release: v4.7.1 2025-03-16 03:40:48 +00:00
Nepenthe
faf390bb18 Merge branch 'NapNeko:main' into main 2025-02-21 21:19:02 +08:00
手瓜一十雪
be2e2e86f0 napcat.protobuf test 2025-02-21 21:12:46 +08:00
Nepenthe
941b30847b Merge branch 'NapNeko:main' into main 2025-01-25 23:14:26 +08:00
Nepenthe
4c5a26698e Merge branch 'NapNeko:main' into main 2025-01-15 21:33:43 +08:00
Nepenthe
d14a1dd948 Merge branch 'NapNeko:main' into main 2024-11-17 17:28:31 +08:00
Nepenthe
1c0b434f47 Merge branch 'NapNeko:main' into main 2024-10-31 19:15:25 +08:00
Nepenthe
573451bade 修复<get_record>接口 2024-10-30 21:07:01 +08:00
111 changed files with 6296 additions and 739 deletions

View File

@@ -6,5 +6,7 @@
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts", "tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE" "package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
}, },
"css.customData": [".vscode/tailwindcss.json"], "css.customData": [
".vscode/tailwindcss.json"
],
} }

View File

@@ -37,18 +37,19 @@ _Modern protocol-side framework implemented based on NTQQ._
| Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) | | Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) |
|:-:|:-:|:-:|:-:| |:-:|:-:|:-:|:-:|
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://docs.napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) | | Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) |
|:-:|:-:|:-:|:-:| |:-:|:-:|:-:|:-:|
| Contact | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) | | QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) |
|:-:|:-:|:-:|:-:| |:-:|:-:|:-:|:-:|:-:|
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) |
|:-:|:-:|
## Thanks ## Thanks
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权 + [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
+ [LLOneBot](https://github.com/LLOneBot/LLOneBot) 相关的开发曾参与本项目部分开发
+ 不过最最重要的 还是需要感谢屏幕前的你哦~ + 不过最最重要的 还是需要感谢屏幕前的你哦~
--- ---
@@ -60,3 +61,7 @@ _Modern protocol-side framework implemented based on NTQQ._
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE). 2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。** **本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
## Warnings
[某框架抄袭部分分析](https://napneko.github.io/other/about-copy)

Binary file not shown.

BIN
external/fonts/AaCute.ttf vendored Normal file

Binary file not shown.

BIN
external/fonts/JetBrainsMono.ttf vendored Normal file

Binary file not shown.

BIN
external/fonts/post.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -1,9 +1,9 @@
{ {
"name": "qq-chat", "name": "qq-chat",
"version": "9.9.18-32793", "version": "9.9.18-32869",
"verHash": "d43f097e", "verHash": "e735296c",
"linuxVersion": "3.2.16-32793", "linuxVersion": "3.2.16-32869",
"linuxVerHash": "ee4bd910", "linuxVerHash": "4c192ba9",
"private": true, "private": true,
"description": "QQ", "description": "QQ",
"productName": "QQ", "productName": "QQ",
@@ -34,9 +34,9 @@
"vuex@4.1.0": "patches/vuex@4.1.0.patch" "vuex@4.1.0": "patches/vuex@4.1.0.patch"
} }
}, },
"buildVersion": "32793", "buildVersion": "32869",
"isPureShell": true, "isPureShell": true,
"isByteCodeShell": true, "isByteCodeShell": true,
"platform": "win32", "platform": "win32",
"eleArch": "x64" "eleArch": "x64"
} }

View File

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

View File

@@ -55,6 +55,7 @@
"ahooks": "^3.8.4", "ahooks": "^3.8.4",
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
"framer-motion": "^12.0.6", "framer-motion": "^12.0.6",
@@ -88,6 +89,7 @@
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@react-types/shared": "^3.26.0", "@react-types/shared": "^3.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/crypto-js": "^4.2.2",
"@types/event-source-polyfill": "^1.0.5", "@types/event-source-polyfill": "^1.0.5",
"@types/fabric": "^5.3.9", "@types/fabric": "^5.3.9",
"@types/node": "^22.12.0", "@types/node": "^22.12.0",

View File

@@ -3,7 +3,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill'
import { LogLevel } from '@/const/enum' import { LogLevel } from '@/const/enum'
import { serverRequest } from '@/utils/request' import { serverRequest } from '@/utils/request'
import CryptoJS from "crypto-js";
export interface Log { export interface Log {
level: LogLevel level: LogLevel
message: string message: string
@@ -17,9 +17,10 @@ export default class WebUIManager {
} }
public static async loginWithToken(token: string) { public static async loginWithToken(token: string) {
const sha256 = CryptoJS.SHA256(token + '.napcat').toString();
const { data } = await serverRequest.post<ServerResponse<AuthResponse>>( const { data } = await serverRequest.post<ServerResponse<AuthResponse>>(
'/auth/login', '/auth/login',
{ token } { hash: sha256 }
) )
return data.data.Credential return data.data.Credential
} }

View File

@@ -47,6 +47,22 @@ export default function WebLoginPage() {
} }
} }
// 处理全局键盘事件
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading) {
onSubmit()
}
}
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
// 清理函数
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [tokenValue, isLoading]) // 依赖项包含用于登录的状态
useEffect(() => { useEffect(() => {
if (token) { if (token) {
onSubmit() onSubmit()
@@ -79,6 +95,7 @@ export default function WebLoginPage() {
<CardBody className="flex gap-5 py-5 px-5 md:px-10"> <CardBody className="flex gap-5 py-5 px-5 md:px-10">
<Input <Input
isClearable isClearable
type="password"
classNames={{ classNames={{
label: 'text-black/50 dark:text-white/90', label: 'text-black/50 dark:text-white/90',
input: [ input: [

View File

@@ -2,7 +2,7 @@
"name": "napcat", "name": "napcat",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "4.7.0", "version": "4.7.45",
"scripts": { "scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1", "build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1", "build:framework": "npm run build:webui && vite build --mode framework || exit 1",
@@ -21,7 +21,6 @@
"@eslint/compat": "^1.2.2", "@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",
"@ffmpeg.wasm/main": "^0.13.1",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@log4js-node/log4js-api": "^1.0.2", "@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4", "@napneko/nap-proto-core": "^0.0.4",
@@ -36,6 +35,7 @@
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/react-color": "^3.0.13", "@types/react-color": "^3.0.13",
"@types/type-is": "^1.6.7", "@types/type-is": "^1.6.7",
"@types/wordcloud": "^1.2.2",
"@types/ws": "^8.5.12", "@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0", "@typescript-eslint/parser": "^8.3.0",
@@ -45,7 +45,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"esbuild": "0.25.0", "esbuild": "0.25.0",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"fast-xml-parser": "^4.3.6", "fast-xml-parser": "^4.3.6",
@@ -57,16 +57,19 @@
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.13.0", "typescript-eslint": "^8.13.0",
"vite": "^6.0.1", "vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8", "vite-plugin-cp": "^6.0.0",
"vite-tsconfig-paths": "^5.1.0", "vite-tsconfig-paths": "^5.1.0",
"napcat.protobuf": "^1.1.3", "napcat.protobuf": "^1.1.4",
"winston": "^3.17.0", "winston": "^3.17.0",
"compressing": "^1.10.1" "compressing": "^1.10.1"
}, },
"dependencies": { "dependencies": {
"@ffmpeg.wasm/core-mt": "^0.13.2", "@napi-rs/canvas": "^0.1.67",
"@node-rs/jieba": "^2.0.1",
"express": "^5.0.0", "express": "^5.0.0",
"napcat.protobuf": "^1.1.2",
"silk-wasm": "^3.6.1", "silk-wasm": "^3.6.1",
"wordcloud": "^1.2.3",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
} }

229
src/common/clean-task.ts Normal file
View File

@@ -0,0 +1,229 @@
import fs from 'fs';
// generate Claude 3.7 Sonet Thinking
interface FileRecord {
filePath: string;
addedTime: number;
retries: number;
}
interface CleanupTask {
fileRecord: FileRecord;
timer: NodeJS.Timeout;
}
class CleanupQueue {
private tasks: Map<string, CleanupTask> = new Map();
private readonly MAX_RETRIES = 3;
private isProcessing: boolean = false;
private pendingOperations: Array<() => void> = [];
/**
* 执行队列中的待处理操作,确保异步安全
*/
private executeNextOperation(): void {
if (this.pendingOperations.length === 0) {
this.isProcessing = false;
return;
}
this.isProcessing = true;
const operation = this.pendingOperations.shift();
operation?.();
// 使用 setImmediate 允许事件循环继续,防止阻塞
setImmediate(() => this.executeNextOperation());
}
/**
* 安全执行操作,防止竞态条件
* @param operation 要执行的操作
*/
private safeExecute(operation: () => void): void {
this.pendingOperations.push(operation);
if (!this.isProcessing) {
this.executeNextOperation();
}
}
/**
* 检查文件是否存在
* @param filePath 文件路径
* @returns 文件是否存在
*/
private fileExists(filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch (error) {
//console.log(`检查文件存在出错: ${filePath}`, error);
return false;
}
}
/**
* 添加文件到清理队列
* @param filePath 文件路径
* @param cleanupDelay 清理延迟时间(毫秒)
*/
addFile(filePath: string, cleanupDelay: number): void {
this.safeExecute(() => {
// 如果文件已在队列中,取消原来的计时器
if (this.tasks.has(filePath)) {
this.cancelCleanup(filePath);
}
// 创建新的文件记录
const fileRecord: FileRecord = {
filePath,
addedTime: Date.now(),
retries: 0
};
// 设置计时器
const timer = setTimeout(() => {
this.cleanupFile(fileRecord, cleanupDelay);
}, cleanupDelay);
// 添加到任务队列
this.tasks.set(filePath, { fileRecord, timer });
});
}
/**
* 批量添加文件到清理队列
* @param filePaths 文件路径数组
* @param cleanupDelay 清理延迟时间(毫秒)
*/
addFiles(filePaths: string[], cleanupDelay: number): void {
this.safeExecute(() => {
for (const filePath of filePaths) {
// 内部直接处理,不通过 safeExecute 以保证批量操作的原子性
if (this.tasks.has(filePath)) {
// 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查
const existingTask = this.tasks.get(filePath);
if (existingTask) {
clearTimeout(existingTask.timer);
}
}
const fileRecord: FileRecord = {
filePath,
addedTime: Date.now(),
retries: 0
};
const timer = setTimeout(() => {
this.cleanupFile(fileRecord, cleanupDelay);
}, cleanupDelay);
this.tasks.set(filePath, { fileRecord, timer });
}
});
}
/**
* 清理文件
* @param record 文件记录
* @param delay 延迟时间,用于重试
*/
private cleanupFile(record: FileRecord, delay: number): void {
this.safeExecute(() => {
// 首先检查文件是否存在,不存在则视为清理成功
if (!this.fileExists(record.filePath)) {
//console.log(`文件已不存在,跳过清理: ${record.filePath}`);
this.tasks.delete(record.filePath);
return;
}
try {
// 尝试删除文件
fs.unlinkSync(record.filePath);
// 删除成功,从队列中移除任务
this.tasks.delete(record.filePath);
} catch (error) {
const err = error as NodeJS.ErrnoException;
// 明确处理文件不存在的情况
if (err.code === 'ENOENT') {
//console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`);
this.tasks.delete(record.filePath);
return;
}
// 文件没有访问权限等情况
if (err.code === 'EACCES' || err.code === 'EPERM') {
//console.error(`没有权限删除文件: ${record.filePath}`, err);
}
// 其他删除失败情况,考虑重试
if (record.retries < this.MAX_RETRIES - 1) {
// 还有重试机会,增加重试次数
record.retries++;
//console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`);
// 设置相同的延迟时间再次尝试
const timer = setTimeout(() => {
this.cleanupFile(record, delay);
}, delay);
// 更新任务
this.tasks.set(record.filePath, { fileRecord: record, timer });
} else {
// 已达到最大重试次数,从队列中移除任务
this.tasks.delete(record.filePath);
//console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error);
}
}
});
}
/**
* 取消文件的清理任务
* @param filePath 文件路径
* @returns 是否成功取消
*/
cancelCleanup(filePath: string): boolean {
let cancelled = false;
this.safeExecute(() => {
const task = this.tasks.get(filePath);
if (task) {
clearTimeout(task.timer);
this.tasks.delete(filePath);
cancelled = true;
}
});
return cancelled;
}
/**
* 获取队列中的文件数量
* @returns 文件数量
*/
getQueueSize(): number {
return this.tasks.size;
}
/**
* 获取所有待清理的文件
* @returns 文件路径数组
*/
getPendingFiles(): string[] {
return Array.from(this.tasks.keys());
}
/**
* 清空所有清理任务
*/
clearAll(): void {
this.safeExecute(() => {
// 取消所有定时器
for (const task of this.tasks.values()) {
clearTimeout(task.timer);
}
this.tasks.clear();
//console.log('已清空所有清理任务');
});
}
}
export const cleanTaskQueue = new CleanupQueue();

View File

@@ -0,0 +1,352 @@
// 更正导入语句
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import * as os from 'os';
import * as compressing from 'compressing'; // 修正导入方式
import { pipeline } from 'stream/promises';
import { fileURLToPath } from 'url';
import { LogWrapper } from './log';
const downloadOri = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-04-16-12-54/ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1.zip"
const urls = [
"https://github.moeyy.xyz/" + downloadOri,
"https://ghp.ci/" + downloadOri,
"https://gh.api.99988866.xyz/" + downloadOri,
downloadOri
];
/**
* 测试URL是否可用
* @param url 待测试的URL
* @returns 如果URL可访问返回true否则返回false
*/
async function testUrl(url: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const req = https.get(url, { timeout: 5000 }, (res) => {
// 检查状态码是否表示成功
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
// 终止请求并返回true
req.destroy();
resolve(true);
} else {
req.destroy();
resolve(false);
}
});
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
/**
* 查找第一个可用的URL
* @returns 返回第一个可用的URL如果都不可用则返回null
*/
async function findAvailableUrl(): Promise<string | null> {
for (const url of urls) {
try {
const available = await testUrl(url);
if (available) {
return url;
}
} catch (error) {
// 忽略错误
}
}
return null;
}
/**
* 下载文件
* @param url 下载URL
* @param destPath 目标保存路径
* @returns 成功返回true失败返回false
*/
async function downloadFile(url: string, destPath: string, progressCallback?: (percent: number) => void): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const file = fs.createWriteStream(destPath);
const req = https.get(url, (res) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
// 获取文件总大小
const totalSize = parseInt(res.headers['content-length'] || '0', 10);
let downloadedSize = 0;
let lastReportedPercent = -1; // 上次报告的百分比
let lastReportTime = 0; // 上次报告的时间戳
// 如果有内容长度和进度回调,则添加数据监听
if (totalSize > 0 && progressCallback) {
// 初始报告 0%
progressCallback(0);
lastReportTime = Date.now();
res.on('data', (chunk) => {
downloadedSize += chunk.length;
const currentPercent = Math.floor((downloadedSize / totalSize) * 100);
const now = Date.now();
// 只在以下条件触发回调:
// 1. 百分比变化至少为1%
// 2. 距离上次报告至少500毫秒
// 3. 确保报告100%完成
if ((currentPercent !== lastReportedPercent &&
(currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) &&
(now - lastReportTime >= 1000 || currentPercent === 100)) {
progressCallback(currentPercent);
lastReportedPercent = currentPercent;
lastReportTime = now;
}
});
}
pipeline(res, file)
.then(() => {
// 确保最后报告100%
if (progressCallback && lastReportedPercent !== 100) {
progressCallback(100);
}
resolve(true);
})
.catch(() => resolve(false));
} else {
file.close();
fs.unlink(destPath, () => { });
resolve(false);
}
});
req.on('error', () => {
file.close();
fs.unlink(destPath, () => { });
resolve(false);
});
});
}
/**
* 解压缩zip文件中的特定内容
* 只解压bin目录中的文件到目标目录
* @param zipPath 压缩文件路径
* @param extractDir 解压目标路径
*/
async function extractBinDirectory(zipPath: string, extractDir: string): Promise<void> {
try {
// 确保目标目录存在
if (!fs.existsSync(extractDir)) {
fs.mkdirSync(extractDir, { recursive: true });
}
// 解压文件
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
return new Promise<void>((resolve, reject) => {
// 监听条目事件
zipStream.on('entry', (header, stream, next) => {
// 获取文件路径
const filePath = header.name;
// 匹配内层bin目录中的文件
// 例如ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe
if (filePath.includes('/bin/') && filePath.endsWith('.exe')) {
// 提取文件名
const fileName = path.basename(filePath);
const targetPath = path.join(extractDir, fileName);
// 创建写入流
const writeStream = fs.createWriteStream(targetPath);
// 将流管道连接到文件
stream.pipe(writeStream);
// 监听写入完成事件
writeStream.on('finish', () => {
next();
});
writeStream.on('error', () => {
next();
});
} else {
// 跳过不需要的文件
stream.resume();
next();
}
});
zipStream.on('error', (err) => {
reject(err);
});
zipStream.on('finish', () => {
resolve();
});
});
} catch (err) {
throw err;
}
}
/**
* 下载并设置FFmpeg
* @param destDir 目标安装目录默认为用户临时目录下的ffmpeg文件夹
* @param tempDir 临时文件目录,默认为系统临时目录
* @returns 返回ffmpeg可执行文件的路径如果失败则返回null
*/
export async function downloadFFmpeg(
destDir?: string,
tempDir?: string,
progressCallback?: (percent: number, stage: string) => void
): Promise<string | null> {
// 仅限Windows
if (os.platform() !== 'win32') {
return null;
}
const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg');
const tempDirectory = tempDir || os.tmpdir();
const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录
const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe');
// 确保目录存在
if (!fs.existsSync(destinationDir)) {
fs.mkdirSync(destinationDir, { recursive: true });
}
// 确保临时目录存在
if (!fs.existsSync(tempDirectory)) {
fs.mkdirSync(tempDirectory, { recursive: true });
}
// 如果ffmpeg已经存在直接返回路径
if (fs.existsSync(ffmpegExePath)) {
if (progressCallback) progressCallback(100, '已找到FFmpeg');
return ffmpegExePath;
}
// 查找可用URL
if (progressCallback) progressCallback(0, '查找可用下载源');
const availableUrl = await findAvailableUrl();
if (!availableUrl) {
return null;
}
// 下载文件
if (progressCallback) progressCallback(5, '开始下载FFmpeg');
const downloaded = await downloadFile(
availableUrl,
zipFilePath,
(percent) => {
// 下载占总进度的70%
if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg');
}
);
if (!downloaded) {
return null;
}
try {
// 直接解压bin目录文件到目标目录
if (progressCallback) progressCallback(75, '解压FFmpeg');
await extractBinDirectory(zipFilePath, destinationDir);
// 清理下载文件
if (progressCallback) progressCallback(95, '清理临时文件');
try {
fs.unlinkSync(zipFilePath);
} catch (err) {
// 忽略清理临时文件失败的错误
}
// 检查ffmpeg.exe是否成功解压
if (fs.existsSync(ffmpegExePath)) {
if (progressCallback) progressCallback(100, 'FFmpeg安装完成');
return ffmpegExePath;
} else {
return null;
}
} catch (err) {
return null;
}
}
/**
* 检查系统PATH环境变量中是否存在指定可执行文件
* @param executable 可执行文件名
* @returns 如果找到返回完整路径否则返回null
*/
function findExecutableInPath(executable: string): string | null {
// 仅适用于Windows系统
if (os.platform() !== 'win32') return null;
// 获取PATH环境变量
const pathEnv = process.env['PATH'] || '';
const pathDirs = pathEnv.split(';');
// 检查每个目录
for (const dir of pathDirs) {
if (!dir) continue;
try {
const filePath = path.join(dir, executable);
if (fs.existsSync(filePath)) {
return filePath;
}
} catch (error) {
continue;
}
}
return null;
}
export async function downloadFFmpegIfNotExists(log: LogWrapper) {
// 仅限Windows
if (os.platform() !== 'win32') {
return {
path: null,
reset: false
};
}
const ffmpegInPath = findExecutableInPath('ffmpeg.exe');
const ffprobeInPath = findExecutableInPath('ffprobe.exe');
if (ffmpegInPath && ffprobeInPath) {
const ffmpegDir = path.dirname(ffmpegInPath);
return {
path: ffmpegDir,
reset: true
};
}
// 如果环境变量中没有,检查项目目录中是否存在
const currentPath = path.dirname(fileURLToPath(import.meta.url));
const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe'));
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
if (!ffmpeg_exist || !ffprobe_exist) {
await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
});
return {
path: path.join(currentPath, 'ffmpeg'),
reset: true
}
}
return {
path: path.join(currentPath, 'ffmpeg'),
reset: true
}
}

View File

@@ -1,165 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FFmpeg } from '@ffmpeg.wasm/main';
import { randomUUID } from 'crypto';
import { readFileSync, statSync, writeFileSync } from 'fs';
import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
import imageSize from 'image-size';
import { parentPort } from 'worker_threads';
export function recvTask<T>(cb: (taskData: T) => Promise<unknown>) {
parentPort?.on('message', async (taskData: T) => {
try {
let ret = await cb(taskData);
parentPort?.postMessage(ret);
} catch (error: unknown) {
parentPort?.postMessage({ error: (error as Error).message });
}
});
}
class FFmpegService {
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const videoFileName = `${randomUUID()}.mp4`;
const outputFileName = `${randomUUID()}.jpg`;
try {
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
const code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
if (code !== 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
const thumbnail = ffmpegInstance.fs.readFile(outputFileName);
writeFileSync(thumbnailPath, thumbnail);
} catch (error) {
console.error('Error extracting thumbnail:', error);
throw error;
} finally {
try {
ffmpegInstance.fs.unlink(outputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
try {
ffmpegInstance.fs.unlink(videoFileName);
} catch (unlinkError) {
console.error('Error unlinking video file:', unlinkError);
}
}
}
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const inputFileName = `${randomUUID()}.pcm`;
const outputFileName = `${randomUUID()}.${format}`;
try {
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(inputFile));
const params = format === 'amr'
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
const code = await ffmpegInstance.run(...params);
if (code !== 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
const outputData = ffmpegInstance.fs.readFile(outputFileName);
writeFileSync(outputFile, outputData);
} catch (error) {
console.error('Error converting file:', error);
throw error;
} finally {
try {
ffmpegInstance.fs.unlink(outputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
try {
ffmpegInstance.fs.unlink(inputFileName);
} catch (unlinkError) {
console.error('Error unlinking input file:', unlinkError);
}
}
}
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const inputFileName = `${randomUUID()}.input`;
const outputFileName = `${randomUUID()}.pcm`;
try {
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
const code = await ffmpegInstance.run(...params);
if (code !== 0) {
throw new Error('FFmpeg process exited with code ' + code);
}
const outputData = ffmpegInstance.fs.readFile(outputFileName);
writeFileSync(pcmPath, outputData);
return Buffer.from(outputData);
} catch (error: any) {
throw new Error('FFmpeg处理转换出错: ' + error.message);
} finally {
try {
ffmpegInstance.fs.unlink(outputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
try {
ffmpegInstance.fs.unlink(inputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
}
}
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
const fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
const inputFileName = `${randomUUID()}.${fileType}`;
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
ffmpegInstance.setLogging(true);
let duration = 60;
ffmpegInstance.setLogger((_level, ...msg) => {
const message = msg.join(' ');
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
if (durationMatch) {
const hours = parseInt(durationMatch[1] ?? '0', 10);
const minutes = parseInt(durationMatch[2] ?? '0', 10);
const seconds = parseFloat(durationMatch[3] ?? '0');
duration = hours * 3600 + minutes * 60 + seconds;
}
});
await ffmpegInstance.run('-i', inputFileName);
const image = imageSize(thumbnailPath);
ffmpegInstance.fs.unlink(inputFileName);
const fileSize = statSync(videoPath).size;
return {
width: image.width ?? 100,
height: image.height ?? 100,
time: duration,
format: fileType,
size: fileSize,
filePath: videoPath
};
}
}
type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
interface FFmpegTask {
method: FFmpegMethod;
args: any[];
}
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
switch (method) {
case 'extractThumbnail':
return await FFmpegService.extractThumbnail(...args as [string, string]);
case 'convertFile':
return await FFmpegService.convertFile(...args as [string, string, string]);
case 'convert':
return await FFmpegService.convert(...args as [string, string]);
case 'getVideoInfo':
return await FFmpegService.getVideoInfo(...args as [string, string]);
default:
throw new Error(`Unknown method: ${method}`);
}
}
recvTask<FFmpegTask>(async ({ method, args }: FFmpegTask) => {
return await handleFFmpegTask({ method, args });
});

View File

@@ -1,36 +1,195 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ import { readFileSync, statSync, existsSync, mkdirSync } from 'fs';
import { VideoInfo } from './video'; import path, { dirname } from 'path';
import path from 'path'; import { execFile } from 'child_process';
import { fileURLToPath } from 'url'; import { promisify } from 'util';
import { runTask } from './worker'; import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
type EncodeArgs = { import imageSize from 'image-size';
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo'; import { fileURLToPath } from 'node:url';
args: any[]; import { platform } from 'node:os';
import { LogWrapper } from './log';
const currentPath = dirname(fileURLToPath(import.meta.url));
const execFileAsync = promisify(execFile);
const getFFmpegPath = (tool: string): string => {
if (process.platform === 'win32') {
const exeName = `${tool}.exe`;
const isLocalExeExists = existsSync(path.join(currentPath, 'ffmpeg', exeName));
return isLocalExeExists ? path.join(currentPath, 'ffmpeg', exeName) : exeName;
}
return tool;
}; };
export let FFMPEG_CMD = getFFmpegPath('ffmpeg');
type EncodeResult = any; export let FFPROBE_CMD = getFFmpegPath('ffprobe');
function getWorkerPath() {
return path.join(path.dirname(fileURLToPath(import.meta.url)), './ffmpeg-worker.mjs');
}
export class FFmpegService { export class FFmpegService {
// 确保目标目录存在
public static setFfmpegPath(ffmpegPath: string,logger:LogWrapper): void {
if (platform() === 'win32') {
FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe');
FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe');
logger.log('[Check] ffmpeg:', FFMPEG_CMD);
logger.log('[Check] ffprobe:', FFPROBE_CMD);
}
}
private static ensureDirExists(filePath: string): void {
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> { public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'extractThumbnail', args: [videoPath, thumbnailPath] }); try {
this.ensureDirExists(thumbnailPath);
const { stderr } = await execFileAsync(FFMPEG_CMD, [
'-i', videoPath,
'-ss', '00:00:01.000',
'-vframes', '1',
'-y', // 覆盖输出文件
thumbnailPath
]);
if (!existsSync(thumbnailPath)) {
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
}
} catch (error) {
console.error('Error extracting thumbnail:', error);
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
}
} }
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> { public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convertFile', args: [inputFile, outputFile, format] }); try {
this.ensureDirExists(outputFile);
const params = format === 'amr'
? [
'-f', 's16le',
'-ar', '24000',
'-ac', '1',
'-i', inputFile,
'-ar', '8000',
'-b:a', '12.2k',
'-y',
outputFile
]
: [
'-f', 's16le',
'-ar', '24000',
'-ac', '1',
'-i', inputFile,
'-y',
outputFile
];
await execFileAsync(FFMPEG_CMD, params);
if (!existsSync(outputFile)) {
throw new Error('转换失败,输出文件不存在');
}
} catch (error) {
console.error('Error converting file:', error);
throw new Error(`文件转换失败: ${(error as Error).message}`);
}
} }
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> { public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
const result = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convert', args: [filePath, pcmPath] }); try {
return result; this.ensureDirExists(pcmPath);
await execFileAsync(FFMPEG_CMD, [
'-y',
'-i', filePath,
'-ar', '24000',
'-ac', '1',
'-f', 's16le',
pcmPath
]);
if (!existsSync(pcmPath)) {
throw new Error('转换PCM失败输出文件不存在');
}
return readFileSync(pcmPath);
} catch (error: any) {
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
}
} }
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> { public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
const result = await await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] }); try {
return result; // 并行执行获取文件信息和提取缩略图
const [fileInfo, duration] = await Promise.all([
this.getFileInfo(videoPath, thumbnailPath),
this.getVideoDuration(videoPath)
]);
const result: VideoInfo = {
width: fileInfo.width,
height: fileInfo.height,
time: duration,
format: fileInfo.format,
size: fileInfo.size,
filePath: videoPath
};
return result;
} catch (error) {
throw error;
}
} }
}
private static async getFileInfo(videoPath: string, thumbnailPath: string): Promise<{
format: string,
size: number,
width: number,
height: number
}> {
// 获取文件大小和类型
const [fileType, fileSize] = await Promise.all([
fileTypeFromFile(videoPath).catch(() => {
return null;
}),
Promise.resolve(statSync(videoPath).size)
]);
try {
await this.extractThumbnail(videoPath, thumbnailPath);
// 获取图片尺寸
const dimensions = imageSize(thumbnailPath);
return {
format: fileType?.ext ?? 'mp4',
size: fileSize,
width: dimensions.width ?? 100,
height: dimensions.height ?? 100
};
} catch (error) {
return {
format: fileType?.ext ?? 'mp4',
size: fileSize,
width: 100,
height: 100
};
}
}
private static async getVideoDuration(videoPath: string): Promise<number> {
try {
// 使用FFprobe获取时长
const { stdout } = await execFileAsync(FFPROBE_CMD, [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
videoPath
]);
const duration = parseFloat(stdout.trim());
return isNaN(duration) ? 60 : duration;
} catch (error) {
return 60; // 默认时长
}
}
}

View File

@@ -76,7 +76,7 @@ export function calculateFileMD5(filePath: string): Promise<string> {
const stream = fs.createReadStream(filePath); const stream = fs.createReadStream(filePath);
const hash = crypto.createHash('md5'); const hash = crypto.createHash('md5');
stream.on('data', (data: Buffer) => { stream.on('data', (data) => {
// 当读取到数据时,更新哈希对象的状态 // 当读取到数据时,更新哈希对象的状态
hash.update(data); hash.update(data);
}); });
@@ -115,7 +115,7 @@ async function tryDownload(options: string | HttpDownloadOptions, useReferer: bo
if (useReferer && !headers['Referer']) { if (useReferer && !headers['Referer']) {
headers['Referer'] = url; headers['Referer'] = url;
} }
const fetchRes = await fetch(url, { headers }).catch((err) => { const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
if (err.cause) { if (err.cause) {
throw err.cause; throw err.cause;
} }
@@ -145,8 +145,8 @@ export enum FileUriType {
export async function checkUriType(Uri: string) { export async function checkUriType(Uri: string) {
const LocalFileRet = await solveProblem((uri: string) => { const LocalFileRet = await solveProblem((uri: string) => {
if (fs.existsSync(uri)) { if (fs.existsSync(path.normalize(uri))) {
return { Uri: uri, Type: FileUriType.Local }; return { Uri: path.normalize(uri), Type: FileUriType.Local };
} }
return undefined; return undefined;
}, Uri); }, Uri);
@@ -182,28 +182,28 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string
const filePath = path.join(dir, filename); const filePath = path.join(dir, filename);
switch (UriType) { switch (UriType) {
case FileUriType.Local: { case FileUriType.Local: {
const fileExt = path.extname(HandledUri); const fileExt = path.extname(HandledUri);
const localFileName = path.basename(HandledUri, fileExt) + fileExt; const localFileName = path.basename(HandledUri, fileExt) + fileExt;
const tempFilePath = path.join(dir, filename + fileExt); const tempFilePath = path.join(dir, filename + fileExt);
fs.copyFileSync(HandledUri, tempFilePath); fs.copyFileSync(HandledUri, tempFilePath);
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath }; return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
} }
case FileUriType.Remote: { case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} }); const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
fs.writeFileSync(filePath, buffer); fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath }; return { success: true, errMsg: '', fileName: filename, path: filePath };
} }
case FileUriType.Base64: { case FileUriType.Base64: {
const base64 = HandledUri.replace(/^base64:\/\//, ''); const base64 = HandledUri.replace(/^base64:\/\//, '');
const base64Buffer = Buffer.from(base64, 'base64'); const base64Buffer = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, base64Buffer); fs.writeFileSync(filePath, base64Buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath }; return { success: true, errMsg: '', fileName: filename, path: filePath };
} }
default: default:
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' }; return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
} }
} }

View File

@@ -109,7 +109,6 @@ export class RequestUtil {
req.end(); req.end();
}); });
} }
// 请求返回都是原始内容 // 请求返回都是原始内容
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); return this.HttpGetJson<string>(url, method, data, headers, false, false);

View File

@@ -1 +1 @@
export const napCatVersion = '4.7.0'; export const napCatVersion = '4.7.45';

View File

@@ -5,6 +5,12 @@ export async function runTask<T, R>(workerScript: string, taskData: T): Promise<
try { try {
return await new Promise<R>((resolve, reject) => { return await new Promise<R>((resolve, reject) => {
worker.on('message', (result: R) => { worker.on('message', (result: R) => {
if ((result as any)?.log) {
console.error('Worker Log--->:', (result as { log: string }).log);
}
if ((result as any)?.error) {
reject(new Error("Worker error: " + (result as { error: string }).error));
}
resolve(result); resolve(result);
}); });

View File

@@ -41,10 +41,10 @@ export class NTQQFileApi {
this.context = context; this.context = context;
this.core = core; this.core = core;
this.rkeyManager = new RkeyManager([ this.rkeyManager = new RkeyManager([
'https://ss.xingzhige.com/music_card/rkey', // 国内 'https://secret-service.bietiaop.com/rkeys',
'https://secret-service.bietiaop.com/rkeys',//国内 'http://ss.xingzhige.com/music_card/rkey',
], ],
this.context.logger this.context.logger
); );
} }
@@ -182,23 +182,30 @@ export class NTQQFileApi {
filePath = newFilePath; filePath = newFilePath;
const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO); const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO);
context.deleteAfterSentFiles.push(path);
if (fileSize === 0) { if (fileSize === 0) {
throw new Error('文件异常大小为0'); throw new Error('文件异常大小为0');
} }
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`); const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true }); fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`); const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
} catch {
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
if (_diyThumbPath) { if (_diyThumbPath) {
try { try {
await this.copyFile(_diyThumbPath, thumbPath); await this.copyFile(_diyThumbPath, thumbPath);
} catch (e) { } catch (e) {
this.context.logger.logError('复制自定义缩略图失败', e); this.context.logger.logError('复制自定义缩略图失败', e);
} }
} else {
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
if (!fs.existsSync(thumbPath)) {
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
throw new Error('获取视频缩略图失败');
}
} catch (e) {
this.context.logger.logError('获取视频信息失败', e);
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
} }
context.deleteAfterSentFiles.push(thumbPath); context.deleteAfterSentFiles.push(thumbPath);
const thumbSize = (await fsPromises.stat(thumbPath)).size; const thumbSize = (await fsPromises.stat(thumbPath)).size;
@@ -224,7 +231,7 @@ export class NTQQFileApi {
}, },
}; };
} }
async createValidSendPttElement(pttPath: string): Promise<SendPttElement> { async createValidSendPttElement(_context: SendMessageContext, pttPath: string): Promise<SendPttElement> {
const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger); const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger);
if (!silkPath) { if (!silkPath) {
@@ -301,18 +308,18 @@ export class NTQQFileApi {
element.elementType === ElementType.FILE element.elementType === ElementType.FILE
) { ) {
switch (element.elementType) { switch (element.elementType) {
case ElementType.PIC: case ElementType.PIC:
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? ''; element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
break; break;
case ElementType.VIDEO: case ElementType.VIDEO:
element.videoElement!.filePath = elementResults?.[elementIndex] ?? ''; element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
break; break;
case ElementType.PTT: case ElementType.PTT:
element.pttElement!.filePath = elementResults?.[elementIndex] ?? ''; element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
break; break;
case ElementType.FILE: case ElementType.FILE:
element.fileElement!.filePath = elementResults?.[elementIndex] ?? ''; element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
break; break;
} }
elementIndex++; elementIndex++;
} }
@@ -338,6 +345,7 @@ export class NTQQFileApi {
'NodeIKernelMsgListener/onRichMediaDownloadComplete', 'NodeIKernelMsgListener/onRichMediaDownloadComplete',
[{ [{
fileModelId: '0', fileModelId: '0',
downSourceType: 0,
downloadSourceType: 0, downloadSourceType: 0,
triggerType: 1, triggerType: 1,
msgId: msgId, msgId: msgId,

View File

@@ -86,4 +86,31 @@ export class NTQQFriendApi {
accept, accept,
}); });
} }
async handleDoubtFriendRequest(friendUid: string, str1: string = '', str2: string = '') {
this.context.session.getBuddyService().approvalDoubtBuddyReq(friendUid, str1, str2);
}
async getDoubtFriendRequest(count: number) {
let date = Date.now().toString();
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelBuddyService/getDoubtBuddyReq',
'NodeIKernelBuddyListener/onDoubtBuddyReqChange',
[date, count, ''],
() => true,
(data) => data.reqId === date
);
let requests = Promise.all(ret.doubtList.map(async (item) => {
return {
flag: item.uid, //注意强制String 非isNumeric 不遵守则不符合设计
uin: await this.core.apis.UserApi.getUinByUidV2(item.uid) ?? 0,// 信息字段
nick: item.nick, // 信息字段 这个不是nickname 可能是来源的群内的昵称
source: item.source, // 信息字段
reason: item.reason, // 信息字段
msg: item.msg, // 信息字段
group_code: item.groupCode, // 信息字段
time: item.reqTime, // 信息字段
type: 'doubt' //保留字段
};
}))
return requests;
}
} }

View File

@@ -218,6 +218,10 @@ export class NTQQGroupApi {
return this.context.session.getRichMediaService().deleteGroupFolder(groupCode, folderId); return this.context.session.getRichMediaService().deleteGroupFolder(groupCode, folderId);
} }
async transGroupFile(groupCode: string, fileId: string) {
return this.context.session.getRichMediaService().transGroupFile(groupCode, fileId);
}
async addGroupEssence(groupCode: string, msgId: string) { async addGroupEssence(groupCode: string, msgId: string) {
const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({ const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
chatType: 2, chatType: 2,

View File

@@ -12,11 +12,29 @@ export class NTQQMsgApi {
this.context = context; this.context = context;
this.core = core; this.core = core;
} }
async clickInlineKeyboardButton(...params: Parameters<NodeIKernelMsgService['clickInlineKeyboardButton']>) { async clickInlineKeyboardButton(...params: Parameters<NodeIKernelMsgService['clickInlineKeyboardButton']>) {
return this.context.session.getMsgService().clickInlineKeyboardButton(...params); return this.context.session.getMsgService().clickInlineKeyboardButton(...params);
} }
async searchMsgWithKeywords(keyWords: string[], param: Peer & { searchFields: number, pageLimit: number }) {
let outputSearchId = 0;
return this.core.eventWrapper.callNormalEventV2(
'NodeIKernelSearchService/searchMsgWithKeywords',
'NodeIKernelSearchListener/onSearchMsgKeywordsResult',
[keyWords, param],
(searchId) => {
outputSearchId = searchId;
return true;
},
(event) => {
return event.searchId == outputSearchId;
},
1,
5000
);
}
getMsgByClientSeqAndTime(peer: Peer, replyMsgClientSeq: string, replyMsgTime: string) { getMsgByClientSeqAndTime(peer: Peer, replyMsgClientSeq: string, replyMsgTime: string) {
// https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取 // https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取
return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime); return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime);
@@ -137,7 +155,6 @@ export class NTQQMsgApi {
} }
async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) { async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) {
console.log(peer, SendersUid);
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', { return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer, chatInfo: peer,
filterMsgType: [], filterMsgType: [],
@@ -149,7 +166,30 @@ export class NTQQMsgApi {
pageLimit: 20000, pageLimit: 20000,
}); });
} }
async queryFirstMsgBySenderTime(peer: Peer, SendersUid: string[], filterMsgFromTime: string, filterMsgToTime: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: SendersUid,
filterMsgToTime: filterMsgToTime,
filterMsgFromTime: filterMsgFromTime,
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 20000,
});
}
async queryFirstMsgByTime(peer: Peer, filterMsgFromTime: string, filterMsgToTime: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: filterMsgToTime,
filterMsgFromTime: filterMsgFromTime,
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 20000,
});
}
async setMsgRead(peer: Peer) { async setMsgRead(peer: Peer) {
return this.context.session.getMsgService().setMsgRead(peer); return this.context.session.getMsgService().setMsgRead(peer);
} }

View File

@@ -90,7 +90,30 @@ export class NTQQUserApi {
() => true, () => true,
(profile) => profile.uid === uid, (profile) => profile.uid === uid,
); );
const RetUser: User = { return profile;
}
async getUserDetailInfo(uid: string, no_cache: boolean = false): Promise<User> {
let profile = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, no_cache ? UserDetailSource.KSERVER : UserDetailSource.KDB), uid);
if (profile && profile.uin !== '0' && profile.commonExt) {
return {
...profile.simpleInfo.status,
...profile.simpleInfo.vasInfo,
...profile.commonExt,
...profile.simpleInfo.baseInfo,
...profile.simpleInfo.coreInfo,
qqLevel: profile.commonExt?.qqLevel,
age: profile.simpleInfo.baseInfo.age,
pendantId: '',
nick: profile.simpleInfo.coreInfo.nick || '',
};
}
this.context.logger.logDebug('[NapCat] [Mark] getUserDetailInfo Mode1 Failed.');
profile = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER);
if (profile && profile.uin === '0') {
profile.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0';
}
return {
...profile.simpleInfo.status, ...profile.simpleInfo.status,
...profile.simpleInfo.vasInfo, ...profile.simpleInfo.vasInfo,
...profile.commonExt, ...profile.commonExt,
@@ -101,33 +124,6 @@ export class NTQQUserApi {
pendantId: '', pendantId: '',
nick: profile.simpleInfo.coreInfo.nick || '', nick: profile.simpleInfo.coreInfo.nick || '',
}; };
return RetUser;
}
async getUserDetailInfo(uid: string): Promise<User> {
let retUser = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, UserDetailSource.KDB), uid);
if (retUser && retUser.uin !== '0') {
return retUser;
}
this.context.logger.logDebug('[NapCat] [Mark] getUserDetailInfo Mode1 Failed.');
retUser = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER);
if (retUser && retUser.uin === '0') {
retUser.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0';
}
return retUser;
}
async getUserDetailInfoV2(uid: string): Promise<User> {
const fallback = new Fallback<User>((user) => FallbackUtil.boolchecker(user, user !== undefined && user.uin !== '0'))
.add(() => this.fetchUserDetailInfo(uid, UserDetailSource.KDB))
.add(() => this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER));
const retUser = await fallback.run().then(async (user) => {
if (user && user.uin === '0') {
user.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0';
}
return user;
});
return retUser;
} }
async modifySelfProfile(param: ModifyProfileParams) { async modifySelfProfile(param: ModifyProfileParams) {

View File

@@ -1,4 +1,76 @@
{ {
"3.1.2-13107": {
"appid": 537146866,
"qua": "V1_LNX_NQ_3.1.2-13107_RDM_B"
},
"3.2.10-25765": {
"appid": 537234773,
"qua": "V1_LNX_NQ_3.2.10_25765_GW_B"
},
"3.2.12-26702": {
"appid": 537237950,
"qua": "V1_LNX_NQ_3.2.12_26702_GW_B"
},
"3.2.12-26740": {
"appid": 537237950,
"qua": "V1_LNX_NQ_3.2.12_26740_GW_B"
},
"3.2.12-26909": {
"appid": 537237923,
"qua": "V1_LNX_NQ_3.2.12_26909_GW_B"
},
"3.2.12-27187": {
"appid": 537240645,
"qua": "V1_LNX_NQ_3.2.12_27187_GW_B"
},
"3.2.12-27206": {
"appid": 537240645,
"qua": "V1_LNX_NQ_3.2.12_27206_GW_B"
},
"3.2.12-27254": {
"appid": 537240795,
"qua": "V1_LNX_NQ_3.2.12_27254_GW_B"
},
"9.9.11-24815": {
"appid": 537226656,
"qua": "V1_WIN_NQ_9.9.11_24815_GW_B"
},
"9.9.12-25493": {
"appid": 537231759,
"qua": "V1_WIN_NQ_9.9.12_25493_GW_B"
},
"9.9.12-25765": {
"appid": 537234702,
"qua": "V1_WIN_NQ_9.9.12_25765_GW_B"
},
"9.9.12-26299": {
"appid": 537234826,
"qua": "V1_WIN_NQ_9.9.12_26299_GW_B"
},
"9.9.12-26339": {
"appid": 537234826,
"qua": "V1_WIN_NQ_9.9.12_26339_GW_B"
},
"9.9.12-26466": {
"appid": 537234826,
"qua": "V1_WIN_NQ_9.9.12_26466_GW_B"
},
"9.9.15-26702": {
"appid": 537237765,
"qua": "V1_WIN_NQ_9.9.15_26702_GW_B"
},
"9.9.15-26740": {
"appid": 537237765,
"qua": "V1_WIN_NQ_9.9.15_26740_GW_B"
},
"3.2.12-27556": {
"appid": 537243600,
"qua": "V1_LNX_NQ_3.2.12_27556_GW_B"
},
"3.2.12-27597": {
"appid": 537243600,
"qua": "V1_LNX_NQ_3.2.12_27597_GW_B"
},
"9.9.15-28060": { "9.9.15-28060": {
"appid": 537246092, "appid": 537246092,
"qua": "V1_WIN_NQ_9.9.15_28060_GW_B" "qua": "V1_WIN_NQ_9.9.15_28060_GW_B"
@@ -218,5 +290,277 @@
"9.9.18-32869": { "9.9.18-32869": {
"appid": 537271294, "appid": 537271294,
"qua": "V1_WIN_NQ_9.9.18_32869_GW_B" "qua": "V1_WIN_NQ_9.9.18_32869_GW_B"
},
"3.2.16-33139": {
"appid": 537273909,
"qua": "V1_LNX_NQ_3.2.16_33139_GW_B"
},
"9.9.18-33139": {
"appid": 537273874,
"qua": "V1_WIN_NQ_9.9.18_33139_GW_B"
},
"9.9.18-33800": {
"appid": 537273974,
"qua": "V1_WIN_NQ_9.9.18_33800_GW_B"
},
"3.2.16-33800": {
"appid": 537274009,
"qua": "V1_LNX_NQ_3.2.16_33800_GW_B"
},
"9.9.19-34231": {
"appid": 537279209,
"qua": "V1_WIN_NQ_9.9.19_34231_GW_B"
},
"3.2.17-34231": {
"appid": 537279245,
"qua": "V1_LNX_NQ_3.2.17_34231_GW_B"
},
"9.9.19-34362": {
"appid": 537279260,
"qua": "V1_WIN_NQ_9.9.19_34362_GW_B"
},
"3.2.17-34362": {
"appid": 537279296,
"qua": "V1_LNX_NQ_3.2.17_34362_GW_B"
},
"9.9.19-34467": {
"appid": 537282256,
"qua": "V1_WIN_NQ_9.9.19_34467_GW_B"
},
"3.2.17-34467": {
"appid": 537282292,
"qua": "V1_LNX_NQ_3.2.17_34467_GW_B"
},
"9.9.19-34566": {
"appid": 537282307,
"qua": "V1_WIN_NQ_9.9.19_34566_GW_B"
},
"3.2.17-34566": {
"appid": 537282343,
"qua": "V1_LNX_NQ_3.2.17_34566_GW_B"
},
"3.2.17-34606": {
"appid": 537282343,
"qua": "V1_LNX_NQ_3.2.17_34606_GW_B"
},
"9.9.19-34606": {
"appid": 537282307,
"qua": "V1_WIN_NQ_9.9.19_34606_GW_B"
},
"9.9.19-34740": {
"appid": 537290691,
"qua": "V1_WIN_NQ_9.9.19_34740_GW_B"
},
"3.2.17-34740": {
"appid": 537290727,
"qua": "V1_LNX_NQ_3.2.17_34740_GW_B"
},
"8.9.50-Android": {
"appid": 537155551,
"qua": "V1_AND_SQ_8.9.50_3898_YYB_D"
},
"9.0.1-Watch": {
"appid": 537214131,
"qua": "V1_AND_SQ_8.9.68_0_RDM_B"
},
"6.8.2-21241": {
"appid": 537128930,
"qua": "V1_IOS_SQ_6.8.2_21241_YYB_D"
},
"8.8.88-Android": {
"appid": 537118044,
"qua": "V1_IOS_SQ_8.8.88_2770_YYB_D"
},
"8.9.90-Android": {
"appid": 537185007,
"qua": "V1_AND_SQ_8.9.90_4938_YYB_D"
},
"8.9.33-Android": {
"appid": 537151682,
"qua": "V1_AND_SQ_8.9.33_3898_YYB_D"
},
"3.5.1-Tim": {
"appid": 537150355,
"qua": "V1_AND_SQ_8.3.9_351_TIM_D"
},
"8.9.58-Android": {
"appid": 537163194,
"qua": "V1_AND_SQ_8.9.58_4108_YYB_D"
},
"8.9.63-Android": {
"appid": 537163194,
"qua": "V1_AND_SQ_8.9.63_4194_YYB_D"
},
"8.9.68-Android": {
"appid": 537168313,
"qua": "V1_AND_SQ_8.9.68_4264_YYB_D"
},
"8.9.70-Android": {
"appid": 537169928,
"qua": "V1_AND_SQ_8.9.70_4330_YYB_D"
},
"8.9.71-Android": {
"appid": 537170024,
"qua": "V1_AND_SQ_8.9.71_4332_YYB_D"
},
"8.9.73-Android": {
"appid": 537171689,
"qua": "V1_AND_SQ_8.9.73_4416_YYB_D"
},
"8.9.75-Android": {
"appid": 537173381,
"qua": "V1_AND_SQ_8.9.75_4482_YYB_D"
},
"8.9.76-Android": {
"appid": 537173477,
"qua": "V1_AND_SQ_8.9.76_4484_YYB_D"
},
"8.9.78-Android": {
"appid": 537175315,
"qua": "V1_AND_SQ_8.9.78_4548_YYB_D"
},
"8.9.80-Android": {
"appid": 537176863,
"qua": "V1_AND_SQ_8.9.80_4614_YYB_D"
},
"8.9.83-Android": {
"appid": 537178646,
"qua": "V1_AND_SQ_8.9.83_4680_YYB_D"
},
"8.9.85-Android": {
"appid": 537180568,
"qua": "V1_AND_SQ_8.9.85_4766_YYB_D"
},
"8.9.88-Android": {
"appid": 537182769,
"qua": "V1_AND_SQ_8.9.88_4852_YYB_D"
},
"8.9.93-Android": {
"appid": 537187398,
"qua": "V1_AND_SQ_8.9.93_5028_YYB_D"
},
"9.0.0-Android": {
"appid": 537194351,
"qua": "V1_AND_SQ_9.0.0_5282_YYB_D"
},
"9.0.8-Android": {
"appid": 537200218,
"qua": "V1_AND_SQ_9.0.8_5540_YYB_D"
},
"9.0.17-Android": {
"appid": 537204056,
"qua": "V1_AND_SQ_9.0.17_5712_YYB_D"
},
"9.0.25-Android": {
"appid": 537210084,
"qua": "V1_AND_SQ_9.0.25_5942_YYB_D"
},
"9.0.35-Android": {
"appid": 537215475,
"qua": "V1_AND_SQ_9.0.35_6150_YYB_D"
},
"9.0.50-Android": {
"appid": 537217916,
"qua": "V1_AND_SQ_9.0.50_6258_YYB_D"
},
"9.0.56-Android": {
"appid": 537220323,
"qua": "V1_AND_SQ_9.0.56_6372_YYB_D"
},
"9.0.60-Android": {
"appid": 537222797,
"qua": "V1_AND_SQ_9.0.60_6478_YYB_D"
},
"9.0.65-Android": {
"appid": 537225139,
"qua": "V1_AND_SQ_9.0.65_6588_YYB_D"
},
"9.0.70-Android": {
"appid": 537228487,
"qua": "V1_AND_SQ_9.0.70_6698_YYB_D"
},
"9.0.81-Android": {
"appid": 537233527,
"qua": "V1_AND_SQ_9.0.81_6922_YYB_D"
},
"9.0.85-Android": {
"appid": 537236316,
"qua": "V1_AND_SQ_9.0.85_7068_YYB_D"
},
"9.0.90-Android":{
"appid": 537239255,
"qua": "V1_AND_SQ_9.0.90_7218_YYB_D"
},
"9.0.95-Android": {
"appid": 537242075,
"qua": "V1_AND_SQ_9.0.95_7368_YYB_D"
},
"9.1.0-Android": {
"appid": 537244893,
"qua": "V1_AND_SQ_9.1.0_7518_YYB_D"
},
"9.1.20-Android": {
"appid": 537257414,
"qua": "V1_AND_SQ_9.1.20_8198_YYB_D"
},
"9.1.25-Android": {
"appid": 537260030,
"qua": "V1_AND_SQ_9.1.25_8368_YYB_D"
},
"9.1.67-Android": {
"appid": 537284101,
"qua": "V1_AND_SQ_9.1.67_9728_YYB_D"
},
"9.1.70-Android": {
"appid": 537285947,
"qua": "V1_AND_SQ_9.1.70_9898_YYB_D"
},
"9.1.65-Android": {
"appid": 537278302,
"qua": "V1_AND_SQ_9.1.65_9558_YYB_D"
},
"9.1.60-Android": {
"appid": 537275636,
"qua": "V1_AND_SQ_9.1.60_9388_YYB_D"
},
"9.1.55-Android": {
"appid": 537272835,
"qua": "V1_AND_SQ_9.1.55_9218_YYB_D"
},
"9.1.52-Android": {
"appid": 537270265,
"qua": "V1_AND_SQ_9.1.52_9054_YYB_D"
},
"9.1.50-Android": {
"appid": 537270031,
"qua": "V1_AND_SQ_9.1.50_9048_YYB_D"
},
"9.1.5-Android": {
"appid": 537247779,
"qua": "V1_AND_SQ_9.1.5_7688_YYB_D"
},
"9.1.35-Android": {
"appid": 537265576,
"qua": "V1_AND_SQ_9.1.35_8708_YYB_D"
},
"9.1.31-Android": {
"appid": 537262715,
"qua": "V1_AND_SQ_9.1.31_8542_YYB_D"
},
"9.1.30-Android": {
"appid": 537262559,
"qua": "V1_AND_SQ_9.1.30_8538_YYB_D"
},
"9.1.16-Android": {
"appid": 537254305,
"qua": "V1_AND_SQ_9.1.16_8032_YYB_D"
},
"9.1.15-Android": {
"appid": 537254149,
"qua": "V1_AND_SQ_9.1.15_8028_YYB_D"
},
"9.1.10-Android": {
"appid": 537251380,
"qua": "V1_AND_SQ_9.1.10_7858_YYB_D"
} }
} }

View File

@@ -290,5 +290,69 @@
"3.2.16-32869-arm64": { "3.2.16-32869-arm64": {
"send": "7226630", "send": "7226630",
"recv": "7229F60" "recv": "7229F60"
},
"9.9.18-33139-x64": {
"send": "39F5870",
"recv": "39FA070"
},
"3.2.16-33139-x64": {
"send": "A634F60",
"recv": "A638980"
},
"3.2.16-33139-arm64": {
"send": "7262BB0",
"recv": "72664E0"
},
"9.9.18-33800-x64": {
"send": "39F5870",
"recv": "39FA070"
},
"3.2.16-33800-x64": {
"send": "A634F60",
"recv": "A638980"
},
"3.2.16-33800-arm64": {
"send": "7262BB0",
"recv": "72664E0"
},
"9.9.19-34231-x64": {
"send": "3BD73D0",
"recv": "3BDBBD0"
},
"3.2.17-34231-x64": {
"send": "AD787E0",
"recv": "AD7C200"
},
"3.2.17-34231-arm64": {
"send": "770CDC0",
"recv": "77106F0"
},
"9.9.19-34362-x64": {
"send": "3BD80D0",
"recv": "3BDC8D0"
},
"9.9.19-34467-x64": {
"send": "3BD8690",
"recv": "3BDCE90"
},
"9.9.19-34566-x64": {
"send": "3BDA110",
"recv": "3BDE910"
},
"9.9.19-34606-x64": {
"send": "3BDA110",
"recv": "3BDE910"
},
"3.2.17-34606-x64": {
"send": "AD7DC60",
"recv": "AD81680"
},
"3.2.17-34606-arm64": {
"send": "7711270",
"recv": "7714BA0"
},
"9.9.19-34740-x64": {
"send": "3BDD8D0",
"recv": "3BE20D0"
} }
} }

View File

@@ -6,6 +6,17 @@ interface ServerRkeyData {
private_rkey: string; private_rkey: string;
expired_time: number; expired_time: number;
} }
interface OneBotApiRet {
status: string,
retcode: number,
data: ServerRkeyData,
message: string,
wording: string,
}
interface UrlFailureInfo {
count: number;
lastTimestamp: number;
}
export class RkeyManager { export class RkeyManager {
serverUrl: string[] = []; serverUrl: string[] = [];
@@ -15,9 +26,8 @@ export class RkeyManager {
private_rkey: '', private_rkey: '',
expired_time: 0, expired_time: 0,
}; };
private failureCount: number = 0; private urlFailures: Map<string, UrlFailureInfo> = new Map();
private lastFailureTimestamp: number = 0; private readonly FAILURE_LIMIT: number = 4;
private readonly FAILURE_LIMIT: number = 8;
private readonly ONE_DAY: number = 24 * 60 * 60 * 1000; private readonly ONE_DAY: number = 24 * 60 * 60 * 1000;
constructor(serverUrl: string[], logger: LogWrapper) { constructor(serverUrl: string[], logger: LogWrapper) {
@@ -26,50 +36,92 @@ export class RkeyManager {
} }
async getRkey() { async getRkey() {
const now = new Date().getTime(); const availableUrls = this.getAvailableUrls();
if (now - this.lastFailureTimestamp > this.ONE_DAY) { if (availableUrls.length === 0) {
this.failureCount = 0; // 重置失败计数器 this.logger.logError('[Rkey] 所有服务均已禁用, 图片使用FallBack机制');
} throw new Error('获取rkey失败所有服务URL均已被禁用');
if (this.failureCount >= this.FAILURE_LIMIT) {
this.logger.logError('[Rkey] 服务存在异常, 图片使用FallBack机制');
throw new Error('获取rkey失败次数过多请稍后再试');
} }
if (this.isExpired()) { if (this.isExpired()) {
try { try {
await this.refreshRkey(); await this.refreshRkey();
} catch (e) { } catch (e) {
throw new Error(`${e}`);//外抛 throw new Error(`${e}`);
} }
} }
return this.rkeyData; return this.rkeyData;
} }
private getAvailableUrls(): string[] {
return this.serverUrl.filter(url => !this.isUrlDisabled(url));
}
private isUrlDisabled(url: string): boolean {
const failureInfo = this.urlFailures.get(url);
if (!failureInfo) return false;
const now = new Date().getTime();
// 如果已经过了一天,重置失败计数
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
failureInfo.count = 0;
this.urlFailures.set(url, failureInfo);
return false;
}
return failureInfo.count >= this.FAILURE_LIMIT;
}
private updateUrlFailure(url: string) {
const now = new Date().getTime();
const failureInfo = this.urlFailures.get(url) || { count: 0, lastTimestamp: 0 };
// 如果已经过了一天,重置失败计数
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
failureInfo.count = 1;
} else {
failureInfo.count++;
}
failureInfo.lastTimestamp = now;
this.urlFailures.set(url, failureInfo);
if (failureInfo.count >= this.FAILURE_LIMIT) {
this.logger.logError(`[Rkey] URL ${url} 已被禁用,失败次数达到 ${this.FAILURE_LIMIT}`);
}
}
isExpired(): boolean { isExpired(): boolean {
const now = new Date().getTime() / 1000; const now = new Date().getTime() / 1000;
return now > this.rkeyData.expired_time; return now > this.rkeyData.expired_time;
} }
async refreshRkey() { async refreshRkey() {
//刷新rkey const availableUrls = this.getAvailableUrls();
for (const url of this.serverUrl) {
if (availableUrls.length === 0) {
this.logger.logError('[Rkey] 所有服务均已禁用');
throw new Error('获取rkey失败所有服务URL均已被禁用');
}
for (const url of availableUrls) {
try { try {
const temp = await RequestUtil.HttpGetJson<ServerRkeyData>(url, 'GET'); let temp = await RequestUtil.HttpGetJson<ServerRkeyData>(url, 'GET');
if ('retcode' in temp) {
// 支持Onebot Ret风格
temp = (temp as unknown as OneBotApiRet).data;
}
this.rkeyData = { this.rkeyData = {
group_rkey: temp.group_rkey.slice(6), group_rkey: temp.group_rkey.slice(6),
private_rkey: temp.private_rkey.slice(6), private_rkey: temp.private_rkey.slice(6),
expired_time: temp.expired_time expired_time: temp.expired_time
}; };
this.failureCount = 0;
return; return;
} catch (e) { } catch (e) {
this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e); this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e);
this.failureCount++; this.updateUrlFailure(url);
this.lastFailureTimestamp = new Date().getTime();
//是否为最后一个url if (url === availableUrls[availableUrls.length - 1]) {
if (url === this.serverUrl[this.serverUrl.length - 1]) { throw new Error(`获取rkey失败: ${e}`);
throw new Error(`获取rkey失败: ${e}`);//外抛
} }
} }
} }

View File

@@ -40,12 +40,30 @@ export class NodeIKernelBuddyListener {
} }
onDelBatchBuddyInfos(arg: unknown): any { onDelBatchBuddyInfos(arg: unknown): any {
console.log('onDelBatchBuddyInfos not implemented', ...arguments);
} }
onDoubtBuddyReqChange(arg: unknown): any { onDoubtBuddyReqChange(_arg:
{
reqId: string;
cookie: string;
doubtList: Array<{
uid: string;
nick: string;
age: number,
sex: number;
commFriendNum: number;
reqTime: string;
msg: string;
source: string;
reason: string;
groupCode: string;
nameMore?: null;
}>;
}): void | Promise<void> {
} }
onDoubtBuddyReqUnreadNumChange(arg: unknown): any { onDoubtBuddyReqUnreadNumChange(_num: number): void | Promise<void> {
} }
onNickUpdated(arg: unknown): any { onNickUpdated(arg: unknown): any {

View File

@@ -21,7 +21,8 @@ export interface OnRichMediaDownloadCompleteParams {
clientMsg: string, clientMsg: string,
businessId: number, businessId: number,
userTotalSpacePerDay: unknown, userTotalSpacePerDay: unknown,
userUsedSpacePerDay: unknown userUsedSpacePerDay: unknown,
chatType: number,
} }
export interface GroupFileInfoUpdateParamType { export interface GroupFileInfoUpdateParamType {
@@ -97,112 +98,112 @@ export interface TempOnRecvParams {
} }
export class NodeIKernelMsgListener { export class NodeIKernelMsgListener {
onAddSendMsg(msgRecord: RawMessage): any { onAddSendMsg(_msgRecord: RawMessage): any {
} }
onBroadcastHelperDownloadComplete(broadcastHelperTransNotifyInfo: unknown): any { onBroadcastHelperDownloadComplete(_broadcastHelperTransNotifyInfo: unknown): any {
} }
onBroadcastHelperProgressUpdate(broadcastHelperTransNotifyInfo: unknown): any { onBroadcastHelperProgressUpdate(_broadcastHelperTransNotifyInfo: unknown): any {
} }
onChannelFreqLimitInfoUpdate(contact: unknown, z: unknown, freqLimitInfo: unknown): any { onChannelFreqLimitInfoUpdate(_contact: unknown, _z: unknown, _freqLimitInfo: unknown): any {
} }
onContactUnreadCntUpdate(hashMap: unknown): any { onContactUnreadCntUpdate(_hashMap: unknown): any {
} }
onCustomWithdrawConfigUpdate(customWithdrawConfig: unknown): any { onCustomWithdrawConfigUpdate(_customWithdrawConfig: unknown): any {
} }
onDraftUpdate(contact: unknown, arrayList: unknown, j2: unknown): any { onDraftUpdate(_contact: unknown, _arrayList: unknown, _j2: unknown): any {
} }
onEmojiDownloadComplete(emojiNotifyInfo: unknown): any { onEmojiDownloadComplete(_emojiNotifyInfo: unknown): any {
} }
onEmojiResourceUpdate(emojiResourceInfo: unknown): any { onEmojiResourceUpdate(_emojiResourceInfo: unknown): any {
} }
onFeedEventUpdate(firstViewDirectMsgNotifyInfo: unknown): any { onFeedEventUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
} }
onFileMsgCome(arrayList: unknown): any { onFileMsgCome(_arrayList: unknown): any {
} }
onFirstViewDirectMsgUpdate(firstViewDirectMsgNotifyInfo: unknown): any { onFirstViewDirectMsgUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
} }
onFirstViewGroupGuildMapping(arrayList: unknown): any { onFirstViewGroupGuildMapping(_arrayList: unknown): any {
} }
onGrabPasswordRedBag(i2: unknown, str: unknown, i3: unknown, recvdOrder: unknown, msgRecord: unknown): any { onGrabPasswordRedBag(_i2: unknown, _str: unknown, _i3: unknown, _recvdOrder: unknown, _msgRecord: unknown): any {
} }
onGroupFileInfoAdd(groupItem: unknown): any { onGroupFileInfoAdd(_groupItem: unknown): any {
} }
onGroupFileInfoUpdate(groupFileListResult: GroupFileInfoUpdateParamType): any { onGroupFileInfoUpdate(_groupFileListResult: GroupFileInfoUpdateParamType): any {
} }
onGroupGuildUpdate(groupGuildNotifyInfo: unknown): any { onGroupGuildUpdate(_groupGuildNotifyInfo: unknown): any {
} }
onGroupTransferInfoAdd(groupItem: unknown): any { onGroupTransferInfoAdd(_groupItem: unknown): any {
} }
onGroupTransferInfoUpdate(groupFileListResult: unknown): any { onGroupTransferInfoUpdate(_groupFileListResult: unknown): any {
} }
onGuildInteractiveUpdate(guildInteractiveNotificationItem: unknown): any { onGuildInteractiveUpdate(_guildInteractiveNotificationItem: unknown): any {
} }
onGuildMsgAbFlagChanged(guildMsgAbFlag: unknown): any { onGuildMsgAbFlagChanged(_guildMsgAbFlag: unknown): any {
} }
onGuildNotificationAbstractUpdate(guildNotificationAbstractInfo: unknown): any { onGuildNotificationAbstractUpdate(_guildNotificationAbstractInfo: unknown): any {
} }
onHitCsRelatedEmojiResult(downloadRelateEmojiResultInfo: unknown): any { onHitCsRelatedEmojiResult(_downloadRelateEmojiResultInfo: unknown): any {
} }
onHitEmojiKeywordResult(hitRelatedEmojiWordsResult: unknown): any { onHitEmojiKeywordResult(_hitRelatedEmojiWordsResult: unknown): any {
} }
onHitRelatedEmojiResult(relatedWordEmojiInfo: unknown): any { onHitRelatedEmojiResult(_relatedWordEmojiInfo: unknown): any {
} }
onImportOldDbProgressUpdate(importOldDbMsgNotifyInfo: unknown): any { onImportOldDbProgressUpdate(_importOldDbMsgNotifyInfo: unknown): any {
} }
onInputStatusPush(inputStatusInfo: { onInputStatusPush(_inputStatusInfo: {
chatType: number; chatType: number;
eventType: number; eventType: number;
fromUin: string; fromUin: string;
@@ -215,55 +216,55 @@ export class NodeIKernelMsgListener {
} }
onKickedOffLine(kickedInfo: KickedOffLineInfo): any { onKickedOffLine(_kickedInfo: KickedOffLineInfo): any {
} }
onLineDev(arrayList: unknown): any { onLineDev(_arrayList: unknown): any {
} }
onLogLevelChanged(j2: unknown): any { onLogLevelChanged(_j2: unknown): any {
} }
onMsgAbstractUpdate(arrayList: unknown): any { onMsgAbstractUpdate(_arrayList: unknown): any {
} }
onMsgBoxChanged(arrayList: unknown): any { onMsgBoxChanged(_arrayList: unknown): any {
} }
onMsgDelete(contact: unknown, arrayList: unknown): any { onMsgDelete(_contact: unknown, _arrayList: unknown): any {
} }
onMsgEventListUpdate(hashMap: unknown): any { onMsgEventListUpdate(_hashMap: unknown): any {
} }
onMsgInfoListAdd(arrayList: unknown): any { onMsgInfoListAdd(_arrayList: unknown): any {
} }
onMsgInfoListUpdate(msgList: RawMessage[]): any { onMsgInfoListUpdate(_msgList: RawMessage[]): any {
} }
onMsgQRCodeStatusChanged(i2: unknown): any { onMsgQRCodeStatusChanged(_i2: unknown): any {
} }
onMsgRecall(chatType: ChatType, uid: string, msgSeq: string): any { onMsgRecall(_chatType: ChatType, _uid: string, _msgSeq: string): any {
} }
onMsgSecurityNotify(msgRecord: unknown): any { onMsgSecurityNotify(_msgRecord: unknown): any {
} }
onMsgSettingUpdate(msgSetting: unknown): any { onMsgSettingUpdate(_msgSetting: unknown): any {
} }
@@ -279,108 +280,108 @@ export class NodeIKernelMsgListener {
} }
onReadFeedEventUpdate(firstViewDirectMsgNotifyInfo: unknown): any { onReadFeedEventUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
} }
onRecvGroupGuildFlag(i2: unknown): any { onRecvGroupGuildFlag(_i2: unknown): any {
} }
onRecvMsg(arrayList: RawMessage[]): any { onRecvMsg(_arrayList: RawMessage[]): any {
} }
onRecvMsgSvrRspTransInfo(j2: unknown, contact: unknown, i2: unknown, i3: unknown, str: unknown, bArr: unknown): any { onRecvMsgSvrRspTransInfo(_j2: unknown, _contact: unknown, _i2: unknown, _i3: unknown, _str: unknown, _bArr: unknown): any {
} }
onRecvOnlineFileMsg(arrayList: unknown): any { onRecvOnlineFileMsg(_arrayList: unknown): any {
} }
onRecvS2CMsg(arrayList: unknown): any { onRecvS2CMsg(_arrayList: unknown): any {
} }
onRecvSysMsg(arrayList: Array<number>): any { onRecvSysMsg(_arrayList: Array<number>): any {
} }
onRecvUDCFlag(i2: unknown): any { onRecvUDCFlag(_i2: unknown): any {
} }
onRichMediaDownloadComplete(fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any { onRichMediaDownloadComplete(_fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any {
} }
onRichMediaProgerssUpdate(fileTransNotifyInfo: unknown): any { onRichMediaProgerssUpdate(_fileTransNotifyInfo: unknown): any {
} }
onRichMediaUploadComplete(fileTransNotifyInfo: unknown): any { onRichMediaUploadComplete(_fileTransNotifyInfo: unknown): any {
} }
onSearchGroupFileInfoUpdate(searchGroupFileResult: unknown): any { onSearchGroupFileInfoUpdate(_searchGroupFileResult: unknown): any {
} }
onSendMsgError(j2: unknown, contact: unknown, i2: unknown, str: unknown): any { onSendMsgError(_j2: unknown, _contact: unknown, _i2: unknown, _str: unknown): any {
} }
onSysMsgNotification(i2: unknown, j2: unknown, j3: unknown, arrayList: unknown): any { onSysMsgNotification(_i2: unknown, _j2: unknown, _j3: unknown, _arrayList: unknown): any {
} }
onTempChatInfoUpdate(tempChatInfo: TempOnRecvParams): any { onTempChatInfoUpdate(_tempChatInfo: TempOnRecvParams): any {
} }
onUnreadCntAfterFirstView(hashMap: unknown): any { onUnreadCntAfterFirstView(_hashMap: unknown): any {
} }
onUnreadCntUpdate(hashMap: unknown): any { onUnreadCntUpdate(_hashMap: unknown): any {
} }
onUserChannelTabStatusChanged(z: unknown): any { onUserChannelTabStatusChanged(_z: unknown): any {
} }
onUserOnlineStatusChanged(z: unknown): any { onUserOnlineStatusChanged(_z: unknown): any {
} }
onUserTabStatusChanged(arrayList: unknown): any { onUserTabStatusChanged(_arrayList: unknown): any {
} }
onlineStatusBigIconDownloadPush(i2: unknown, j2: unknown, str: unknown): any { onlineStatusBigIconDownloadPush(_i2: unknown, _j2: unknown, _str: unknown): any {
} }
onlineStatusSmallIconDownloadPush(i2: unknown, j2: unknown, str: unknown): any { onlineStatusSmallIconDownloadPush(_i2: unknown, _j2: unknown, _str: unknown): any {
} }
// 第一次发现于Linux // 第一次发现于Linux
onUserSecQualityChanged(...args: unknown[]): any { onUserSecQualityChanged(..._args: unknown[]): any {
} }
onMsgWithRichLinkInfoUpdate(...args: unknown[]): any { onMsgWithRichLinkInfoUpdate(..._args: unknown[]): any {
} }
onRedTouchChanged(...args: unknown[]): any { onRedTouchChanged(..._args: unknown[]): any {
} }
// 第一次发现于Win 9.9.9-23159 // 第一次发现于Win 9.9.9-23159
onBroadcastHelperProgerssUpdate(...args: unknown[]): any { onBroadcastHelperProgerssUpdate(..._args: unknown[]): any {
} }
} }

View File

@@ -96,7 +96,7 @@ export interface NodeIKernelSearchListener {
}): any; }): any;
onSearchMsgKeywordsResult(params: { onSearchMsgKeywordsResult(params: {
searchId: string, searchId: number,
hasMore: boolean, hasMore: boolean,
resultItems: Array<{ resultItems: Array<{
msgId: string, msgId: string,

View File

@@ -8,7 +8,8 @@ import { LRUCache } from '@/common/lru-cache';
import { LogStack } from '@/core/packet/context/clientContext'; import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext'; import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext'; import { PacketLogger } from '@/core/packet/context/loggerContext';
import { ProtoBufDecode } from 'napcat.protobuf';
export const MsgData = new LRUCache<string, string>(5000);
// 0 send 1 recv // 0 send 1 recv
export interface NativePacketExportType { export interface NativePacketExportType {
InitHook?: (send: string, recv: string, callback: (type: number, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean; InitHook?: (send: string, recv: string, callback: (type: number, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean;
@@ -38,12 +39,12 @@ export class NativePacketClient extends IPacketClient {
return true; return true;
} }
async init(pid: number, recv: string, send: string): Promise<void> { async init(_pid: number, recv: string, send: string): Promise<void> {
const platform = process.platform + '.' + process.arch; const platform = process.platform + '.' + process.arch;
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node'); const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node');
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY); process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, uin: string, cmd: string, seq: number, hex_data: string) => { this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, _uin: string, cmd: string, seq: number, hex_data: string) => {
const trace_id = createHash('md5').update(Buffer.from(hex_data, 'hex')).digest('hex'); const trace_id = createHash('md5').update(Buffer.from(hex_data, 'hex')).digest('hex');
if (type === 0 && this.cb.get(trace_id + 'recv')) { if (type === 0 && this.cb.get(trace_id + 'recv')) {
//此时为send 提取seq //此时为send 提取seq
@@ -56,6 +57,19 @@ export class NativePacketClient extends IPacketClient {
// console.log('callback:', callback, trace_id); // console.log('callback:', callback, trace_id);
callback?.({ seq, cmd, hex_data }); callback?.({ seq, cmd, hex_data });
} }
if (cmd === 'trpc.msg.olpush.OlPushService.MsgPush') {
try {
let msg_info = ProtoBufDecode(Buffer.from(hex_data, 'hex')) as any;
let group_id = (msg_info['1']['1']['8']['1'] as number).toString()
let msg_seq = (msg_info['1']['2']['5'] as number).toString()
let msg_id = group_id + '_' + msg_seq;
MsgData.put(msg_id, hex_data);
console.log('add msgid:', msg_id);
} catch (error) {
console.log('error:', error);
}
}
}, this.napcore.config.o3HookMode == 1); }, this.napcore.config.o3HookMode == 1);
this.available = true; this.available = true;
} }

View File

@@ -1,113 +0,0 @@
import { Data, WebSocket, ErrorEvent } from 'ws';
import { IPacketClient, RecvPacket } from '@/core/packet/client/baseClient';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
export class WsPacketClient extends IPacketClient {
private websocket: WebSocket | null = null;
private reconnectAttempts: number = 0;
private readonly maxReconnectAttempts: number = 60; // 现在暂时不可配置
private readonly clientUrl: string;
private readonly clientUrlWrap: (url: string) => string = (url: string) => `ws://${url}/ws`;
private isInitialized: boolean = false;
private initPayload: { pid: number, recv: string, send: string } | null = null;
constructor(napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) {
super(napCore, logger, logStack);
this.clientUrl = this.napcore.config.packetServer
? this.clientUrlWrap(this.napcore.config.packetServer)
: this.clientUrlWrap('127.0.0.1:8083');
}
check(): boolean {
if (!this.napcore.config.packetServer) {
this.logStack.pushLogWarn('wsPacketClient 未配置服务器地址');
return false;
}
return true;
}
async init(pid: number, recv: string, send: string): Promise<void> {
this.initPayload = { pid, recv, send };
await this.connectWithRetry();
}
sendCommandImpl(cmd: string, data: string, trace_id: string): void {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify({
action: 'send',
cmd,
data,
trace_id
}));
} else {
this.logStack.pushLogWarn(`WebSocket 未连接,无法发送命令: ${cmd}`);
}
}
private async connectWithRetry(): Promise<void> {
while (this.reconnectAttempts < this.maxReconnectAttempts) {
try {
await this.connect();
return;
} catch {
this.reconnectAttempts++;
this.logStack.pushLogWarn(`${this.reconnectAttempts}/${this.maxReconnectAttempts} 次尝试重连失败!`);
await this.delay(5000);
}
}
this.logStack.pushLogError(`wsPacketClient 在 ${this.clientUrl} 达到最大重连次数 (${this.maxReconnectAttempts})`);
throw new Error(`无法连接到 WebSocket 服务器:${this.clientUrl}`);
}
private connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.websocket = new WebSocket(this.clientUrl);
this.websocket.onopen = () => {
this.available = true;
this.reconnectAttempts = 0;
this.logger.info(`wsPacketClient 已连接到 ${this.clientUrl}`);
if (!this.isInitialized && this.initPayload) {
this.websocket!.send(JSON.stringify({
action: 'init',
...this.initPayload
}));
this.isInitialized = true;
}
resolve();
};
this.websocket.onclose = () => {
this.available = false;
this.logger.warn('WebSocket 连接关闭,尝试重连...');
reject(new Error('WebSocket 连接关闭'));
};
this.websocket.onmessage = (event) => this.handleMessage(event.data).catch(err => {
this.logger.error(`处理消息时出错: ${err}`);
});
this.websocket.onerror = (event: ErrorEvent) => {
this.available = false;
this.logger.error(`WebSocket 出错: ${event.message}`);
this.websocket?.close();
reject(new Error(`WebSocket 出错: ${event.message}`));
};
});
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private async handleMessage(message: Data): Promise<void> {
try {
const json: RecvPacket = JSON.parse(message.toString());
const trace_id_md5 = json.trace_id_md5;
const action = json?.type ?? 'init';
const event = this.cb.get(`${trace_id_md5}${action}`);
if (event) await event(json.data);
} catch (error) {
this.logger.error(`解析ws消息时出错: ${(error as Error).message}`);
}
}
}

View File

@@ -1,6 +1,5 @@
import { IPacketClient } from '@/core/packet/client/baseClient'; import { IPacketClient } from '@/core/packet/client/baseClient';
import { NativePacketClient } from '@/core/packet/client/nativeClient'; import { NativePacketClient } from '@/core/packet/client/nativeClient';
import { WsPacketClient } from '@/core/packet/client/wsClient';
import { OidbPacket } from '@/core/packet/transformer/base'; import { OidbPacket } from '@/core/packet/transformer/base';
import { PacketLogger } from '@/core/packet/context/loggerContext'; import { PacketLogger } from '@/core/packet/context/loggerContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext'; import { NapCoreContext } from '@/core/packet/context/napCoreContext';
@@ -10,8 +9,7 @@ type clientPriorityType = {
} }
const clientPriority: clientPriorityType = { const clientPriority: clientPriorityType = {
10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack), 10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack)
1: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new WsPacketClient(napCore, logger, logStack),
}; };
export class LogStack { export class LogStack {
@@ -88,10 +86,6 @@ export class PacketClientContext {
this.logger.info('使用指定的 NativePacketClient 作为后端'); this.logger.info('使用指定的 NativePacketClient 作为后端');
client = new NativePacketClient(this.napCore, this.logger, this.logStack); client = new NativePacketClient(this.napCore, this.logger, this.logStack);
break; break;
case 'frida':
this.logger.info('[Core] [Packet] 使用指定的 FridaPacketClient 作为后端');
client = new WsPacketClient(this.napCore, this.logger, this.logStack);
break;
case 'auto': case 'auto':
case undefined: case undefined:
client = this.judgeClient(); client = this.judgeClient();

View File

@@ -6,13 +6,14 @@ import {
PacketMsgFileElement, PacketMsgFileElement,
PacketMsgPicElement, PacketMsgPicElement,
PacketMsgPttElement, PacketMsgPttElement,
PacketMsgVideoElement PacketMsgReplyElement,
PacketMsgVideoElement,
} from '@/core/packet/message/element'; } from '@/core/packet/message/element';
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core'; import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp'; import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
import { AIVoiceChatType } from '@/core/packet/entities/aiChat'; import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core'; import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { IndexNode, LongMsgResult, MsgInfo } from '@/core/packet/transformer/proto'; import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/core/packet/transformer/proto';
import { OidbPacket } from '@/core/packet/transformer/base'; import { OidbPacket } from '@/core/packet/transformer/base';
import { ImageOcrResult } from '@/core/packet/entities/ocrResult'; import { ImageOcrResult } from '@/core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib'; import { gunzipSync } from 'zlib';
@@ -68,30 +69,32 @@ export class PacketOperationContext {
} }
} }
async SetGroupSpecialTitle(groupUin: number, uid: string, tittle: string) { async SetGroupSpecialTitle(groupUin: number, uid: string, title: string) {
const req = trans.SetSpecialTitle.build(groupUin, uid, tittle); const req = trans.SetSpecialTitle.build(groupUin, uid, title);
await this.context.client.sendOidbPacket(req); await this.context.client.sendOidbPacket(req);
} }
async UploadResources(msg: PacketMsg[], groupUin: number = 0) { async UploadResources(msg: PacketMsg[], groupUin: number = 0) {
const chatType = groupUin ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C; const chatType = groupUin ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C;
const peerUid = groupUin ? String(groupUin) : this.context.napcore.basicInfo.uid; const peerUid = groupUin ? String(groupUin) : this.context.napcore.basicInfo.uid;
const reqList = msg.flatMap(m => const reqList = msg.flatMap((m) =>
m.msg.map(e => { m.msg
if (e instanceof PacketMsgPicElement) { .map((e) => {
return this.context.highway.uploadImage({ chatType, peerUid }, e); if (e instanceof PacketMsgPicElement) {
} else if (e instanceof PacketMsgVideoElement) { return this.context.highway.uploadImage({ chatType, peerUid }, e);
return this.context.highway.uploadVideo({ chatType, peerUid }, e); } else if (e instanceof PacketMsgVideoElement) {
} else if (e instanceof PacketMsgPttElement) { return this.context.highway.uploadVideo({ chatType, peerUid }, e);
return this.context.highway.uploadPtt({ chatType, peerUid }, e); } else if (e instanceof PacketMsgPttElement) {
} else if (e instanceof PacketMsgFileElement) { return this.context.highway.uploadPtt({ chatType, peerUid }, e);
return this.context.highway.uploadFile({ chatType, peerUid }, e); } else if (e instanceof PacketMsgFileElement) {
} return this.context.highway.uploadFile({ chatType, peerUid }, e);
return null; }
}).filter(Boolean) return null;
})
.filter(Boolean)
); );
const res = await Promise.allSettled(reqList); const res = await Promise.allSettled(reqList);
this.context.logger.info(`上传资源${res.length}个,失败${res.filter(r => r.status === 'rejected').length}`); this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}`);
res.forEach((result, index) => { res.forEach((result, index) => {
if (result.status === 'rejected') { if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`); this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
@@ -100,10 +103,13 @@ export class PacketOperationContext {
} }
async UploadImage(img: PacketMsgPicElement) { async UploadImage(img: PacketMsgPicElement) {
await this.context.highway.uploadImage({ await this.context.highway.uploadImage(
chatType: ChatType.KCHATTYPEC2C, {
peerUid: this.context.napcore.basicInfo.uid chatType: ChatType.KCHATTYPEC2C,
}, img); peerUid: this.context.napcore.basicInfo.uid,
},
img
);
const index = img.msgInfo?.msgInfoBody?.at(0)?.index; const index = img.msgInfo?.msgInfoBody?.at(0)?.index;
if (!index) { if (!index) {
throw new Error('img.msgInfo?.msgInfoBody![0].index! is undefined'); throw new Error('img.msgInfo?.msgInfoBody![0].index! is undefined');
@@ -137,23 +143,79 @@ export class PacketOperationContext {
coordinates: item.polygon.coordinates.map((c) => { coordinates: item.polygon.coordinates.map((c) => {
return { return {
x: c.x, x: c.x,
y: c.y y: c.y,
}; };
}), }),
}; };
}), }),
language: res.ocrRspBody.language language: res.ocrRspBody.language,
} as ImageOcrResult; } as ImageOcrResult;
} }
async UploadForwardMsg(msg: PacketMsg[], groupUin: number = 0) { private async SendPreprocess(msg: PacketMsg[], groupUin: number = 0) {
const ps = msg.map((m) => {
return m.msg.map(async(e) => {
if (e instanceof PacketMsgReplyElement && !e.targetElems) {
this.context.logger.debug(`Cannot find reply element's targetElems, prepare to fetch it...`);
if (!e.targetPeer?.peerUid) {
this.context.logger.error(`targetPeer is undefined!`);
}
let targetMsg: NapProtoEncodeStructType<typeof PushMsgBody>[] | undefined;
if (e.isGroupReply) {
targetMsg = await this.FetchGroupMessage(+(e.targetPeer?.peerUid ?? 0), e.targetMessageSeq, e.targetMessageSeq);
} else {
targetMsg = await this.FetchC2CMessage(await this.context.napcore.basicInfo.uin2uid(e.targetUin), e.targetMessageSeq, e.targetMessageSeq);
}
e.targetElems = targetMsg.at(0)?.body?.richText?.elems;
e.targetSourceMsg = targetMsg.at(0);
}
});
}).flat();
await Promise.all(ps)
await this.UploadResources(msg, groupUin); await this.UploadResources(msg, groupUin);
}
async FetchGroupMessage(groupUin: number, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
const req = trans.FetchGroupMessage.build(groupUin, startSeq, endSeq);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.FetchGroupMessage.parse(resp);
return res.body.messages
}
async FetchC2CMessage(targetUid: string, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
const req = trans.FetchC2CMessage.build(targetUid, startSeq, endSeq);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.FetchC2CMessage.parse(resp);
return res.messages
}
async UploadForwardMsg(msg: PacketMsg[], groupUin: number = 0) {
await this.SendPreprocess(msg, groupUin);
const req = trans.UploadForwardMsg.build(this.context.napcore.basicInfo.uid, msg, groupUin); const req = trans.UploadForwardMsg.build(this.context.napcore.basicInfo.uid, msg, groupUin);
const resp = await this.context.client.sendOidbPacket(req, true); const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.UploadForwardMsg.parse(resp); const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId; return res.result.resId;
} }
async MoveGroupFile(
groupUin: number,
fileUUID: string,
currentParentDirectory: string,
targetParentDirectory: string
) {
const req = trans.MoveGroupFile.build(groupUin, fileUUID, currentParentDirectory, targetParentDirectory);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.MoveGroupFile.parse(resp);
return res.move.retCode;
}
async RenameGroupFile(groupUin: number, fileUUID: string, currentParentDirectory: string, newName: string) {
const req = trans.RenameGroupFile.build(groupUin, fileUUID, currentParentDirectory, newName);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.RenameGroupFile.parse(resp);
return res.rename.retCode;
}
async GetGroupFileUrl(groupUin: number, fileUUID: string) { async GetGroupFileUrl(groupUin: number, fileUUID: string) {
const req = trans.DownloadGroupFile.build(groupUin, fileUUID); const req = trans.DownloadGroupFile.build(groupUin, fileUUID);
const resp = await this.context.client.sendOidbPacket(req, true); const resp = await this.context.client.sendOidbPacket(req, true);
@@ -189,12 +251,17 @@ export class PacketOperationContext {
return res.content.map((item) => { return res.content.map((item) => {
return { return {
category: item.category, category: item.category,
voices: item.voices voices: item.voices,
}; };
}); });
} }
async GetAiVoice(groupUin: number, voiceId: string, text: string, chatType: AIVoiceChatType): Promise<NapProtoDecodeStructType<typeof MsgInfo>> { async GetAiVoice(
groupUin: number,
voiceId: string,
text: string,
chatType: AIVoiceChatType
): Promise<NapProtoDecodeStructType<typeof MsgInfo>> {
let reqTime = 0; let reqTime = 0;
const reqMaxTime = 30; const reqMaxTime = 30;
const sessionId = crypto.randomBytes(4).readUInt32BE(0); const sessionId = crypto.randomBytes(4).readUInt32BE(0);
@@ -222,6 +289,7 @@ export class PacketOperationContext {
if (!main?.actionData.msgBody) { if (!main?.actionData.msgBody) {
throw new Error('msgBody is empty'); throw new Error('msgBody is empty');
} }
this.context.logger.debug('rawChains ', inflate.toString('hex'));
const messagesPromises = main.actionData.msgBody.map(async (msg) => { const messagesPromises = main.actionData.msgBody.map(async (msg) => {
if (!msg?.body?.richText?.elems) { if (!msg?.body?.richText?.elems) {
@@ -237,12 +305,12 @@ export class PacketOperationContext {
const groupUin = msg?.responseHead.grp?.groupUin ?? 0; const groupUin = msg?.responseHead.grp?.groupUin ?? 0;
element.picElement = { element.picElement = {
...element.picElement, ...element.picElement,
originImageUrl: await this.GetGroupImageUrl(groupUin, index!) originImageUrl: await this.GetGroupImageUrl(groupUin, index!),
}; };
} else { } else {
element.picElement = { element.picElement = {
...element.picElement, ...element.picElement,
originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!) originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!),
}; };
} }
return element; return element;
@@ -255,7 +323,7 @@ export class PacketOperationContext {
elements: elements, elements: elements,
guildId: '', guildId: '',
isOnlineMsg: false, isOnlineMsg: false,
msgId: '7467703692092974645', // TODO: no necessary msgId: '7467703692092974645', // TODO: no necessary
msgRandom: '0', msgRandom: '0',
msgSeq: String(msg.contentHead.sequence ?? 0), msgSeq: String(msg.contentHead.sequence ?? 0),
msgTime: String(msg.contentHead.timeStamp ?? 0), msgTime: String(msg.contentHead.timeStamp ?? 0),

View File

@@ -24,12 +24,15 @@ export class PacketMsgBuilder {
} }
return { return {
responseHead: { responseHead: {
fromUid: '',
fromUin: node.senderUin, fromUin: node.senderUin,
toUid: node.groupId ? undefined : selfUid, type: 0,
sigMap: 0,
toUin: 0,
fromUid: '',
forward: node.groupId ? undefined : { forward: node.groupId ? undefined : {
friendName: node.senderName, friendName: node.senderName,
}, },
toUid: node.groupId ? undefined : selfUid,
grp: node.groupId ? { grp: node.groupId ? {
groupUin: node.groupId, groupUin: node.groupId,
memberName: node.senderName, memberName: node.senderName,
@@ -40,16 +43,13 @@ export class PacketMsgBuilder {
type: node.groupId ? 82 : 9, type: node.groupId ? 82 : 9,
subType: node.groupId ? undefined : 4, subType: node.groupId ? undefined : 4,
divSeq: node.groupId ? undefined : 4, divSeq: node.groupId ? undefined : 4,
msgId: crypto.randomBytes(4).readUInt32LE(0), autoReply: 0,
sequence: crypto.randomBytes(4).readUInt32LE(0), sequence: crypto.randomBytes(4).readUInt32LE(0),
timeStamp: +node.time.toString().substring(0, 10), timeStamp: +node.time.toString().substring(0, 10),
field7: BigInt(1),
field8: 0,
field9: 0,
forward: { forward: {
field1: 0, field1: 0,
field2: 0, field2: 0,
field3: node.groupId ? 0 : 2, field3: node.groupId ? 1 : 2,
unknownBase64: avatar, unknownBase64: avatar,
avatar: avatar avatar: avatar
} }

View File

@@ -10,6 +10,7 @@ import {
MsgInfo, MsgInfo,
NotOnlineImage, NotOnlineImage,
OidbSvcTrpcTcp0XE37_800Response, OidbSvcTrpcTcp0XE37_800Response,
PushMsgBody,
QBigFaceExtra, QBigFaceExtra,
QSmallFaceExtra, QSmallFaceExtra,
} from '@/core/packet/transformer/proto'; } from '@/core/packet/transformer/proto';
@@ -29,7 +30,8 @@ import {
SendReplyElement, SendReplyElement,
SendMultiForwardMsgElement, SendMultiForwardMsgElement,
SendTextElement, SendTextElement,
SendVideoElement SendVideoElement,
Peer
} from '@/core'; } from '@/core';
import {ForwardMsgBuilder} from '@/common/forward-msg-builder'; import {ForwardMsgBuilder} from '@/common/forward-msg-builder';
import {PacketMsg, PacketSendMsgElement} from '@/core/packet/message/message'; import {PacketMsg, PacketSendMsgElement} from '@/core/packet/message/message';
@@ -146,41 +148,40 @@ export class PacketMsgAtElement extends PacketMsgTextElement {
} }
export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> { export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
messageId: bigint; time: number;
messageSeq: number; targetMessageId: bigint;
messageClientSeq: number; targetMessageSeq: number;
targetMessageClientSeq: number;
targetUin: number; targetUin: number;
targetUid: string; targetUid: string;
time: number; targetElems?: NapProtoEncodeStructType<typeof Elem>[];
elems: PacketMsg[]; targetSourceMsg?: NapProtoEncodeStructType<typeof PushMsgBody>;
targetPeer?: Peer;
constructor(element: SendReplyElement) { constructor(element: SendReplyElement) {
super(element); super(element);
this.messageId = BigInt(element.replyElement.replayMsgId ?? 0); this.time = +(element.replyElement.replyMsgTime ?? Math.floor(Date.now() / 1000));
this.messageSeq = +(element.replyElement.replayMsgSeq ?? 0); this.targetMessageId = BigInt(element.replyElement.replayMsgId ?? 0);
this.messageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0); this.targetMessageSeq = +(element.replyElement.replayMsgSeq ?? 0);
this.targetMessageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0);
this.targetUin = +(element.replyElement.senderUin ?? 0); this.targetUin = +(element.replyElement.senderUin ?? 0);
this.targetUid = element.replyElement.senderUidStr ?? ''; this.targetUid = element.replyElement.senderUidStr ?? '';
this.time = +(element.replyElement.replyMsgTime ?? 0); this.targetPeer = element.replyElement._replyMsgPeer;
this.elems = []; // TODO: in replyElement.sourceMsgTextElems
} }
get isGroupReply(): boolean { get isGroupReply(): boolean {
return this.messageClientSeq === 0; return this.targetMessageClientSeq === 0;
} }
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] { override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{ return [{
srcMsg: { srcMsg: {
origSeqs: [this.isGroupReply ? this.messageClientSeq : this.messageSeq], origSeqs: [this.isGroupReply ? this.targetMessageSeq : this.targetMessageClientSeq],
senderUin: BigInt(this.targetUin), senderUin: BigInt(this.targetUin),
time: this.time, time: this.time,
elems: [], // TODO: in replyElement.sourceMsgTextElems elems: this.targetElems ?? [],
pbReserve: { sourceMsg: new NapProtoMsg(PushMsgBody).encode(this.targetSourceMsg ?? {}),
messageId: this.messageId, toUin: BigInt(0),
},
toUin: BigInt(this.targetUin),
type: 1,
} }
}]; }];
} }

View File

@@ -0,0 +1,35 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
class MoveGroupFile extends PacketTransformer<typeof proto.OidbSvcTrpcTcp0x6D6Response> {
constructor() {
super();
}
build(groupUin: number, fileUUID: string, currentParentDirectory: string, targetParentDirectory: string): OidbPacket {
const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({
move: {
groupUin: groupUin,
appId: 5,
busId: 102,
fileId: fileUUID,
parentDirectory: currentParentDirectory,
targetDirectory: targetParentDirectory,
}
});
return OidbBase.build(0x6D6, 5, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody);
if (res.move.retCode !== 0) {
throw new Error(`sendGroupFileMoveReq error: ${res.move.clientWording} (code=${res.move.retCode})`);
}
return res;
}
}
export default new MoveGroupFile();

View File

@@ -0,0 +1,34 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
class RenameGroupFile extends PacketTransformer<typeof proto.OidbSvcTrpcTcp0x6D6Response> {
constructor() {
super();
}
build(groupUin: number, fileUUID: string, currentParentDirectory: string, newName: string): OidbPacket {
const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({
rename: {
groupUin: groupUin,
busId: 102,
fileId: fileUUID,
parentFolder: currentParentDirectory,
newFileName: newName,
}
});
return OidbBase.build(0x6D6, 4, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody);
if (res.rename.retCode !== 0) {
throw new Error(`sendGroupFileRenameReq error: ${res.rename.clientWording} (code=${res.rename.retCode})`);
}
return res;
}
}
export default new RenameGroupFile();

View File

@@ -8,16 +8,15 @@ class SetSpecialTitle extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase>
super(); super();
} }
build(groupCode: number, uid: string, tittle: string): OidbPacket { build(groupCode: number, uid: string, title: string): OidbPacket {
const oidb_0x8FC_2_body = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2_Body).encode({
targetUid: uid,
specialTitle: tittle,
expiredTime: -1,
uinName: tittle
});
const oidb_0x8FC_2 = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2).encode({ const oidb_0x8FC_2 = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2).encode({
groupUin: +groupCode, groupUin: +groupCode,
body: oidb_0x8FC_2_body body: {
targetUid: uid,
specialTitle: title,
expiredTime: -1,
uinName: title
}
}); });
return OidbBase.build(0x8FC, 2, oidb_0x8FC_2, false, false); return OidbBase.build(0x8FC, 2, oidb_0x8FC_2, false, false);
} }

View File

@@ -6,3 +6,5 @@ export { default as GetStrangerInfo } from './GetStrangerInfo';
export { default as SendPoke } from './SendPoke'; export { default as SendPoke } from './SendPoke';
export { default as SetSpecialTitle } from './SetSpecialTitle'; export { default as SetSpecialTitle } from './SetSpecialTitle';
export { default as ImageOCR } from './ImageOCR'; export { default as ImageOCR } from './ImageOCR';
export { default as MoveGroupFile } from './MoveGroupFile';
export { default as RenameGroupFile } from './RenameGroupFile';

View File

@@ -0,0 +1,27 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchC2CMessage extends PacketTransformer<typeof proto.SsoGetC2cMsgResponse> {
constructor() {
super();
}
build(targetUid: string, startSeq: number, endSeq: number): OidbPacket {
const req = new NapProtoMsg(proto.SsoGetC2cMsg).encode({
friendUid: targetUid,
startSequence: startSeq,
endSequence: endSeq,
});
return {
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg',
data: PacketHexStrBuilder(req)
};
}
parse(data: Buffer) {
return new NapProtoMsg(proto.SsoGetC2cMsgResponse).decode(data);
}
}
export default new FetchC2CMessage();

View File

@@ -0,0 +1,30 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchGroupMessage extends PacketTransformer<typeof proto.SsoGetGroupMsgResponse> {
constructor() {
super();
}
build(groupUin: number, startSeq: number, endSeq: number): OidbPacket {
const req = new NapProtoMsg(proto.SsoGetGroupMsg).encode({
info: {
groupUin: groupUin,
startSequence: startSeq,
endSequence: endSeq
},
direction: true
});
return {
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetGroupMsg',
data: PacketHexStrBuilder(req)
};
}
parse(data: Buffer) {
return new NapProtoMsg(proto.SsoGetGroupMsgResponse).decode(data);
}
}
export default new FetchGroupMessage();

View File

@@ -1,2 +1,4 @@
export { default as UploadForwardMsg } from './UploadForwardMsg'; export { default as UploadForwardMsg } from './UploadForwardMsg';
export { default as DownloadForwardMsg } from './DownloadForwardMsg'; export { default as FetchGroupMessage } from './FetchGroupMessage';
export { default as FetchC2CMessage } from './FetchC2CMessage';
export { default as DownloadForwardMsg } from './DownloadForwardMsg';

View File

@@ -13,13 +13,15 @@ import {
export const ContentHead = { export const ContentHead = {
type: ProtoField(1, ScalarType.UINT32), type: ProtoField(1, ScalarType.UINT32),
subType: ProtoField(2, ScalarType.UINT32, true), subType: ProtoField(2, ScalarType.UINT32, true),
divSeq: ProtoField(3, ScalarType.UINT32, true), c2cCmd: ProtoField(3, ScalarType.UINT32, true),
msgId: ProtoField(4, ScalarType.UINT32, true), ranDom: ProtoField(4, ScalarType.UINT32, true),
sequence: ProtoField(5, ScalarType.UINT32, true), sequence: ProtoField(5, ScalarType.UINT32, true),
timeStamp: ProtoField(6, ScalarType.UINT32, true), timeStamp: ProtoField(6, ScalarType.UINT32, true),
field7: ProtoField(7, ScalarType.UINT64, true), pkgNum: ProtoField(7, ScalarType.UINT64, true),
field8: ProtoField(8, ScalarType.UINT32, true), pkgIndex: ProtoField(8, ScalarType.UINT32, true),
field9: ProtoField(9, ScalarType.UINT32, true), divSeq: ProtoField(9, ScalarType.UINT32, true),
autoReply: ProtoField(10, ScalarType.UINT32),
ntMsgSeq: ProtoField(10, ScalarType.UINT32, true),
newId: ProtoField(12, ScalarType.UINT64, true), newId: ProtoField(12, ScalarType.UINT64, true),
forward: ProtoField(15, () => ForwardHead, true), forward: ProtoField(15, () => ForwardHead, true),
}; };

View File

@@ -4,12 +4,12 @@ import { ProtoField, ScalarType } from '@napneko/nap-proto-core';
//设置群头衔 OidbSvcTrpcTcp.0x8fc_2 //设置群头衔 OidbSvcTrpcTcp.0x8fc_2
export const OidbSvcTrpcTcp0X8FC_2_Body = { export const OidbSvcTrpcTcp0X8FC_2_Body = {
targetUid: ProtoField(1, ScalarType.STRING), targetUid: ProtoField(1, ScalarType.STRING),
specialTitle: ProtoField(5, ScalarType.STRING), specialTitle: ProtoField(5, ScalarType.STRING, true),
expiredTime: ProtoField(6, ScalarType.SINT32), expiredTime: ProtoField(6, ScalarType.INT32),
uinName: ProtoField(7, ScalarType.STRING), uinName: ProtoField(7, ScalarType.STRING, true),
targetName: ProtoField(8, ScalarType.STRING), targetName: ProtoField(8, ScalarType.STRING),
}; };
export const OidbSvcTrpcTcp0X8FC_2 = { export const OidbSvcTrpcTcp0X8FC_2 = {
groupUin: ProtoField(1, ScalarType.UINT32), groupUin: ProtoField(1, ScalarType.UINT32),
body: ProtoField(3, ScalarType.BYTES), body: ProtoField(3, () => OidbSvcTrpcTcp0X8FC_2_Body),
}; };

View File

@@ -16,7 +16,7 @@ export interface NodeIKernelBuddyService {
getBuddyListFromCache(reqType: BuddyListReqType): Promise<Array< getBuddyListFromCache(reqType: BuddyListReqType): Promise<Array<
{ {
categoryId: number,//9999应该跳过 那是兜底数据吧 categoryId: number,//9999为特别关心
categorySortId: number,//排序方式 categorySortId: number,//排序方式
categroyName: string,//分类名 categroyName: string,//分类名
categroyMbCount: number,//不懂 categroyMbCount: number,//不懂
@@ -106,15 +106,15 @@ export interface NodeIKernelBuddyService {
getAddMeSetting(): unknown; getAddMeSetting(): unknown;
getDoubtBuddyReq(): unknown; getDoubtBuddyReq(reqId: string, num: number,uk:string): Promise<GeneralCallResult>;
getDoubtBuddyUnreadNum(): number; getDoubtBuddyUnreadNum(): number;
approvalDoubtBuddyReq(uid: number, isAgree: boolean): void; approvalDoubtBuddyReq(uid: string, str1: string, str2: string): void;
delDoubtBuddyReq(uid: number): void; delDoubtBuddyReq(uid: number): void;
delAllDoubtBuddyReq(): void; delAllDoubtBuddyReq(): Promise<GeneralCallResult>;
reportDoubtBuddyReqUnread(): void; reportDoubtBuddyReqUnread(): void;

View File

@@ -425,7 +425,20 @@ export interface NodeIKernelMsgService {
switchToOfflineGetRichMediaElement(...args: unknown[]): unknown; switchToOfflineGetRichMediaElement(...args: unknown[]): unknown;
downloadRichMedia(...args: unknown[]): unknown; downloadRichMedia(args: {
fileModelId: string,
downSourceType: number,
triggerType: number,
msgId: string,
chatType: number,
peerUid: string,
elementId: string,
thumbSize: number,
downloadType: number,
filePath: string
} & {
downloadSourceType: number, //33800左右一下的老版本 新版34606已经完全上面格式
}): unknown;
getFirstUnreadMsgSeq(args: { getFirstUnreadMsgSeq(args: {
peerUid: string peerUid: string

View File

@@ -1,5 +1,5 @@
import { AnyCnameRecord } from 'node:dns'; import { AnyCnameRecord } from 'node:dns';
import { BizKey, ModifyProfileParams, NodeIKernelProfileListener, ProfileBizType, SimpleInfo, UserDetailInfoByUin, UserDetailSource } from '@/core'; import { BizKey, ModifyProfileParams, NodeIKernelProfileListener, ProfileBizType, SimpleInfo, UserDetailInfoByUin, UserDetailInfoListenerArg, UserDetailSource } from '@/core';
import { GeneralCallResult } from '@/core/services/common'; import { GeneralCallResult } from '@/core/services/common';
export interface NodeIKernelProfileService { export interface NodeIKernelProfileService {
@@ -15,7 +15,13 @@ export interface NodeIKernelProfileService {
getCoreAndBaseInfo(callfrom: string, uids: string[]): Promise<Map<string, SimpleInfo>>; getCoreAndBaseInfo(callfrom: string, uids: string[]): Promise<Map<string, SimpleInfo>>;
fetchUserDetailInfo(trace: string, uids: string[], source: UserDetailSource, bizType: ProfileBizType[]): Promise<GeneralCallResult>; fetchUserDetailInfo(trace: string, uids: string[], source: UserDetailSource, bizType: ProfileBizType[]): Promise<GeneralCallResult &
{
source: UserDetailSource,
// uid -> detail
detail: Map<string, UserDetailInfoListenerArg>,
}
>;
addKernelProfileListener(listener: NodeIKernelProfileListener): number; addKernelProfileListener(listener: NodeIKernelProfileListener): number;

View File

@@ -198,9 +198,29 @@ export interface NodeIKernelRichMediaService {
renameGroupFile(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): unknown; renameGroupFile(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): unknown;
moveGroupFile(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): unknown; moveGroupFile(groupCode: string, busId: Array<number>, fileList: Array<string>, currentParentDirectory: string, targetParentDirectory: string): Promise<GeneralCallResult & {
moveGroupFileResult: {
result: {
retCode: number,
retMsg: symbol,
clientWording: string
},
successFileIdList: Array<string>,
failFileIdList: Array<string>
}
}>;
transGroupFile(arg1: unknown, arg2: unknown): unknown; transGroupFile(groupCode: string, fileId: string): Promise<GeneralCallResult & {
transGroupFileResult: {
result: {
retCode: number
retMsg: string
clientWording: string
}
saveBusId: number
saveFilePath: string
}
}>;
searchGroupFile( searchGroupFile(
keywords: Array<string>, keywords: Array<string>,

View File

@@ -54,7 +54,7 @@ export interface NodeIKernelSearchService {
cancelSearchChatMsgs(...args: unknown[]): unknown;// needs 3 arguments cancelSearchChatMsgs(...args: unknown[]): unknown;// needs 3 arguments
searchMsgWithKeywords(keyWords: string[], param: Peer & { searchFields: number, pageLimit: number }): Promise<GeneralCallResult>; searchMsgWithKeywords(keyWords: string[], param: Peer & { searchFields: number, pageLimit: number }): number;
searchMoreMsgWithKeywords(...args: unknown[]): unknown;// needs 1 arguments searchMoreMsgWithKeywords(...args: unknown[]): unknown;// needs 1 arguments

View File

@@ -1,4 +1,15 @@
import { ElementType, MessageElement, NTGrayTipElementSubTypeV2, PicSubType, PicType, TipAioOpGrayTipElement, TipGroupElement, NTVideoType, FaceType } from './msg'; import {
ElementType,
MessageElement,
NTGrayTipElementSubTypeV2,
PicSubType,
PicType,
TipAioOpGrayTipElement,
TipGroupElement,
NTVideoType,
FaceType,
Peer
} from './msg';
type ElementFullBase = Omit<MessageElement, 'elementType' | 'elementId' | 'extBufForUI'>; type ElementFullBase = Omit<MessageElement, 'elementType' | 'elementId' | 'extBufForUI'>;
@@ -213,6 +224,9 @@ export interface ReplyElement {
senderUidStr?: string; senderUidStr?: string;
replyMsgTime?: string; replyMsgTime?: string;
replyMsgClientSeq?: string; replyMsgClientSeq?: string;
// HACK: Attributes that were not originally available,
// but were added due to NTQQ and NapCat's internal implementation, are used to supplement NapCat
_replyMsgPeer?: Peer;
} }
export interface CalendarElement { export interface CalendarElement {

View File

@@ -403,7 +403,7 @@ export interface NTGroupGrayMember {
} }
/** /**
* 群灰色提示邀请者和被邀请者接口 * 群灰色提示邀请者和被邀请者接口
* *
* */ * */
export interface NTGroupGrayInviterAndInvite { export interface NTGroupGrayInviterAndInvite {
invited: NTGroupGrayMember; invited: NTGroupGrayMember;
@@ -501,6 +501,7 @@ export interface RawMessage {
elements: MessageElement[];// 消息元素 elements: MessageElement[];// 消息元素
sourceType: MsgSourceType;// 消息来源类型 sourceType: MsgSourceType;// 消息来源类型
isOnlineMsg: boolean;// 是否为在线消息 isOnlineMsg: boolean;// 是否为在线消息
clientSeq?: string;
} }
/** /**
@@ -565,4 +566,4 @@ export enum FaceType {
AniSticke = 3, // 动画贴纸 AniSticke = 3, // 动画贴纸
Lottie = 4,// 新格式表情 Lottie = 4,// 新格式表情
Poke = 5 // 可变Poke Poke = 5 // 可变Poke
} }

View File

@@ -207,6 +207,7 @@ interface PhotoWall {
// 简单信息 // 简单信息
export interface SimpleInfo { export interface SimpleInfo {
qqLevel?: QQLevel;//临时添加
uid?: string; uid?: string;
uin?: string; uin?: string;
coreInfo: CoreInfo; coreInfo: CoreInfo;

View File

@@ -115,7 +115,7 @@ export interface GroupEssenceMsg {
add_digest_uin: string; add_digest_uin: string;
add_digest_nick: string; add_digest_nick: string;
add_digest_time: number; add_digest_time: number;
msg_content: unknown[]; msg_content: { msg_type: number, text?: string, image_url?: string }[];
can_be_removed: true; can_be_removed: true;
} }

View File

@@ -9,6 +9,8 @@ import { NodeIKernelLoginService } from '@/core/services';
import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper'; import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper';
import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui'; import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui';
import { NapCatOneBot11Adapter } from '@/onebot'; import { NapCatOneBot11Adapter } from '@/onebot';
import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg';
import { FFmpegService } from '@/common/ffmpeg';
//Framework ES入口文件 //Framework ES入口文件
export async function getWebUiUrl() { export async function getWebUiUrl() {
@@ -36,6 +38,15 @@ export async function NCoreInitFramework(
const logger = new LogWrapper(pathWrapper.logsPath); const logger = new LogWrapper(pathWrapper.logsPath);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion()); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion());
if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
downloadFFmpegIfNotExists(logger).then(({ path, reset }) => {
if (reset && path) {
FFmpegService.setFfmpegPath(path, logger);
}
}).catch(e => {
logger.logError('[Ffmpeg] Error:', e);
});
}
//直到登录成功后,执行下一步 //直到登录成功后,执行下一步
const selfInfo = await new Promise<SelfInfo>((resolveSelfInfo) => { const selfInfo = await new Promise<SelfInfo>((resolveSelfInfo) => {
const loginListener = new NodeIKernelLoginListener(); const loginListener = new NodeIKernelLoginListener();

View File

@@ -3,6 +3,7 @@ import Ajv, { ErrorObject, ValidateFunction } from 'ajv';
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
import { NapCatOneBot11Adapter, OB11Return } from '@/onebot'; import { NapCatOneBot11Adapter, OB11Return } from '@/onebot';
import { NetworkAdapterConfig } from '../config/config'; import { NetworkAdapterConfig } from '../config/config';
import { TSchema } from '@sinclair/typebox';
export class OB11Response { export class OB11Response {
private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null): OB11Return<T> { private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null): OB11Return<T> {
@@ -33,7 +34,7 @@ export abstract class OneBotAction<PayloadType, ReturnDataType> {
actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown; actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown;
core: NapCatCore; core: NapCatCore;
private validate?: ValidateFunction<unknown> = undefined; private validate?: ValidateFunction<unknown> = undefined;
payloadSchema?: unknown = undefined; payloadSchema?: TSchema = undefined;
obContext: NapCatOneBot11Adapter; obContext: NapCatOneBot11Adapter;
constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) { constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
@@ -43,7 +44,7 @@ export abstract class OneBotAction<PayloadType, ReturnDataType> {
protected async check(payload: PayloadType): Promise<BaseCheckResult> { protected async check(payload: PayloadType): Promise<BaseCheckResult> {
if (this.payloadSchema) { if (this.payloadSchema) {
this.validate = new Ajv({ allowUnionTypes: true, useDefaults: true }).compile(this.payloadSchema); this.validate = new Ajv({ allowUnionTypes: true, useDefaults: true, coerceTypes: true }).compile(this.payloadSchema);
} }
if (this.validate && !this.validate(payload)) { if (this.validate && !this.validate(payload)) {
const errors = this.validate.errors as ErrorObject[]; const errors = this.validate.errors as ErrorObject[];

View File

@@ -0,0 +1,33 @@
import { ActionName } from '@/onebot/action/router';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
file_id: Type.String(),
current_parent_directory: Type.String(),
target_parent_directory: Type.String(),
});
type Payload = Static<typeof SchemaData>;
interface MoveGroupFileResponse {
ok: boolean;
}
export class MoveGroupFile extends GetPacketStatusDepends<Payload, MoveGroupFileResponse> {
override actionName = ActionName.MoveGroupFile;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id);
if (contextMsgFile?.fileUUID) {
await this.core.apis.PacketApi.pkt.operation.MoveGroupFile(+payload.group_id, contextMsgFile.fileUUID, payload.current_parent_directory, payload.target_parent_directory);
return {
ok: true,
};
}
throw new Error('real fileUUID not found!');
}
}

View File

@@ -0,0 +1,33 @@
import { ActionName } from '@/onebot/action/router';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
file_id: Type.String(),
current_parent_directory: Type.String(),
new_name: Type.String(),
});
type Payload = Static<typeof SchemaData>;
interface RenameGroupFileResponse {
ok: boolean;
}
export class RenameGroupFile extends GetPacketStatusDepends<Payload, RenameGroupFileResponse> {
override actionName = ActionName.RenameGroupFile;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id);
if (contextMsgFile?.fileUUID) {
await this.core.apis.PacketApi.pkt.operation.RenameGroupFile(+payload.group_id, contextMsgFile.fileUUID, payload.current_parent_directory, payload.new_name);
return {
ok: true,
};
}
throw new Error('real fileUUID not found!');
}
}

View File

@@ -10,8 +10,8 @@ const SchemaData = Type.Object({
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
export class SetSpecialTittle extends GetPacketStatusDepends<Payload, void> { export class SetSpecialTitle extends GetPacketStatusDepends<Payload, void> {
override actionName = ActionName.SetSpecialTittle; override actionName = ActionName.SetSpecialTitle;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload) { async _handle(payload: Payload) {

View File

@@ -0,0 +1,34 @@
import { ActionName } from '@/onebot/action/router';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
file_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
interface TransGroupFileResponse {
ok: boolean;
}
export class TransGroupFile extends GetPacketStatusDepends<Payload, TransGroupFileResponse> {
override actionName = ActionName.TransGroupFile;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id);
if (contextMsgFile?.fileUUID) {
const result = await this.core.apis.GroupApi.transGroupFile(payload.group_id.toString(), contextMsgFile.fileUUID);
if (result.transGroupFileResult.result.retCode === 0) {
return {
ok: true
};
}
throw new Error(result.transGroupFileResult.result.retMsg);
}
throw new Error('real fileUUID not found!');
}
}

View File

@@ -4,14 +4,14 @@ import { ActionName } from '@/onebot/action/router';
import { MessageUnique } from '@/common/message-unique'; import { MessageUnique } from '@/common/message-unique';
import { Static, Type } from '@sinclair/typebox'; import { Static, Type } from '@sinclair/typebox';
import { ChatType, ElementType, MsgSourceType, NTMsgType, RawMessage } from '@/core'; import { ChatType, ElementType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
import { isNumeric } from '@/common/helper';
const SchemaData = Type.Object({ const SchemaData = Type.Object({
message_id: Type.Optional(Type.Union([Type.Number(), Type.String()])), message_id: Type.Optional(Type.String()),
id: Type.Optional(Type.Union([Type.Number(), Type.String()])), id: Type.Optional(Type.String()),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, { export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
messages: OB11Message[] | undefined; messages: OB11Message[] | undefined;
}> { }> {
@@ -53,19 +53,21 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
} }
async _handle(payload: Payload) { async _handle(payload: Payload) {
// 1. 检查消息ID是否存在
const msgId = payload.message_id || payload.id; const msgId = payload.message_id || payload.id;
if (!msgId) { if (!msgId) {
throw new Error('message_id is required'); throw new Error('message_id is required');
} }
const fakeForwardMsg = (res_id: string) => { // 2. 定义辅助函数 - 创建伪转发消息对象
const createFakeForwardMsg = (resId: string): RawMessage => {
return { return {
chatType: ChatType.KCHATTYPEGROUP, chatType: ChatType.KCHATTYPEGROUP,
elements: [{ elements: [{
elementType: ElementType.MULTIFORWARD, elementType: ElementType.MULTIFORWARD,
elementId: '', elementId: '',
multiForwardMsgElement: { multiForwardMsgElement: {
resId: res_id, resId: resId,
fileName: '', fileName: '',
xmlContent: '', xmlContent: '',
} }
@@ -96,8 +98,9 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
} as RawMessage; } as RawMessage;
}; };
const protocolFallbackLogic = async (res_id: string) => { // 3. 定义协议回退逻辑函数
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(fakeForwardMsg(res_id)))?.arrayMsg; const protocolFallbackLogic = async (resId: string) => {
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId)))?.arrayMsg;
if (ob) { if (ob) {
return { return {
messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content
@@ -105,31 +108,37 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
} }
throw new Error('protocolFallbackLogic: 找不到相关的聊天记录'); throw new Error('protocolFallbackLogic: 找不到相关的聊天记录');
}; };
// 4. 尝试通过正常渠道获取消息
// 如果是数字ID优先使用getMsgsByMsgId获取
if (!isNumeric(msgId)) {
let ret = await protocolFallbackLogic(msgId);
if (ret.messages) {
return ret;
}
throw new Error('ResId无效: 找不到相关的聊天记录');
}
const rootMsgId = MessageUnique.getShortIdByMsgId(msgId.toString()); const rootMsgId = MessageUnique.getShortIdByMsgId(msgId.toString());
const rootMsg = MessageUnique.getMsgIdAndPeerByShortId(rootMsgId ?? +msgId); const rootMsg = MessageUnique.getMsgIdAndPeerByShortId(rootMsgId ?? +msgId);
if (!rootMsg) {
return await protocolFallbackLogic(msgId.toString());
}
const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
if (!data || data.result !== 0) { if (rootMsg) {
return await protocolFallbackLogic(msgId.toString()); // 5. 获取消息内容
} const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
const singleMsg = data.msgList[0]; if (data && data.result === 0 && data.msgList.length > 0) {
if (!singleMsg) { const singleMsg = data.msgList[0];
return await protocolFallbackLogic(msgId.toString()); if (!singleMsg) {
} throw new Error('消息不存在或已过期');
const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;//强制array 以便处理 }
if (!(resMsg?.message?.[0] as OB11MessageForward)?.data?.content) { // 6. 解析消息内容
return await protocolFallbackLogic(msgId.toString()); const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;
}
return {
messages: (resMsg?.message?.[0] as OB11MessageForward)?.data?.content
};
//}
// return { message: resMsg }; const forwardContent = (resMsg?.message?.[0] as OB11MessageForward)?.data?.content;
if (forwardContent) {
return { messages: forwardContent };
}
}
}
// 说明消息已过期或者为内层消息 NapCat 一次返回不处理内层消息
throw new Error('消息已过期或者为内层消息,无法获取转发消息');
} }
} }

View File

@@ -11,10 +11,10 @@ interface Response {
messages: OB11Message[]; messages: OB11Message[];
} }
const SchemaData = Type.Object({ const SchemaData = Type.Object({
user_id: Type.Union([Type.Number(), Type.String()]), user_id: Type.String(),
message_seq: Type.Optional(Type.Union([Type.Number(), Type.String()])), message_seq: Type.Optional(Type.String()),
count: Type.Union([Type.Number(), Type.String()], { default: 20 }), count: Type.Number({ default: 20 }),
reverseOrder: Type.Optional(Type.Union([Type.Boolean(), Type.String()])) reverseOrder: Type.Boolean({ default: false })
}); });
@@ -27,18 +27,14 @@ export default class GetFriendMsgHistory extends OneBotAction<Payload, Response>
async _handle(payload: Payload, _adapter: string, config: NetworkAdapterConfig): Promise<Response> { async _handle(payload: Payload, _adapter: string, config: NetworkAdapterConfig): Promise<Response> {
//处理参数 //处理参数
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
const isReverseOrder = typeof payload.reverseOrder === 'string' ? payload.reverseOrder === 'true' : !!payload.reverseOrder;
if (!uid) throw new Error(`记录${payload.user_id}不存在`); if (!uid) throw new Error(`记录${payload.user_id}不存在`);
const friend = await this.core.apis.FriendApi.isBuddy(uid); const friend = await this.core.apis.FriendApi.isBuddy(uid);
const peer = { chatType: friend ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid: uid }; const peer = { chatType: friend ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid: uid };
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0'); const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0'; const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
const msgList = hasMessageSeq ? const msgList = hasMessageSeq ?
(await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`); if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
//翻转消息
if (isReverseOrder) msgList.reverse();
//转换序号 //转换序号
await Promise.all(msgList.map(async msg => { await Promise.all(msgList.map(async msg => {
msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId); msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId);

View File

@@ -11,10 +11,10 @@ interface Response {
} }
const SchemaData = Type.Object({ const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]), group_id: Type.String(),
message_seq: Type.Optional(Type.Union([Type.Number(), Type.String()])), message_seq: Type.Optional(Type.String()),
count: Type.Union([Type.Number(), Type.String()], { default: 20 }), count: Type.Number({ default: 20 }),
reverseOrder: Type.Optional(Type.Union([Type.Boolean(), Type.String()])) reverseOrder: Type.Boolean({ default: false })
}); });
@@ -26,17 +26,13 @@ export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction<Payload, Re
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload, _adapter: string, config: NetworkAdapterConfig): Promise<Response> { async _handle(payload: Payload, _adapter: string, config: NetworkAdapterConfig): Promise<Response> {
//处理参数
const isReverseOrder = typeof payload.reverseOrder === 'string' ? payload.reverseOrder === 'true' : !!payload.reverseOrder;
const peer: Peer = { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id.toString() }; const peer: Peer = { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id.toString() };
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0'); const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
//拉取消息 //拉取消息
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0'; const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
const msgList = hasMessageSeq ? const msgList = hasMessageSeq ?
(await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`); if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
//翻转消息
if (isReverseOrder) msgList.reverse();
//转换序号 //转换序号
await Promise.all(msgList.map(async msg => { await Promise.all(msgList.map(async msg => {
msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId); msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId);

View File

@@ -7,6 +7,7 @@ import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({ const SchemaData = Type.Object({
user_id: Type.Union([Type.Number(), Type.String()]), user_id: Type.Union([Type.Number(), Type.String()]),
no_cache: Type.Union([Type.Boolean(), Type.String()], { default: false }),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
@@ -16,10 +17,11 @@ export default class GoCQHTTPGetStrangerInfo extends OneBotAction<Payload, OB11U
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload) { async _handle(payload: Payload) {
const user_id = payload.user_id.toString(); const user_id = payload.user_id.toString();
const isNocache = typeof payload.no_cache === 'string' ? payload.no_cache === 'true' : !!payload.no_cache;
const extendData = await this.core.apis.UserApi.getUserDetailInfoByUin(user_id); const extendData = await this.core.apis.UserApi.getUserDetailInfoByUin(user_id);
let uid = (await this.core.apis.UserApi.getUidByUinV2(user_id)); let uid = (await this.core.apis.UserApi.getUidByUinV2(user_id));
if (!uid) uid = extendData.detail.uid; if (!uid) uid = extendData.detail.uid;
const info = (await this.core.apis.UserApi.getUserDetailInfo(uid)); const info = (await this.core.apis.UserApi.getUserDetailInfo(uid, isNocache));
return { return {
...extendData.detail.simpleInfo.coreInfo, ...extendData.detail.simpleInfo.coreInfo,
...extendData.detail.commonExt ?? {}, ...extendData.detail.commonExt ?? {},

View File

@@ -3,7 +3,7 @@ import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox'; import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({ const SchemaData = Type.Object({
model: Type.String(), model: Type.Optional(Type.String()),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;

View File

@@ -38,6 +38,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, null>
deleteAfterSentFiles: [] deleteAfterSentFiles: []
}; };
const sendFileEle = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id); const sendFileEle = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles); await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);
return null; return null;
} }

View File

@@ -23,7 +23,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
if (payload.user_id) { if (payload.user_id) {
const peerUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); const peerUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
if (!peerUid) { if (!peerUid) {
throw new Error( `私聊${payload.user_id}不存在`); throw new Error(`私聊${payload.user_id}不存在`);
} }
const isBuddy = await this.core.apis.FriendApi.isBuddy(peerUid); const isBuddy = await this.core.apis.FriendApi.isBuddy(peerUid);
return { chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid }; return { chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid };
@@ -48,6 +48,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
deleteAfterSentFiles: [] deleteAfterSentFiles: []
}; };
const sendFileEle: SendFileElement = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name); const sendFileEle: SendFileElement = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles); await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);
return null; return null;
} }

View File

@@ -20,6 +20,8 @@ class GetGroupInfo extends OneBotAction<Payload, OB11Group> {
const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString()); const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString());
return { return {
...data, ...data,
group_all_shut: data.shutUpAllTimestamp > 0 ? -1 : 0,
group_remark: '',
group_id: +payload.group_id, group_id: +payload.group_id,
group_name: data.groupName, group_name: data.groupName,
member_count: data.memberNum, member_count: data.memberNum,

View File

@@ -32,7 +32,7 @@ class GetGroupMemberInfo extends OneBotAction<Payload, OB11GroupMember> {
const [member, info] = await Promise.all([ const [member, info] = await Promise.all([
this.core.apis.GroupApi.getGroupMemberEx(payload.group_id.toString(), uid, isNocache), this.core.apis.GroupApi.getGroupMemberEx(payload.group_id.toString(), uid, isNocache),
this.core.apis.UserApi.getUserDetailInfo(uid), this.core.apis.UserApi.getUserDetailInfo(uid, isNocache),
]); ]);
if (!member || !groupMember) throw new Error(`群(${payload.group_id})成员${payload.user_id}不存在`); if (!member || !groupMember) throw new Error(`群(${payload.group_id})成员${payload.user_id}不存在`);

View File

@@ -81,7 +81,7 @@ import { GetGroupSystemMsg } from './system/GetSystemMsg';
import { GroupPoke } from './group/GroupPoke'; import { GroupPoke } from './group/GroupPoke';
import { GetUserStatus } from './extends/GetUserStatus'; import { GetUserStatus } from './extends/GetUserStatus';
import { GetRkey } from './extends/GetRkey'; import { GetRkey } from './extends/GetRkey';
import { SetSpecialTittle } from './extends/SetSpecialTittle'; import { SetSpecialTitle } from './extends/SetSpecialTitle';
import { GetGroupShutList } from './group/GetGroupShutList'; import { GetGroupShutList } from './group/GetGroupShutList';
import { GetGroupMemberList } from './group/GetGroupMemberList'; import { GetGroupMemberList } from './group/GetGroupMemberList';
import { GetGroupFileUrl } from '@/onebot/action/file/GetGroupFileUrl'; import { GetGroupFileUrl } from '@/onebot/action/file/GetGroupFileUrl';
@@ -109,10 +109,24 @@ import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton';
import { GetPrivateFileUrl } from './file/GetPrivateFileUrl'; import { GetPrivateFileUrl } from './file/GetPrivateFileUrl';
import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList'; import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList';
import SetGroupRemark from './extends/SetGroupRemark'; import SetGroupRemark from './extends/SetGroupRemark';
import { MoveGroupFile } from './extends/MoveGroupFile';
import { TransGroupFile } from './extends/TransGroupFile';
import { RenameGroupFile } from './extends/RenameGroupFile';
import { GetRkeyServer } from './packet/GetRkeyServer';
import { GetRkeyEx } from './packet/GetRkeyEx';
import { CleanCache } from './system/CleanCache';
import SetFriendRemark from './user/SetFriendRemark';
import { SetDoubtFriendsAddRequest } from './new/SetDoubtFriendsAddRequest';
import { GetDoubtFriendsAddRequest } from './new/GetDoubtFriendsAddRequest';
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) { export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
const actionHandlers = [ const actionHandlers = [
new SetDoubtFriendsAddRequest(obContext, core),
new GetDoubtFriendsAddRequest(obContext, core),
new SetFriendRemark(obContext, core),
new GetRkeyEx(obContext, core),
new GetRkeyServer(obContext, core),
new SetGroupRemark(obContext, core), new SetGroupRemark(obContext, core),
new GetGroupInfoEx(obContext, core), new GetGroupInfoEx(obContext, core),
new FetchEmojiLike(obContext, core), new FetchEmojiLike(obContext, core),
@@ -132,6 +146,9 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new SetGroupSign(obContext, core), new SetGroupSign(obContext, core),
new SendGroupSign(obContext, core), new SendGroupSign(obContext, core),
new GetClientkey(obContext, core), new GetClientkey(obContext, core),
new MoveGroupFile(obContext, core),
new RenameGroupFile(obContext, core),
new TransGroupFile(obContext, core),
// onebot11 // onebot11
new SendLike(obContext, core), new SendLike(obContext, core),
new GetMsg(obContext, core), new GetMsg(obContext, core),
@@ -215,7 +232,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new FriendPoke(obContext, core), new FriendPoke(obContext, core),
new GetUserStatus(obContext, core), new GetUserStatus(obContext, core),
new GetRkey(obContext, core), new GetRkey(obContext, core),
new SetSpecialTittle(obContext, core), new SetSpecialTitle(obContext, core),
new SetDiyOnlineStatus(obContext, core), new SetDiyOnlineStatus(obContext, core),
// new UploadForwardMsg(obContext, core), // new UploadForwardMsg(obContext, core),
new GetGroupShutList(obContext, core), new GetGroupShutList(obContext, core),
@@ -231,6 +248,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new ClickInlineKeyboardButton(obContext, core), new ClickInlineKeyboardButton(obContext, core),
new GetPrivateFileUrl(obContext, core), new GetPrivateFileUrl(obContext, core),
new GetUnidirectionalFriendList(obContext, core), new GetUnidirectionalFriendList(obContext, core),
new CleanCache(obContext, core),
]; ];
type HandlerUnion = typeof actionHandlers[number]; type HandlerUnion = typeof actionHandlers[number];

View File

@@ -38,7 +38,7 @@ export function normalize(message: OB11MessageMixType, autoEscape = false): OB11
export async function createContext(core: NapCatCore, payload: OB11PostContext | undefined, contextMode: ContextMode = ContextMode.Normal): Promise<Peer> { export async function createContext(core: NapCatCore, payload: OB11PostContext | undefined, contextMode: ContextMode = ContextMode.Normal): Promise<Peer> {
if (!payload) { if (!payload) {
throw new Error('请指定 group_id 或 user_id'); throw new Error('请传递请求内容');
} }
if ((contextMode === ContextMode.Group || contextMode === ContextMode.Normal) && payload.group_id) { if ((contextMode === ContextMode.Group || contextMode === ContextMode.Normal) && payload.group_id) {
return { return {
@@ -48,7 +48,16 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext |
} }
if ((contextMode === ContextMode.Private || contextMode === ContextMode.Normal) && payload.user_id) { if ((contextMode === ContextMode.Private || contextMode === ContextMode.Normal) && payload.user_id) {
const Uid = await core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); const Uid = await core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
if (!Uid) throw new Error('无法获取用户信息'); if (!Uid) {
if (payload.group_id) {
return {
chatType: ChatType.KCHATTYPEGROUP,
peerUid: payload.group_id.toString(),
guildId: ''
}
}
throw new Error('无法获取用户信息');
}
const isBuddy = await core.apis.FriendApi.isBuddy(Uid); const isBuddy = await core.apis.FriendApi.isBuddy(Uid);
if (!isBuddy) { if (!isBuddy) {
const ret = await core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, Uid); const ret = await core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, Uid);
@@ -78,7 +87,13 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext |
guildId: '', guildId: '',
}; };
} }
throw new Error('请指定 group_id 或 user_id'); if (contextMode === ContextMode.Private && payload.group_id) {
throw new Error('当前私聊发送,请指定 user_id 而不是 group_id');
}
if (contextMode === ContextMode.Group && payload.user_id) {
throw new Error('当前群聊发送,请指定 group_id 而不是 user_id');
}
throw new Error('请指定正确的 group_id 或 user_id');
} }
function getSpecialMsgNum(payload: OB11PostSendMsg, msgType: OB11MessageDataType): number { function getSpecialMsgNum(payload: OB11PostSendMsg, msgType: OB11MessageDataType): number {

View File

@@ -0,0 +1,18 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
count: Type.Number({ default: 50 }),
});
type Payload = Static<typeof SchemaData>;
export class GetDoubtFriendsAddRequest extends OneBotAction<Payload, unknown> {
override actionName = ActionName.GetDoubtFriendsAddRequest;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return await this.core.apis.FriendApi.getDoubtFriendRequest(payload.count);
}
}

View File

@@ -0,0 +1,21 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
flag: Type.String(),
//注意强制String 非isNumeric 不遵守则不符合设计
approve: Type.Boolean({ default: true }),
//该字段没有语义 仅做保留 强制为True
});
type Payload = Static<typeof SchemaData>;
export class SetDoubtFriendsAddRequest extends OneBotAction<Payload, unknown> {
override actionName = ActionName.SetDoubtFriendsAddRequest;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return await this.core.apis.FriendApi.handleDoubtFriendRequest(payload.flag);
}
}

View File

@@ -0,0 +1,18 @@
import { ActionName } from '@/onebot/action/router';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
export class GetRkeyEx extends GetPacketStatusDepends<void, unknown> {
override actionName = ActionName.GetRkeyEx;
async _handle() {
let rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
return rkeys.map(rkey => {
return {
type: rkey.type === 10 ? "private" : "group",
rkey: rkey.rkey,
created_at: rkey.time,
ttl: rkey.ttl,
};
});
}
}

View File

@@ -0,0 +1,38 @@
import { ActionName } from '@/onebot/action/router';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
export class GetRkeyServer extends GetPacketStatusDepends<void, { private_rkey?: string; group_rkey?: string; expired_time?: number; name: string }> {
override actionName = ActionName.GetRkeyServer;
private rkeyCache: {
private_rkey?: string;
group_rkey?: string;
expired_time?: number;
name: string;
} | null = null;
private expiryTime: number | null = null;
async _handle() {
// 检查缓存是否有效
if (this.expiryTime && this.expiryTime > Math.floor(Date.now() / 1000) && this.rkeyCache) {
return this.rkeyCache;
}
// 获取新的 Rkey
let rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
let privateRkeyItem = rkeys.filter(rkey => rkey.type === 10)[0];
let groupRkeyItem = rkeys.filter(rkey => rkey.type === 20)[0];
this.expiryTime = Math.floor(Date.now() / 1000) + Math.min(+groupRkeyItem!.ttl.toString(),+privateRkeyItem!.ttl.toString());
// 更新缓存
this.rkeyCache = {
private_rkey: privateRkeyItem ? privateRkeyItem.rkey : undefined,
group_rkey: groupRkeyItem ? groupRkeyItem.rkey : undefined,
expired_time: this.expiryTime,
name: "NapCat 4"
};
return this.rkeyCache;
}
}

View File

@@ -10,6 +10,12 @@ export interface InvalidCheckResult {
} }
export const ActionName = { export const ActionName = {
// new extends 完全差异OneBot类别
GetDoubtFriendsAddRequest: 'get_doubt_friends_add_request',
SetDoubtFriendsAddRequest: 'set_doubt_friends_add_request',
// napcat
GetRkeyEx: 'get_rkey',
GetRkeyServer: 'get_rkey_server',
SetGroupRemark: 'set_group_remark', SetGroupRemark: 'set_group_remark',
NapCat_GetPrivateFileUrl: 'get_private_file_url', NapCat_GetPrivateFileUrl: 'get_private_file_url',
ClickInlineKeyboardButton: 'click_inline_keyboard_button', ClickInlineKeyboardButton: 'click_inline_keyboard_button',
@@ -31,8 +37,9 @@ export const ActionName = {
SetGroupCard: 'set_group_card', SetGroupCard: 'set_group_card',
SetGroupName: 'set_group_name', SetGroupName: 'set_group_name',
SetGroupLeave: 'set_group_leave', SetGroupLeave: 'set_group_leave',
SetSpecialTittle: 'set_group_special_title', SetSpecialTitle: 'set_group_special_title',
SetFriendAddRequest: 'set_friend_add_request', SetFriendAddRequest: 'set_friend_add_request',
SetFriendRemark: 'set_friend_remark',
SetGroupAddRequest: 'set_group_add_request', SetGroupAddRequest: 'set_group_add_request',
GetLoginInfo: 'get_login_info', GetLoginInfo: 'get_login_info',
GoCQHTTP_GetStrangerInfo: 'get_stranger_info', GoCQHTTP_GetStrangerInfo: 'get_stranger_info',
@@ -52,7 +59,7 @@ export const ActionName = {
GetStatus: 'get_status', GetStatus: 'get_status',
GetVersionInfo: 'get_version_info', GetVersionInfo: 'get_version_info',
// Reboot : 'set_restart', // Reboot : 'set_restart',
// CleanCache : 'clean_cache', CleanCache : 'clean_cache',
Exit: 'bot_exit', Exit: 'bot_exit',
// go-cqhttp // go-cqhttp
SetQQProfile: 'set_qq_profile', SetQQProfile: 'set_qq_profile',
@@ -130,6 +137,10 @@ export const ActionName = {
GetRkey: 'nc_get_rkey', GetRkey: 'nc_get_rkey',
GetGroupShutList: 'get_group_shut_list', GetGroupShutList: 'get_group_shut_list',
MoveGroupFile: 'move_group_file',
TransGroupFile: 'trans_group_file',
RenameGroupFile: 'rename_group_file',
GetGuildList: 'get_guild_list', GetGuildList: 'get_guild_list',
GetGuildProfile: 'get_guild_service_profile', GetGuildProfile: 'get_guild_service_profile',

View File

@@ -0,0 +1,38 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { unlink, readdir } from 'fs/promises';
import { join } from 'path';
export class CleanCache extends OneBotAction<void, void> {
override actionName = ActionName.CleanCache;
async _handle() {
try {
// 获取临时文件夹路径
const tempPath = this.core.NapCatTempPath;
// 读取文件夹中的所有文件
const files = await readdir(tempPath);
// 删除每个文件
const deletePromises = files.map(async (file) => {
const filePath = join(tempPath, file);
try {
await unlink(filePath);
this.core.context.logger.log(`已删除文件: ${filePath}`);
} catch (err: unknown) {
this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`);
}
});
// 等待所有删除操作完成
await Promise.all(deletePromises);
this.core.context.logger.log(`临时文件夹清理完成: ${tempPath}`);
} catch (err: unknown) {
this.core.context.logger.log(`清理缓存失败: ${(err as Error).message}`);
throw err;
}
}
}

View File

@@ -14,8 +14,22 @@ export default class GetFriendList extends OneBotAction<Payload, OB11User[]> {
override actionName = ActionName.GetFriendList; override actionName = ActionName.GetFriendList;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload) { async _handle(_payload: Payload) {
//全新逻辑 const buddyMap = await this.core.apis.FriendApi.getBuddyV2SimpleInfoMap();
return OB11Construct.friends(await this.core.apis.FriendApi.getBuddy(typeof payload.no_cache === 'string' ? payload.no_cache === 'true' : !!payload.no_cache)); const isNocache = typeof _payload.no_cache === 'string' ? _payload.no_cache === 'true' : !!_payload.no_cache;
await Promise.all(
Array.from(buddyMap.values()).map(async (buddyInfo) => {
try {
const userDetail = await this.core.apis.UserApi.getUserDetailInfo(buddyInfo.coreInfo.uid, isNocache);
const data = buddyMap.get(buddyInfo.coreInfo.uid);
if (data) {
data.qqLevel = userDetail.qqLevel;
}
} catch (error) {
this.core.context.logger.logError('获取好友详细信息失败', error);
}
})
);
return OB11Construct.friends(Array.from(buddyMap.values()));
} }
} }

View File

@@ -0,0 +1,25 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
user_id: Type.String(),
remark: Type.String()
});
type Payload = Static<typeof SchemaData>;
export default class SetFriendRemark extends OneBotAction<Payload, null> {
override actionName = ActionName.SetFriendRemark;
override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<null> {
let friendUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id);
let is_friend = await this.core.apis.FriendApi.isBuddy(friendUid);
if (!is_friend) {
throw new Error(`用户 ${payload.user_id} 不是好友`);
}
await this.core.apis.FriendApi.setBuddyRemark(friendUid, payload.remark);
return null;
}
}

View File

@@ -114,7 +114,6 @@ export class OneBotGroupApi {
async parseCardChangedEvent(msg: RawMessage) { async parseCardChangedEvent(msg: RawMessage) {
if (msg.senderUin && msg.senderUin !== '0') { if (msg.senderUin && msg.senderUin !== '0') {
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUid, msg.senderUin); const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUid, msg.senderUin);
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (member && member.cardName !== msg.sendMemberName) { if (member && member.cardName !== msg.sendMemberName) {
const newCardName = msg.sendMemberName ?? ''; const newCardName = msg.sendMemberName ?? '';
const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName); const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName);
@@ -130,7 +129,6 @@ export class OneBotGroupApi {
async parsePaiYiPai(msg: RawMessage, jsonStr: string) { async parsePaiYiPai(msg: RawMessage, jsonStr: string) {
const json = JSON.parse(jsonStr); const json = JSON.parse(jsonStr);
//判断业务类型 //判断业务类型
//Poke事件 //Poke事件
const pokedetail: Array<{ uid: string }> = json.items; const pokedetail: Array<{ uid: string }> = json.items;
@@ -151,14 +149,15 @@ export class OneBotGroupApi {
async parseOtherJsonEvent(msg: RawMessage, jsonStr: string, context: InstanceContext) { async parseOtherJsonEvent(msg: RawMessage, jsonStr: string, context: InstanceContext) {
const json = JSON.parse(jsonStr); const json = JSON.parse(jsonStr);
const type = json.items[json.items.length - 1]?.txt; const type = json.items[json.items.length - 1]?.txt;
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (type === '头衔') { if (type === '头衔') {
const memberUin = json.items[1].param[0]; const memberUin = json.items[1].param[0];
const title = json.items[3].txt; const title = json.items[3].txt;
context.logger.logDebug('收到群成员新头衔消息', json); context.logger.logDebug('收到群成员新头衔消息', json);
return new OB11GroupTitleEvent( return new OB11GroupTitleEvent(
this.core, this.core,
parseInt(msg.peerUid), +msg.peerUid,
parseInt(memberUin), +memberUin,
title, title,
); );
} else if (type === '移出') { } else if (type === '移出') {
@@ -251,7 +250,34 @@ export class OneBotGroupApi {
'invite' 'invite'
); );
} }
async parse51TypeEvent(msg: RawMessage, grayTipElement: GrayTipElement) {
// 神经腾讯 没了妈妈想出来的
// Warn 下面存在高并发危险
if (grayTipElement.jsonGrayTipElement.jsonStr) {
const json: {
align: string,
items: Array<{ txt: string, type: string }>
} = JSON.parse(grayTipElement.jsonGrayTipElement.jsonStr);
if (json.items.length === 1 && json.items[0]?.txt.endsWith('加入群')) {
let old_members = structuredClone(this.core.apis.GroupApi.groupMemberCache.get(msg.peerUid));
if (!old_members) return;
let new_members_map = await this.core.apis.GroupApi.refreshGroupMemberCache(msg.peerUid, true);
if (!new_members_map) return;
let new_members = Array.from(new_members_map.values());
// 对比members查找新成员
let new_member = new_members.find((member) => old_members.get(member.uid) == undefined);
if (!new_member) return;
return new OB11GroupIncreaseEvent(
this.core,
+msg.peerUid,
+new_member.uin,
0,
'invite',
);
}
}
return;
}
async parseGrayTipElement(msg: RawMessage, grayTipElement: GrayTipElement) { async parseGrayTipElement(msg: RawMessage, grayTipElement: GrayTipElement) {
if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_GROUP) { if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_GROUP) {
// 解析群组事件 由sysmsg解析 // 解析群组事件 由sysmsg解析
@@ -283,6 +309,9 @@ export class OneBotGroupApi {
return await this.parsePaiYiPai(msg, grayTipElement.jsonGrayTipElement.jsonStr); return await this.parsePaiYiPai(msg, grayTipElement.jsonGrayTipElement.jsonStr);
} else if (grayTipElement.jsonGrayTipElement.busiId == JsonGrayBusiId.AIO_GROUP_ESSENCE_MSG_TIP) { } else if (grayTipElement.jsonGrayTipElement.busiId == JsonGrayBusiId.AIO_GROUP_ESSENCE_MSG_TIP) {
return await this.parseEssenceMsg(msg, grayTipElement.jsonGrayTipElement.jsonStr); return await this.parseEssenceMsg(msg, grayTipElement.jsonGrayTipElement.jsonStr);
} else if (+(grayTipElement.jsonGrayTipElement.busiId ?? 0) == 51) {
// 51是什么{"align":"center","items":[{"txt":"下一秒起床通过王者荣耀加入群","type":"nor"}]
return await this.parse51TypeEvent(msg, grayTipElement);
} else { } else {
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context); return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context);
} }

View File

@@ -34,7 +34,7 @@ import { EventType } from '@/onebot/event/OneBotEvent';
import { encodeCQCode } from '@/onebot/helper/cqcode'; import { encodeCQCode } from '@/onebot/helper/cqcode';
import { uriToLocalFile } from '@/common/file'; import { uriToLocalFile } from '@/common/file';
import { RequestUtil } from '@/common/request'; import { RequestUtil } from '@/common/request';
import fsPromise, { constants } from 'node:fs/promises'; import fsPromise from 'node:fs/promises';
import { OB11FriendAddNoticeEvent } from '@/onebot/event/notice/OB11FriendAddNoticeEvent'; import { OB11FriendAddNoticeEvent } from '@/onebot/event/notice/OB11FriendAddNoticeEvent';
import { ForwardMsgBuilder } from '@/common/forward-msg-builder'; import { ForwardMsgBuilder } from '@/common/forward-msg-builder';
import { NapProtoMsg } from '@napneko/nap-proto-core'; import { NapProtoMsg } from '@napneko/nap-proto-core';
@@ -45,6 +45,7 @@ import { OB11GroupAdminNoticeEvent } from '../event/notice/OB11GroupAdminNoticeE
import { GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody } from '@/core/packet/transformer/proto'; import { GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody } from '@/core/packet/transformer/proto';
import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest'; import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest';
import { LRUCache } from '@/common/lru-cache'; import { LRUCache } from '@/common/lru-cache';
import { cleanTaskQueue } from '@/common/clean-task';
type RawToOb11Converters = { type RawToOb11Converters = {
[Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: ( [Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: (
@@ -372,7 +373,8 @@ export class OneBotMsgApi {
try { try {
multiMsgs = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsg(element.resId); multiMsgs = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsg(element.resId);
} catch (e) { } catch (e) {
this.core.context.logger.logError('Protocol FetchForwardMsg fallback failed!', e); this.core.context.logger.logError(`Protocol FetchForwardMsg fallback failed!
element = ${JSON.stringify(element)} , error=${e})`);
return null; return null;
} }
} }
@@ -465,6 +467,8 @@ export class OneBotMsgApi {
replayMsgId: replyMsg.msgId, // raw.msgId replayMsgId: replyMsg.msgId, // raw.msgId
senderUin: replyMsg.senderUin, senderUin: replyMsg.senderUin,
senderUinStr: replyMsg.senderUin, senderUinStr: replyMsg.senderUin,
replyMsgClientSeq: replyMsg.clientSeq,
_replyMsgPeer: replyMsgM.Peer
}, },
} : } :
undefined; undefined;
@@ -554,7 +558,7 @@ export class OneBotMsgApi {
}, },
[OB11MessageDataType.voice]: async (sendMsg, context) => [OB11MessageDataType.voice]: async (sendMsg, context) =>
this.core.apis.FileApi.createValidSendPttElement( this.core.apis.FileApi.createValidSendPttElement(context,
(await this.handleOb11FileLikeMessage(sendMsg, context)).path), (await this.handleOb11FileLikeMessage(sendMsg, context)).path),
[OB11MessageDataType.json]: async ({ data: { data } }) => ({ [OB11MessageDataType.json]: async ({ data: { data } }) => ({
@@ -712,6 +716,56 @@ export class OneBotMsgApi {
this.obContext = obContext; this.obContext = obContext;
this.core = core; this.core = core;
} }
/**
* 解析带有JSON标记的文本
* @param text 要解析的文本
* @returns 解析后的结果数组,每个元素包含类型(text或json)和内容
*/
parseTextWithJson(text: string) {
// 匹配<{...}>格式的JSON
const regex = /<(\{.*?\})>/g;
const parts: Array<{ type: 'text' | 'json', content: string | object }> = [];
let lastIndex = 0;
let match;
// 查找所有匹配项
while ((match = regex.exec(text)) !== null) {
// 添加匹配前的文本
if (match.index > lastIndex) {
parts.push({
type: 'text',
content: text.substring(lastIndex, match.index)
});
}
// 添加JSON部分
try {
const jsonContent = JSON.parse(match[1] ?? '');
parts.push({
type: 'json',
content: jsonContent
});
} catch (e) {
// 如果JSON解析失败作为普通文本处理
parts.push({
type: 'text',
content: match[0]
});
}
lastIndex = regex.lastIndex;
}
// 添加最后一部分文本
if (lastIndex < text.length) {
parts.push({
type: 'text',
content: text.substring(lastIndex)
});
}
return parts;
}
async parsePrivateMsgEvent(msg: RawMessage, grayTipElement: GrayTipElement) { async parsePrivateMsgEvent(msg: RawMessage, grayTipElement: GrayTipElement) {
if (grayTipElement.subElementType == NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_JSON) { if (grayTipElement.subElementType == NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_JSON) {
@@ -855,10 +909,10 @@ export class OneBotMsgApi {
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin); const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode); resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode);
resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话'; resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话';
resMsg.temp_source = resMsg.group_id; resMsg.temp_source = 0;
} else { } else {
resMsg.group_id = 284840486; resMsg.group_id = 284840486;
resMsg.temp_source = resMsg.group_id; resMsg.temp_source = 0;
resMsg.sender.nickname = '临时会话'; resMsg.sender.nickname = '临时会话';
} }
} }
@@ -970,7 +1024,6 @@ export class OneBotMsgApi {
}); });
const timeout = 10000 + (totalSize / 1024 / 256 * 1000); const timeout = 10000 + (totalSize / 1024 / 256 * 1000);
try { try {
const returnMsg = await this.core.apis.MsgApi.sendMsg(peer, sendElements, timeout); const returnMsg = await this.core.apis.MsgApi.sendMsg(peer, sendElements, timeout);
if (!returnMsg) throw new Error('发送消息失败'); if (!returnMsg) throw new Error('发送消息失败');
@@ -983,18 +1036,19 @@ export class OneBotMsgApi {
} catch (error) { } catch (error) {
throw new Error((error as Error).message); throw new Error((error as Error).message);
} finally { } finally {
setTimeout(async () => { cleanTaskQueue.addFiles(deleteAfterSentFiles, timeout);
const deletePromises = deleteAfterSentFiles.map(async file => { // setTimeout(async () => {
try { // const deletePromises = deleteAfterSentFiles.map(async file => {
if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) { // try {
await fsPromise.unlink(file); // if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) {
} // await fsPromise.unlink(file);
} catch (e) { // }
this.core.context.logger.logError('发送消息删除文件失败', e); // } catch (e) {
} // this.core.context.logger.logError('发送消息删除文件失败', e);
}); // }
await Promise.all(deletePromises); // });
}, 60000); // await Promise.all(deletePromises);
// }, 60000);
} }
} }
@@ -1053,6 +1107,8 @@ export class OneBotMsgApi {
return 'kick'; return 'kick';
case 3: case 3:
return 'kick_me'; return 'kick_me';
case 129:
return 'disband';
default: default:
return 'kick'; return 'kick';
} }
@@ -1213,6 +1269,41 @@ export class OneBotMsgApi {
} else if (SysMessage.contentHead.type == 528 && SysMessage.contentHead.subType == 39 && SysMessage.body?.msgContent) { } else if (SysMessage.contentHead.type == 528 && SysMessage.contentHead.subType == 39 && SysMessage.body?.msgContent) {
return await this.obContext.apis.UserApi.parseLikeEvent(SysMessage.body?.msgContent); return await this.obContext.apis.UserApi.parseLikeEvent(SysMessage.body?.msgContent);
} }
// else if (SysMessage.contentHead.type == 732 && SysMessage.contentHead.subType == 16 && SysMessage.body?.msgContent) {
// let data_wrap = PBString(2);
// let user_wrap = PBUint64(5);
// let group_wrap = PBUint64(4);
// ProtoBuf(class extends ProtoBufBase {
// group = group_wrap;
// content = ProtoBufIn(5, { data: data_wrap, user: user_wrap });
// }).decode(SysMessage.body?.msgContent.slice(7));
// let xml_data = UnWrap(data_wrap);
// let group = UnWrap(group_wrap).toString();
// //let user = UnWrap(user_wrap).toString();
// const parsedParts = this.parseTextWithJson(xml_data);
// //解析JSON
// if (parsedParts[1] && parsedParts[3]) {
// let set_user_id: string = (parsedParts[1].content as { data: string }).data;
// let uid = await this.core.apis.UserApi.getUidByUinV2(set_user_id);
// let new_title: string = (parsedParts[3].content as { text: string }).text;
// console.log(this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle, new_title)
// if (this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle == new_title) {
// return;
// }
// await this.core.apis.GroupApi.refreshGroupMemberCachePartial(group, uid);
// //let json_data_1_url_search = new URL((parsedParts[3].content as { url: string }).url).searchParams;
// //let is_new: boolean = json_data_1_url_search.get('isnew') === '1';
// //console.log(group, set_user_id, is_new, new_title);
// return new GroupMemberTitle(
// this.core,
// +group,
// +set_user_id,
// new_title
// );
// }
// }
return undefined; return undefined;
} }
} }

View File

@@ -1,7 +1,7 @@
import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent';
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me'; export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me' | 'disband';
export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent { export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
notice_type = 'group_decrease'; notice_type = 'group_decrease';
@@ -11,7 +11,7 @@ export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
constructor(core: NapCatCore, groupId: number, userId: number, operatorId: number, subType: GroupDecreaseSubType = 'leave') { constructor(core: NapCatCore, groupId: number, userId: number, operatorId: number, subType: GroupDecreaseSubType = 'leave') {
super(core, groupId, userId); super(core, groupId, userId);
this.group_id = groupId; this.group_id = groupId;
this.operator_id = operatorId; this.operator_id = operatorId;
this.user_id = userId; this.user_id = userId;
this.sub_type = subType; this.sub_type = subType;
} }

View File

@@ -20,16 +20,36 @@ export class OB11Construct {
static friends(friends: FriendV2[]): OB11User[] { static friends(friends: FriendV2[]): OB11User[] {
return friends.map(rawFriend => ({ return friends.map(rawFriend => ({
...rawFriend.baseInfo, birthday_year: rawFriend.baseInfo.birthday_year,
...rawFriend.coreInfo, birthday_month: rawFriend.baseInfo.birthday_month,
birthday_day: rawFriend.baseInfo.birthday_day,
user_id: parseInt(rawFriend.coreInfo.uin), user_id: parseInt(rawFriend.coreInfo.uin),
age: rawFriend.baseInfo.age,
phone_num: rawFriend.baseInfo.phoneNum,
email: rawFriend.baseInfo.eMail,
category_id: rawFriend.baseInfo.categoryId,
nickname: rawFriend.coreInfo.nick ?? '', nickname: rawFriend.coreInfo.nick ?? '',
remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick, remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick,
sex: this.sex(rawFriend.baseInfo.sex), sex: this.sex(rawFriend.baseInfo.sex),
level: 0, level: rawFriend.qqLevel && calcQQLevel(rawFriend.qqLevel) || 0,
})); }));
} }
static friend(friends: FriendV2): OB11User {
return {
birthday_year: friends.baseInfo.birthday_year,
birthday_month: friends.baseInfo.birthday_month,
birthday_day: friends.baseInfo.birthday_day,
user_id: parseInt(friends.coreInfo.uin),
age: friends.baseInfo.age,
phone_num: friends.baseInfo.phoneNum,
email: friends.baseInfo.eMail,
category_id: friends.baseInfo.categoryId,
nickname: friends.coreInfo.nick ?? '',
remark: friends.coreInfo.remark ?? friends.coreInfo.nick,
sex: this.sex(friends.baseInfo.sex),
level: 0,
};
}
static groupMemberRole(role: number): OB11GroupMemberRole | undefined { static groupMemberRole(role: number): OB11GroupMemberRole | undefined {
return { return {
4: OB11GroupMemberRole.owner, 4: OB11GroupMemberRole.owner,
@@ -73,6 +93,8 @@ export class OB11Construct {
static group(group: Group): OB11Group { static group(group: Group): OB11Group {
return { return {
group_all_shut: (+group.groupShutupExpireTime > 0 )? -1 : 0,
group_remark: group.remarkName,
group_id: +group.groupCode, group_id: +group.groupCode,
group_name: group.groupName, group_name: group.groupName,
member_count: group.memberCount, member_count: group.memberCount,

View File

@@ -50,6 +50,7 @@ import {
import { OB11Message } from './types'; import { OB11Message } from './types';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter'; import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import { OB11HttpSSEServerAdapter } from './network/http-server-sse'; import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
import { OB11PluginAdapter } from './network/plugin';
//OneBot实现类 //OneBot实现类
export class NapCatOneBot11Adapter { export class NapCatOneBot11Adapter {
@@ -100,7 +101,7 @@ export class NapCatOneBot11Adapter {
const selfInfo = this.core.selfInfo; const selfInfo = this.core.selfInfo;
const ob11Config = this.configLoader.configData; const ob11Config = this.configLoader.configData;
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid) this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false)
.then((user) => { .then((user) => {
selfInfo.nick = user.nick; selfInfo.nick = user.nick;
this.context.logger.setLogSelfInfo(selfInfo); this.context.logger.setLogSelfInfo(selfInfo);
@@ -113,9 +114,9 @@ export class NapCatOneBot11Adapter {
//创建NetWork服务 //创建NetWork服务
// 注册Plugin 如果需要基于NapCat进行快速开发 // 注册Plugin 如果需要基于NapCat进行快速开发
// this.networkManager.registerAdapter( this.networkManager.registerAdapter(
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions) new OB11PluginAdapter('myPlugin', this.core, this, this.actions)
// ); );
for (const key of ob11Config.network.httpServers) { for (const key of ob11Config.network.httpServers) {
if (key.enable) { if (key.enable) {
this.networkManager.registerAdapter( this.networkManager.registerAdapter(

View File

@@ -15,14 +15,14 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
messagePostFormat: 'array', messagePostFormat: 'array',
reportSelfMessage: false, reportSelfMessage: false,
enable: true, enable: true,
debug: false, debug: true,
}; };
super(name, config, core, obContext, actions); super(name, config, core, obContext, actions);
} }
onEvent<T extends OB11EmitEventContent>(event: T) { onEvent<T extends OB11EmitEventContent>(event: T) {
if (event.post_type === 'message') { if (event.post_type === 'message') {
plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch(); plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch(console.log);
} }
} }

View File

@@ -37,7 +37,13 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
}, this.config.heartInterval); }, this.config.heartInterval);
} }
this.isEnable = true; this.isEnable = true;
await this.tryConnect(); try {
await this.tryConnect();
} catch (error) {
this.logger.logError('[OneBot] [WebSocket Client] TryConnect Error , info -> ', error);
}
} }
close() { close() {

View File

@@ -1,4 +1,10 @@
export interface OB11User { export interface OB11User {
birthday_year?: number; // 生日
birthday_month?: number; // 生日
birthday_day?: number; // 生日
phone_num?: string; // 手机号
email?: string; // 邮箱
category_id?: number; // 分组ID
user_id: number; // 用户ID user_id: number; // 用户ID
nickname: string; // 昵称 nickname: string; // 昵称
remark?: string; // 备注 remark?: string; // 备注
@@ -57,6 +63,8 @@ export interface OB11GroupMember {
} }
export interface OB11Group { export interface OB11Group {
group_all_shut: number; // 群全员禁言
group_remark: string; // 群备注
group_id: number; // 群ID group_id: number; // 群ID
group_name: string; // 群名称 group_name: string; // 群名称
member_count?: number; // 成员数量 member_count?: number; // 成员数量

View File

@@ -180,7 +180,7 @@ export interface OB11MessageNode {
id?: string; id?: string;
user_id?: number | string; // number user_id?: number | string; // number
uin?: number | string; // number, compatible with go-cqhttp uin?: number | string; // number, compatible with go-cqhttp
nickname: string; nickname?: string;
name?: string; // compatible with go-cqhttp name?: string; // compatible with go-cqhttp
content: OB11MessageMixType; content: OB11MessageMixType;
source?: string; source?: string;

4
src/plugin/data.ts Normal file
View File

@@ -0,0 +1,4 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
export let current_path = dirname(fileURLToPath(import.meta.url));

319
src/plugin/drawTime.ts Normal file
View File

@@ -0,0 +1,319 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import path from "path";
import { current_path } from "./data";
/**
* 绘制时间模式匹配的可视化图表
* @param data 需要绘制的数据和配置
* @returns Base64编码的图片
*/
export async function drawTimePattern(data: {
targetUser: string,
matchedUsers: Array<{
username: string,
similarity: number,
pattern: Map<string, number>
}>,
targetPattern: Map<string, number>,
timeRange: string
}) {
// 计算需要的画布高度,根据匹配用户数量可能需要更多空间
const legendRowHeight = 30; // 每行图例的高度,增加一点空间
const legendRows = Math.ceil(data.matchedUsers.length / 2) + 1; // 目标用户一行,其他匹配用户每两个一行
// 画布基础配置
const padding = 50;
const titleHeight = 80;
const hourChartHeight = 250;
const weekdayChartHeight = 180;
const legendTitleHeight = 40;
// 计算图例总高度,确保足够空间
const legendHeight = legendRows * legendRowHeight + legendTitleHeight;
// 计算所需的总高度
const requiredHeight = titleHeight + hourChartHeight + 60 + weekdayChartHeight + 40 + legendHeight + padding;
// 设置画布尺寸,确保足够显示所有内容
const width = 1000;
const height = requiredHeight + padding; // 确保底部有足够的padding
// 创建画布
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// 加载背景图
const backgroundImage = await loadImage(path.join(current_path,'./fonts/post.jpg'));
const pattern = ctx.createPattern(backgroundImage, 'repeat');
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, width, height);
// 应用模糊效果
ctx.filter = 'blur(5px)';
ctx.drawImage(canvas, 0, 0);
ctx.filter = 'none';
// 绘制半透明白色背景卡片
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
const radius = 20;
const cardWidth = width - padding * 2;
const cardHeight = height - padding * 2;
const cardX = padding;
const cardY = padding;
// 绘制圆角矩形
ctx.beginPath();
ctx.moveTo(cardX + radius, cardY);
ctx.lineTo(cardX + cardWidth - radius, cardY);
ctx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius);
ctx.lineTo(cardX + cardWidth, cardY + cardHeight - radius);
ctx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight);
ctx.lineTo(cardX + radius, cardY + cardHeight);
ctx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius);
ctx.lineTo(cardX, cardY + radius);
ctx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY);
ctx.closePath();
ctx.fill();
// 设置标题
ctx.fillStyle = '#333';
ctx.font = 'bold 24px "Aa偷吃可爱长大的"';
ctx.textAlign = 'center';
ctx.fillText(`${data.targetUser} ${data.timeRange}聊天时间匹配分析`, width / 2, cardY + 35);
// 绘制小时分布图表
const hourChartTop = cardY + titleHeight;
const hourChartBottom = hourChartTop + hourChartHeight;
const hourChartLeft = cardX + 40;
const hourChartRight = cardX + cardWidth - 40;
const hourChartWidth = hourChartRight - hourChartLeft;
// 绘制小时图表标题
ctx.font = 'bold 18px "Aa偷吃可爱长大的"';
ctx.fillText('每日小时活跃度分布', width / 2, hourChartTop - 10);
// 绘制小时图表横坐标
ctx.fillStyle = '#666';
ctx.font = '14px "JetBrains Mono"';
ctx.textAlign = 'center';
for (let hour = 0; hour < 24; hour += 2) {
const x = hourChartLeft + (hour / 24) * hourChartWidth;
ctx.fillText(`${hour}`, x, hourChartBottom + 20);
}
ctx.textAlign = 'left';
ctx.font = '14px "Aa偷吃可爱长大的"';
ctx.fillText('时间(小时)', hourChartLeft, hourChartBottom + 40);
ctx.font = '14px "JetBrains Mono"';
// 绘制小时图表网格线
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 0.5;
for (let hour = 0; hour < 24; hour += 2) {
const x = hourChartLeft + (hour / 24) * hourChartWidth;
ctx.beginPath();
ctx.moveTo(x, hourChartTop);
ctx.lineTo(x, hourChartBottom);
ctx.stroke();
}
// 确定最大活跃度值,用于缩放
let maxHourValue = 0;
for (let hour = 0; hour < 24; hour++) {
const targetValue = data.targetPattern.get(`hour_${hour}`) || 0;
if (targetValue > maxHourValue) maxHourValue = targetValue;
for (const match of data.matchedUsers) {
const matchValue = match.pattern.get(`hour_${hour}`) || 0;
if (matchValue > maxHourValue) maxHourValue = matchValue;
}
}
// 为了美观,确保最大值不会让图表太扁
maxHourValue = Math.max(maxHourValue, 0.15);
// 绘制目标用户小时分布曲线
ctx.strokeStyle = '#e74c3c';
ctx.lineWidth = 3;
ctx.beginPath();
for (let hour = 0; hour < 24; hour++) {
const x = hourChartLeft + (hour / 24) * hourChartWidth;
const value = data.targetPattern.get(`hour_${hour}`) || 0;
const y = hourChartBottom - (value / maxHourValue) * (hourChartHeight - 30);
if (hour === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
// 绘制匹配用户小时分布曲线
const colors = ['#3498db', '#2ecc71', '#9b59b6', '#f1c40f', '#1abc9c'];
data.matchedUsers.forEach((match, index) => {
const colorIndex = index % colors.length;
ctx.strokeStyle = colors[colorIndex]!;
ctx.lineWidth = 2;
ctx.beginPath();
for (let hour = 0; hour < 24; hour++) {
const x = hourChartLeft + (hour / 24) * hourChartWidth;
const value = match.pattern.get(`hour_${hour}`) || 0;
const y = hourChartBottom - (value / maxHourValue) * (hourChartHeight - 30);
if (hour === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
});
// 绘制星期分布图表
const weekChartTop = hourChartBottom + 60;
const weekChartBottom = weekChartTop + weekdayChartHeight;
const weekChartLeft = hourChartLeft;
const weekChartRight = hourChartRight;
const weekChartWidth = weekChartRight - weekChartLeft;
// 绘制星期图表标题
ctx.fillStyle = '#333';
ctx.font = 'bold 18px "Aa偷吃可爱长大的"';
ctx.textAlign = 'center';
ctx.fillText('星期活跃度分布', width / 2, weekChartTop - 10);
// 绘制星期图表横坐标
ctx.fillStyle = '#666';
ctx.font = '14px "Aa偷吃可爱长大的"';
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
for (let day = 0; day < 7; day++) {
const x = weekChartLeft + (day / 7) * weekChartWidth + (weekChartWidth / 14);
ctx.fillText(weekdays[day]!, x, weekChartBottom + 20);
}
// 绘制星期图表网格线
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 0.5;
for (let day = 0; day <= 7; day++) {
const x = weekChartLeft + (day / 7) * weekChartWidth;
ctx.beginPath();
ctx.moveTo(x, weekChartTop);
ctx.lineTo(x, weekChartBottom);
ctx.stroke();
}
// 确定最大活跃度值,用于缩放
let maxDayValue = 0;
for (let day = 0; day < 7; day++) {
const targetValue = data.targetPattern.get(`day_${day}`) || 0;
if (targetValue > maxDayValue) maxDayValue = targetValue;
for (const match of data.matchedUsers) {
const matchValue = match.pattern.get(`day_${day}`) || 0;
if (matchValue > maxDayValue) maxDayValue = matchValue;
}
}
// 为了美观,确保最大值不会让图表太扁
maxDayValue = Math.max(maxDayValue, 0.3);
// 改进柱状图绘制逻辑,避免重叠
const totalUsers = data.matchedUsers.length + 1; // 包括目标用户
const dayWidth = weekChartWidth / 7; // 每天的总宽度
// 计算单个柱状图宽度,确保有足够间距
const barWidth = dayWidth * 0.7 / totalUsers; // 每个柱子的宽度
const groupPadding = dayWidth * 0.15; // 不同天之间的组间距
const barPadding = dayWidth * 0.15 / (totalUsers + 1); // 同一天内柱子之间的间距
// 绘制所有用户的星期分布(包括目标用户和匹配用户)
const allUsers = [
{ username: data.targetUser, pattern: data.targetPattern, color: '#e74c3c', isTarget: true }
];
data.matchedUsers.forEach((match, idx) => {
allUsers.push({
username: match.username,
pattern: match.pattern,
color: colors[idx % colors.length] || '#3498db',
isTarget: false
});
});
// 统一绘制所有用户的柱状图
allUsers.forEach((user, userIndex) => {
ctx.fillStyle = user.color;
for (let day = 0; day < 7; day++) {
const value = user.pattern.get(`day_${day}`) || 0;
const barHeight = (value / maxDayValue) * (weekdayChartHeight - 30);
// 计算柱子的位置,确保均匀分布
const startX = weekChartLeft + day * dayWidth + groupPadding / 2;
const x = startX + barPadding * (userIndex + 1) + barWidth * userIndex;
const y = weekChartBottom - barHeight;
// 绘制柱子
ctx.fillRect(x, y, barWidth, barHeight);
}
});
// 绘制图例
let legendTop = weekChartBottom + 50; // 增加与上方图表的间距
ctx.textAlign = 'left';
ctx.font = '14px "Aa偷吃可爱长大的"';
// 绘制图例标题
ctx.fillStyle = '#333';
ctx.font = 'bold 18px "Aa偷吃可爱长大的"';
ctx.textAlign = 'center';
ctx.fillText('图例说明', width / 2, legendTop);
ctx.font = '14px "Aa偷吃可爱长大的"';
ctx.textAlign = 'left';
// 计算图例开始位置和每列宽度
const legendStartX = hourChartLeft;
const legendColumnWidth = Math.min(450, (cardWidth - 80) / 2); // 确保在宽度有限时也能正常显示
const legendsPerRow = 2; // 每行最多2个图例
// 目标用户图例 - 单独一行
legendTop += 25; // 图例标题与第一个图例的间距
ctx.fillStyle = '#e74c3c';
ctx.fillRect(legendStartX, legendTop, 20, 10);
ctx.fillStyle = '#333';
ctx.fillText(data.targetUser + " (目标用户)", legendStartX + 30, legendTop + 10);
// 匹配用户图例 - 每行最多2个用户
legendTop += legendRowHeight; // 进入下一行
data.matchedUsers.forEach((match, index) => {
const colorIndex = index % colors.length;
const row = Math.floor(index / legendsPerRow);
const col = index % legendsPerRow;
const x = legendStartX + col * legendColumnWidth;
const y = legendTop + row * legendRowHeight;
// 确保没有超出画布范围
if (y + 20 <= cardY + cardHeight - padding / 2) {
ctx.fillStyle = colors[colorIndex]!;
ctx.fillRect(x, y, 20, 10);
ctx.fillStyle = '#333';
const similarity = (match.similarity * 100).toFixed(1);
// 测量文本长度,确保不超出列宽
const text = `${match.username} (${similarity}% 匹配)`;
const metrics = ctx.measureText(text);
if (metrics.width > legendColumnWidth - 40) {
// 如果文本过长,缩短显示
const shortUsername = match.username.length > 10 ?
match.username.substring(0, 10) + "..." :
match.username;
ctx.fillText(`${shortUsername} (${similarity}% 匹配)`, x + 30, y + 10);
} else {
ctx.fillText(text, x + 30, y + 10);
}
}
});
// 保存图像
const buffer = canvas.toBuffer('image/png');
return "base64://" + buffer.toString('base64');
}

File diff suppressed because it is too large Load Diff

432
src/plugin/network.ts Normal file
View File

@@ -0,0 +1,432 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import path from "path";
import { current_path } from "./data";
interface NetworkNode {
id: string;
label: string;
value: number;
}
interface NetworkEdge {
from: string;
to: string;
value: number;
}
interface NetworkData {
nodes: NetworkNode[];
edges: NetworkEdge[];
title: string;
}
export async function drawWordNetwork(data: NetworkData): Promise<string> {
// 根据节点数量动态调整画布尺寸
const nodeCount = data.nodes.length;
const baseWidth = 1000;
const baseHeight = 800;
// 根据节点数量计算合适的尺寸
const width = Math.max(baseWidth, Math.min(2000, baseWidth + (nodeCount - 10) * 30));
const height = Math.max(baseHeight, Math.min(1500, baseHeight + (nodeCount - 10) * 25));
// 根据画布大小调整边距
const padding = Math.max(60, Math.min(100, 60 + nodeCount / 20));
const centerX = width / 2;
const centerY = height / 2;
// 创建画布
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// 绘制背景
try {
const backgroundImage = await loadImage(path.join(current_path, '.\\fonts\\post.jpg'));
const pattern = ctx.createPattern(backgroundImage, 'repeat');
if (pattern) {
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, width, height);
// 添加模糊效果
ctx.filter = 'blur(5px)';
ctx.drawImage(canvas, 0, 0);
ctx.filter = 'none';
}
} catch (e) {
// 如果背景图加载失败,使用纯色背景
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, width, height);
}
// 绘制半透明卡片背景
const cardWidth = width - padding * 2;
const cardHeight = height - padding * 2;
const cardX = padding;
const cardY = padding;
const radius = 20;
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.beginPath();
ctx.moveTo(cardX + radius, cardY);
ctx.lineTo(cardX + cardWidth - radius, cardY);
ctx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius);
ctx.lineTo(cardX + cardWidth, cardY + cardHeight - radius);
ctx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight);
ctx.lineTo(cardX + radius, cardY + cardHeight);
ctx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius);
ctx.lineTo(cardX, cardY + radius);
ctx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY);
ctx.closePath();
ctx.fill();
// 绘制标题
ctx.fillStyle = '#333';
ctx.font = '28px "Aa偷吃可爱长大的"';
ctx.textAlign = 'center';
ctx.fillText(data.title, centerX, cardY + 40);
// 计算节点位置 (使用简化版力导向算法)
const nodePositions = new Map<string, { x: number, y: number }>();
const radiusScale = 15; // 基础节点半径
const maxRadius = 40; // 最大节点半径
// 找出最大频率值用于缩放
const maxValue = Math.max(...data.nodes.map(n => n.value));
// 计算每个节点的实际半径
const nodeRadiusMap = new Map<string, number>();
for (const node of data.nodes) {
const radius = Math.min(maxRadius, radiusScale + (node.value / maxValue) * 25);
nodeRadiusMap.set(node.id, radius);
}
// 节点重叠标记系统 - 跟踪哪些节点存在重叠
const overlapTracker = new Map<string, Set<string>>();
for (const node of data.nodes) {
overlapTracker.set(node.id, new Set<string>());
}
// 根据画布尺寸调整初始分布范围 - 增加分布范围
const initialRadius = Math.min(cardWidth, cardHeight) * 0.4;
// 对节点按大小排序,确保大节点先放置
const sortedNodes = [...data.nodes].sort((a, b) => b.value - a.value);
// 初始化随机位置 - 改进的空间分配策略
for (let i = 0; i < sortedNodes.length; i++) {
const node = sortedNodes[i];
if (!node) continue; // 防止空节点
const nodeRadius = nodeRadiusMap.get(node.id)!;
// 使用黄金角度法进行更均匀的分布
const goldenAngle = Math.PI * (3 - Math.sqrt(5)); // 黄金角
const angle = i * goldenAngle;
// 根据节点大小调整距离
const sizeFactor = 1 + (nodeRadius / maxRadius) * 0.5; // 大节点获得更远的初始距离
const distance = initialRadius * (0.4 + 0.6 * Math.random()) * sizeFactor;
nodePositions.set(node.id, {
x: centerX + Math.cos(angle) * distance,
y: centerY + Math.sin(angle) * distance
});
}
// 根据节点数量调整迭代次数 - 增加迭代次数确保充分布局
const iterations = Math.max(40, Math.min(80, 40 + nodeCount));
// 模拟物理力学
for (let iteration = 0; iteration < iterations; iteration++) {
// 冷却因子 - 调整冷却曲线以减缓冷却速度
const temperatureFactor = 1 - Math.pow(iteration / iterations, 1.5) * 0.8;
// 清除重叠标记
for (const nodeId of overlapTracker.keys()) {
overlapTracker.get(nodeId)!.clear();
}
// 斥力 (所有节点相互排斥)
for (let i = 0; i < data.nodes.length; i++) {
const node1 = data.nodes[i];
if (!node1) continue; // 防止空节点
const pos1 = nodePositions.get(node1.id)!;
const radius1 = nodeRadiusMap.get(node1.id)!;
for (let j = i + 1; j < data.nodes.length; j++) {
const node2 = data.nodes[j];
if (!node2) continue; // 防止空节点
const pos2 = nodePositions.get(node2.id)!;
const radius2 = nodeRadiusMap.get(node2.id)!;
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// 根据节点实际大小计算最小距离
const minDistance = radius1 + radius2 + 40; // 增加最小间隙
// 检测并标记重叠
if (distance < minDistance) {
overlapTracker.get(node1.id)!.add(node2.id);
overlapTracker.get(node2.id)!.add(node1.id);
}
// 对所有节点应用基础斥力
const repulsionStrength = 1200 * temperatureFactor; // 增强基础斥力
if (distance > 0) {
// 使用反比平方斥力,但对近距离节点增加额外斥力
const proximityFactor = Math.pow(Math.max(0, 1 - distance / (minDistance * 2)), 2) * 3 + 1;
const force = Math.min(8, repulsionStrength / (distance * distance)) * proximityFactor;
// 根据节点大小调整斥力
const sizeFactor = (radius1 + radius2) / (radiusScale * 2);
const adjustedForce = force * Math.sqrt(sizeFactor);
const moveX = (dx / distance) * adjustedForce;
const moveY = (dy / distance) * adjustedForce;
pos1.x -= moveX;
pos1.y -= moveY;
pos2.x += moveX;
pos2.y += moveY;
}
// 如果距离小于最小距离,增加强制分离力
if (distance < minDistance) {
// 计算重叠度
const overlapRatio = (minDistance - distance) / minDistance;
// 计算分离力 - 重叠程度越高,力越大
// 在迭代后期增加分离力
const lateStageFactor = 1 + Math.max(0, (iteration - iterations * 0.6) / (iterations * 0.4)) * 2;
const separationForce = overlapRatio * 0.8 * temperatureFactor * lateStageFactor;
pos1.x -= dx * separationForce;
pos1.y -= dy * separationForce;
pos2.x += dx * separationForce;
pos2.y += dy * separationForce;
// 额外的扭矩力,帮助节点绕过彼此
if (overlapRatio > 0.5 && iteration > iterations * 0.3) {
// 计算垂直于连线的方向
const perpX = -dy / distance;
const perpY = dx / distance;
// 随机选择扭矩方向
const sign = Math.random() > 0.5 ? 1 : -1;
const torqueFactor = 0.2 * overlapRatio * temperatureFactor;
pos1.x += perpX * torqueFactor * sign;
pos1.y += perpY * torqueFactor * sign;
pos2.x -= perpX * torqueFactor * sign;
pos2.y -= perpY * torqueFactor * sign;
}
}
}
}
// 引力 (有连接的节点相互吸引) - 优化以避免过度聚集
for (const edge of data.edges) {
const pos1 = nodePositions.get(edge.from)!;
const pos2 = nodePositions.get(edge.to)!;
const radius1 = nodeRadiusMap.get(edge.from)!;
const radius2 = nodeRadiusMap.get(edge.to)!;
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// 根据边权值和节点大小调整引力
const baseStrength = Math.min(0.015, edge.value / 200);
const strength = baseStrength * temperatureFactor;
// 根据节点大小动态调整最佳距离
const minNodeDistance = radius1 + radius2 + 40;
const optimalDistance = minNodeDistance + 60 + edge.value * 0.5;
if (distance > optimalDistance) {
// 如果节点距离过远,应用引力
const attractionForce = strength * Math.min(1, (distance - optimalDistance) / optimalDistance);
pos1.x += dx * attractionForce;
pos1.y += dy * attractionForce;
pos2.x -= dx * attractionForce;
pos2.y -= dy * attractionForce;
} else if (distance < minNodeDistance) {
// 如果节点距离过近,应用斥力
const repulsionForce = 0.05 * temperatureFactor * (minNodeDistance - distance) / minNodeDistance;
pos1.x -= dx * repulsionForce;
pos1.y -= dy * repulsionForce;
pos2.x += dx * repulsionForce;
pos2.y += dy * repulsionForce;
}
}
// 中心引力 - 防止节点飞得太远
const availableArea = Math.min(cardWidth, cardHeight) * 0.45; // 增加有效区域
for (const node of data.nodes) {
const pos = nodePositions.get(node.id)!;
const dx = centerX - pos.x;
const dy = centerY - pos.y;
const distanceFromCenter = Math.sqrt(dx * dx + dy * dy);
// 根据与中心距离施加引力
if (distanceFromCenter > availableArea) {
const centerForce = 0.01 * temperatureFactor *
Math.pow((distanceFromCenter - availableArea) / availableArea, 1.2);
pos.x += dx * centerForce;
pos.y += dy * centerForce;
}
}
// 确保节点不会超出边界
for (const node of data.nodes) {
const pos = nodePositions.get(node.id)!;
const radius = nodeRadiusMap.get(node.id)!;
const margin = radius + 20; // 考虑节点实际大小的边距
pos.x = Math.max(cardX + margin, Math.min(cardX + cardWidth - margin, pos.x));
pos.y = Math.max(cardY + margin, Math.min(cardY + cardHeight - margin, pos.y));
}
// 重叠度计算 - 统计当前总重叠数量
let totalOverlaps = 0;
for (const overlaps of overlapTracker.values()) {
totalOverlaps += overlaps.size;
}
// 如果迭代已进行了3/4以上且没有重叠可以提前结束
if (iteration > iterations * 0.75 && totalOverlaps === 0) {
break;
}
}
// 最终重叠消除阶段 - 专门解决残余重叠问题
for (let i = 0; i < 15; i++) {
let overlapsFixed = 0;
for (let j = 0; j < data.nodes.length; j++) {
const node1 = data.nodes[j];
if (!node1) continue; // 防止空节点
const pos1 = nodePositions.get(node1.id)!;
const radius1 = nodeRadiusMap.get(node1.id)!;
for (let k = j + 1; k < data.nodes.length; k++) {
const node2 = data.nodes[k];
if (!node2) continue; // 防止空节点
const pos2 = nodePositions.get(node2.id)!;
const radius2 = nodeRadiusMap.get(node2.id)!;
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const minDistance = radius1 + radius2 + 40;
if (distance < minDistance) {
// 计算需要移动的距离
const moveDistance = (minDistance - distance) / 2 + 1;
const moveX = (dx / distance) * moveDistance;
const moveY = (dy / distance) * moveDistance;
// 应用移动
pos1.x -= moveX;
pos1.y -= moveY;
pos2.x += moveX;
pos2.y += moveY;
// 施加小的随机扰动以打破对称性
const jitter = 1;
pos1.x += (Math.random() - 0.5) * jitter;
pos1.y += (Math.random() - 0.5) * jitter;
pos2.x += (Math.random() - 0.5) * jitter;
pos2.y += (Math.random() - 0.5) * jitter;
overlapsFixed++;
}
}
// 确保节点不会超出边界
const radius = nodeRadiusMap.get(node1.id)!;
const margin = radius + 20;
pos1.x = Math.max(cardX + margin, Math.min(cardX + cardWidth - margin, pos1.x));
pos1.y = Math.max(cardY + margin, Math.min(cardY + cardHeight - margin, pos1.y));
}
// 如果没有重叠了,提前退出
if (overlapsFixed === 0) break;
}
// 绘制边 - 改进边的视觉效果
for (const edge of data.edges) {
const pos1 = nodePositions.get(edge.from)!;
const pos2 = nodePositions.get(edge.to)!;
const radius1 = nodeRadiusMap.get(edge.from)!;
const radius2 = nodeRadiusMap.get(edge.to)!;
// 计算边的实际起止点,从节点边缘开始而不是中心
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// 计算实际的起点和终点,从节点边缘开始
const startX = pos1.x + (dx / distance) * radius1;
const startY = pos1.y + (dy / distance) * radius1;
const endX = pos2.x - (dx / distance) * radius2;
const endY = pos2.y - (dy / distance) * radius2;
// 根据权重确定线宽
const lineWidth = Math.max(1, Math.min(5, edge.value / 10));
// 计算透明度 (权重越高透明度越低)
const alpha = Math.min(0.7, 0.2 + edge.value / 20);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = `rgba(100, 100, 255, ${alpha})`;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// 绘制节点
for (const node of data.nodes) {
const pos = nodePositions.get(node.id)!;
// 使用预计算的节点半径
const radius = nodeRadiusMap.get(node.id)!;
// 绘制节点圆形
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 100, 100, 0.8)`;
ctx.fill();
// 绘制边框
ctx.strokeStyle = '#800000';
ctx.lineWidth = 2;
ctx.stroke();
// 根据节点大小调整字体大小
const fontSize = Math.max(14, Math.min(18, 14 + (node.value / maxValue) * 6));
// 绘制文本
ctx.fillStyle = '#000';
ctx.font = `${fontSize}px "Aa偷吃可爱长大的"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(node.label, pos.x, pos.y);
}
// 绘制图例
ctx.fillStyle = '#333';
ctx.font = '18px "Aa偷吃可爱长大的"';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('词频越高,节点越大', cardX + 20, cardY + 20);
ctx.fillText('关联越强,连线越粗', cardX + 20, cardY + 50);
// 保存图像
const buffer = canvas.toBuffer('image/png');
return "base64://" + buffer.toString('base64');
}

952
src/plugin/wordcloud.ts Normal file
View File

@@ -0,0 +1,952 @@
import { createCanvas } from '@napi-rs/canvas';
interface WordFrequency {
word: string;
frequency: number;
}
interface Position {
x: number;
y: number;
width: number;
height: number;
rotation: number;
fontSize: number;
}
/**
* 根据词频生成词云图片
* @param wordFrequencies 词频数组,包含单词和对应的频率
* @param initialWidth 初始画布宽度(最终会自动调整)
* @param initialHeight 初始画布高度(最终会自动调整)
* @param options 词云配置选项
* @returns 图片的base64编码字符串
*/
export async function generateWordCloud(
wordFrequencies: WordFrequency[],
initialWidth = 1000,
initialHeight = 800,
options = {
backgroundColor: 'white',
enableRotation: true,
maxAttempts: 60, // 每个词的最大尝试次数
minFontSize: 20, // 最小字体大小
maxFontSize: 100, // 最大字体大小
padding: 20, // 降低内边距提高紧凑度
horizontalWeight: 0.6, // 提高横排权重增强可读性
rotationVariance: 10, // 减少旋转角度变化
safetyMargin: 6, // 减小安全距离以提高密度
fontSizeRatio: 2.0, // 字体大小差异
overlapThreshold: 0.10, // 允许10%的重叠
maxExpansionAttempts: 10, // 最大画布扩展次数
expansionRatio: 1.15 // 降低每次扩展比例
}
): Promise<string> {
// 空数组检查
if (wordFrequencies.length === 0) {
const emptyCanvas = createCanvas(initialWidth, initialHeight);
const ctx = emptyCanvas.getContext('2d');
ctx.fillStyle = options.backgroundColor;
ctx.fillRect(0, 0, initialWidth, initialHeight);
return "base64://" + emptyCanvas.toBuffer('image/png').toString('base64');
}
// 过滤不可渲染字符 - 增强过滤能力
const filteredWordFrequencies = wordFrequencies.map(item => ({
...item,
word: filterUnrenderableChars(item.word)
})).filter(item => item.word.length > 0);
// 再次检查过滤后是否为空
if (filteredWordFrequencies.length === 0) {
const emptyCanvas = createCanvas(initialWidth, initialHeight);
const ctx = emptyCanvas.getContext('2d');
ctx.fillStyle = options.backgroundColor;
ctx.fillRect(0, 0, initialWidth, initialHeight);
return "base64://" + emptyCanvas.toBuffer('image/png').toString('base64');
}
// 对词频进行排序,频率高的先绘制
const sortedWords = [...filteredWordFrequencies].sort((a, b) => b.frequency - a.frequency);
// 计算最小和最大频率,用于字体大小缩放
const maxFreq = sortedWords[0]?.frequency || 1;
const minFreq = sortedWords[sortedWords.length - 1]?.frequency || 1;
const freqRange = Math.max(1, maxFreq - minFreq); // 避免除以零
// 检查字符类型
const isChineseChar = (char: string): boolean => /[\u4e00-\u9fa5]/.test(char);
const isEnglishChar = (char: string): boolean => /[a-zA-Z0-9]/.test(char);
// 判断单词类型(中文、英文或混合)
const getWordType = (word: string): 'chinese' | 'english' | 'mixed' => {
let hasChinese = false;
let hasEnglish = false;
for (const char of word) {
if (isChineseChar(char)) hasChinese = true;
else if (isEnglishChar(char)) hasEnglish = true;
if (hasChinese && hasEnglish) return 'mixed';
}
return hasChinese ? 'chinese' : 'english';
};
// 获取适合单词的字体
const getFontFamily = (word: string): string => {
const wordType = getWordType(word);
if (wordType === 'chinese') return '"Aa偷吃可爱长大的", sans-serif';
if (wordType === 'english') return '"JetBrains Mono", monospace';
return '"Aa偷吃可爱长大的", "JetBrains Mono", sans-serif'; // 混合类型
};
// 增强的字体大小计算函数,保持高频词更大但减小差距
const calculateFontSize = (frequency: number, index: number): number => {
// 基本的频率比例
const frequencyRatio = (frequency - minFreq) / freqRange;
// 根据词云大小调整差异系数
const smallCloudFactor = sortedWords.length < 15 ? 1.5 : 1.0; // 小词云时增大差异
// 应用非线性映射,使高频词更大但差距不过大
let sizeRatio;
if (index === 0) {
// 最高频词
sizeRatio = Math.pow(frequencyRatio, 0.3) * 2.2 * smallCloudFactor;
} else if (index < sortedWords.length * 0.05) {
// 前5%的高频词
sizeRatio = Math.pow(frequencyRatio, 0.4) * 1.8 * smallCloudFactor;
} else if (index < sortedWords.length * 0.15) {
// 前15%的高频词
sizeRatio = Math.pow(frequencyRatio, 0.5) * 1.5 * smallCloudFactor;
} else if (index < sortedWords.length * 0.3) {
// 前30%的高频词
sizeRatio = Math.pow(frequencyRatio, 0.6) * 1.3 * smallCloudFactor;
} else {
// 其余的词
sizeRatio = Math.pow(frequencyRatio, 0.7) * 1.0 * smallCloudFactor;
}
// 应用配置的字体大小比例系数
sizeRatio *= options.fontSizeRatio;
// 计算最终字体大小
return Math.max(
options.minFontSize,
Math.min(
options.maxFontSize,
Math.floor(options.minFontSize + sizeRatio * (options.maxFontSize - options.minFontSize))
)
);
};
// 获取基于词频的颜色
const getColorFromFrequency = (frequency: number, index: number): string => {
// 使用词频和索引生成不同的色相值
const hue = (index * 137.5) % 360; // 黄金角分布
// 重要词使用更醒目的颜色
let saturation, lightness;
if (index === 0) {
// 最高频词
saturation = 95;
lightness = 45;
} else if (index < sortedWords.length * 0.1) {
// 前10%的高频词
saturation = 90;
lightness = 45;
} else {
// 降低其他词的饱和度,增加整体和谐性
saturation = 75 + (Math.max(0.3, frequency / maxFreq) * 15);
lightness = 40 + (Math.max(0.3, frequency / maxFreq) * 15);
}
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
// 临时画布用于测量文本
let tempCanvas = createCanvas(initialWidth, initialHeight);
let tempCtx = tempCanvas.getContext('2d');
// 已确定位置的单词数组
const placedWords: Position[] = [];
// 根据旋转角度计算包围盒(用于碰撞检测)
const getRotatedBoundingBox = (x: number, y: number, width: number, height: number, rotation: number) => {
// 转换角度为弧度
const rad = rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// 计算四个角的坐标
const corners = [
{ x: -width / 2, y: -height / 2 },
{ x: width / 2, y: -height / 2 },
{ x: width / 2, y: height / 2 },
{ x: -width / 2, y: height / 2 }
].map(pt => {
return {
x: x + width / 2 + (pt.x * cos - pt.y * sin),
y: y + height / 2 + (pt.x * sin + pt.y * cos)
};
});
// 计算包围盒
const boxMinX = Math.min(...corners.map(c => c.x));
const boxMaxX = Math.max(...corners.map(c => c.x));
const boxMinY = Math.min(...corners.map(c => c.y));
const boxMaxY = Math.max(...corners.map(c => c.y));
return { minX: boxMinX, maxX: boxMaxX, minY: boxMinY, maxY: boxMaxY };
};
// 精确重叠检测 - 允许适度重叠,并考虑词的重要性
const isOverlapping = (x: number, y: number, width: number, height: number, rotation: number, index: number): boolean => {
// 获取当前词的包围盒
const currentBox = getRotatedBoundingBox(x, y, width, height, rotation);
// 为边缘增加安全距离,根据重要性调整
const safetyMargin = index < sortedWords.length * 0.05 ?
options.safetyMargin * 1.2 : options.safetyMargin * 0.9;
const safetyBox = {
minX: currentBox.minX - safetyMargin,
maxX: currentBox.maxX + safetyMargin,
minY: currentBox.minY - safetyMargin,
maxY: currentBox.maxY + safetyMargin
};
// 计算当前单词的面积
const currentArea = (safetyBox.maxX - safetyBox.minX) * (safetyBox.maxY - safetyBox.minY);
// 为高频词设置更严格的重叠阈值
const overlapThreshold = index < sortedWords.length * 0.1 ?
options.overlapThreshold * 0.6 : options.overlapThreshold;
// 检查是否与已放置的词重叠超过阈值
for (const pos of placedWords) {
const posBox = getRotatedBoundingBox(
pos.x, pos.y, pos.width, pos.height, pos.rotation
);
// 计算重叠区域
const overlapX = Math.max(0, Math.min(safetyBox.maxX, posBox.maxX) - Math.max(safetyBox.minX, posBox.minX));
const overlapY = Math.max(0, Math.min(safetyBox.maxY, posBox.maxY) - Math.max(safetyBox.minY, posBox.minY));
const overlapArea = overlapX * overlapY;
// 计算重叠率
const overlapRatio = overlapArea / currentArea;
// 如果重叠率超过阈值,则认为重叠
if (overlapRatio > overlapThreshold) {
return true;
}
}
return false;
};
// 获取当前词云形状信息 - 改进密度计算
const getCloudShape = () => {
if (placedWords.length === 0) {
return {
width: initialWidth,
height: initialHeight,
ratio: initialWidth / initialHeight,
density: 0
};
}
// 计算已放置区域的边界
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
let totalArea = 0;
placedWords.forEach(pos => {
const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation);
minX = Math.min(minX, box.minX);
maxX = Math.max(maxX, box.maxX);
minY = Math.min(minY, box.minY);
maxY = Math.max(maxY, box.maxY);
// 累计词的面积
totalArea += pos.width * pos.height;
});
const width = Math.max(1, maxX - minX);
const height = Math.max(1, maxY - minY);
const area = width * height;
// 计算密度 (已用面积 / 总面积)
const density = totalArea / area;
return {
width,
height,
ratio: width / height,
density
};
};
// 自适应旋转角度决策 - 基于可用空间和单词特性
const getOptimalRotation = (word: string, textWidth: number, textHeight: number, index: number) => {
if (!options.enableRotation) return 0;
const wordType = getWordType(word);
const isHighFrequencyWord = index < sortedWords.length * 0.15; // 前15%的高频词
// 单字或短词偏好水平排列
if (word.length === 1 || (wordType === 'english' && word.length <= 3)) {
return 0;
}
// 中文单字不旋转
if (wordType === 'chinese' && word.length === 1) {
return 0;
}
// 高频词优先水平排列
if (isHighFrequencyWord) {
// 最高频词不旋转
if (index === 0) return 0;
// 其他高频词轻微旋转
return (Math.random() * 2 - 1) * 3;
}
// 获取当前词云形状与密度
const cloudShape = getCloudShape();
// 特别定制:根据词的宽高比决定旋转
// 细长的词在排版上更灵活
const isLongWord = textWidth / textHeight > 3;
// 宽高比例调整旋转策略
if (cloudShape.ratio > 1.3) {
// 宽大于高,优先考虑竖排
if (isLongWord) {
// 细长词适合90度旋转
return 90;
} else {
// 其他词随机选择,但偏向竖排
return Math.random() < 0.7 ?
90 + (Math.random() * 2 - 1) * 5 : // 竖排
(Math.random() * 2 - 1) * 5; // 横排
}
} else if (cloudShape.ratio < 0.7) {
// 高大于宽,优先考虑横排
return (Math.random() * 2 - 1) * 5;
}
// 根据词的类型进一步决定倾向
let horizontalBias = options.horizontalWeight;
if (wordType === 'chinese' && word.length > 1) {
// 中文词组更适合横排
horizontalBias += 0.2;
} else if (wordType === 'english' && word.length > 5) {
// 长英文单词可增加竖排几率
horizontalBias -= 0.1;
}
// 根据词的长宽比进一步微调
const aspectRatio = textWidth / textHeight;
if (aspectRatio > 4) {
// 极细长的词更适合竖排
horizontalBias -= 0.25;
} else if (aspectRatio < 1.5) {
// 近方形的词更适合横排
horizontalBias += 0.15;
}
// 最终决定旋转
return Math.random() < horizontalBias ?
(Math.random() * 2 - 1) * options.rotationVariance / 3 : // 横排,减小角度变化
90 + (Math.random() * 2 - 1) * options.rotationVariance / 3; // 竖排,减小角度变化
};
// 增强的过滤不可渲染字符函数
function filterUnrenderableChars(text: string): string {
// 过滤掉控制字符、特殊Unicode和一些可能导致渲染问题的字符
return text
.replace(/[\u0000-\u001F\u007F-\u009F\uFFFD\uFFFE\uFFFF]/g, '') // 控制字符和特殊字符
.replace(/[\u2000-\u200F\u2028-\u202F]/g, '') // 一些特殊空白和控制字符
.replace(/[\u0080-\u00A0]/g, '') // 一些Latin-1补充字符
.replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '') //
.trim();
}
// ===== 优化: 添加新的位置策略函数 =====
// 改进的螺旋布局 - 更紧凑的布局策略
const getSpiralPosition = (
textWidth: number,
textHeight: number,
attempt: number,
canvasShape: { width: number, height: number, ratio: number, density: number }
) => {
// 根据词数量调整螺旋参数 - 词少时更紧凑
const wordCountFactor = Math.min(1, placedWords.length / 20); // 少于20个词时更紧凑
// 使用已放置词的中心点,而非固定画布中心
let centerX = initialWidth / 2;
let centerY = initialHeight / 2;
// 如果已经有足够的词,使用它们的质心作为新的中心点
if (placedWords.length >= 3) {
let sumX = 0, sumY = 0, weightSum = 0;
for (const pos of placedWords) {
// 较大的词有更大的权重影响中心点
const weight = Math.sqrt(pos.width * pos.height);
sumX += (pos.x + pos.width / 2) * weight;
sumY += (pos.y + pos.height / 2) * weight;
weightSum += weight;
}
centerX = sumX / weightSum;
centerY = sumY / weightSum;
}
// 动态调整螺旋参数,词数少时更紧凑
const baseA = Math.min(initialWidth, initialHeight) / (35 + (1 - wordCountFactor) * 15);
const densityFactor = Math.max(0.7, Math.min(1.4, 0.7 + canvasShape.density * 1.2));
const a = baseA / densityFactor; // 反比例,密度高时参数更小,螺旋更紧凑
// 词数量少时使用更小的角度增量,产生更紧凑的螺旋
const angleIncrement = 0.1 + wordCountFactor * 0.25;
const angle = angleIncrement * attempt;
// 非线性距离增长,词数少时增长更慢
const distanceMultiplier = wordCountFactor * (
attempt < 8 ?
0.2 + Math.pow(attempt / 8, 1.5) : // 前几次更靠近中心,呈幂次增长
0.7 + Math.pow((attempt - 8) / 25, 0.7) // 之后缓慢增长
) + (1 - wordCountFactor) * (0.1 + Math.pow(attempt / 20, 1.2)); // 词少时增长更慢
// 根据画布形状自适应调整螺旋方向
let dx, dy;
if (canvasShape.ratio > 1.2) { // 宽大于高
// 水平方向拉伸,但减少拉伸强度
dx = a * angle * Math.cos(angle) * distanceMultiplier * 1.1;
dy = a * angle * Math.sin(angle) * distanceMultiplier * 0.9;
} else if (canvasShape.ratio < 0.8) { // 高大于宽
// 垂直方向拉伸,但减少拉伸强度
dx = a * angle * Math.cos(angle) * distanceMultiplier * 0.9;
dy = a * angle * Math.sin(angle) * distanceMultiplier * 1.1;
} else {
// 更均衡的螺旋
dx = a * angle * Math.cos(angle) * distanceMultiplier;
dy = a * angle * Math.sin(angle) * distanceMultiplier;
}
// 添加少量随机抖动以打破规则性
dx += (Math.random() - 0.5) * a * 0.5;
dy += (Math.random() - 0.5) * a * 0.5;
const x = centerX + dx - textWidth / 2;
const y = centerY + dy - textHeight / 2;
return {
x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, x)),
y: Math.max(textHeight / 2 + options.safetyMargin, Math.min(initialHeight - textHeight / 2 - options.safetyMargin, y))
};
};
// 新增: 空白区域填充策略
const findGapPosition = (
textWidth: number,
textHeight: number,
canvasShape: { width: number, height: number, ratio: number, density: number }
) => {
// 如果词太少,直接返回螺旋位置
if (placedWords.length < 5) {
return getSpiralPosition(textWidth, textHeight, Math.floor(Math.random() * 10), canvasShape);
}
// 计算当前词云的边界
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const pos of placedWords) {
const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation);
minX = Math.min(minX, box.minX);
maxX = Math.max(maxX, box.maxX);
minY = Math.min(minY, box.minY);
maxY = Math.max(maxY, box.maxY);
}
// 定义搜索区域,适当扩大范围
const searchMargin = Math.max(textWidth, textHeight) * 0.5;
const searchMinX = Math.max(0, minX - searchMargin);
const searchMaxX = Math.min(initialWidth, maxX + searchMargin);
const searchMinY = Math.max(0, minY - searchMargin);
const searchMaxY = Math.min(initialHeight, maxY + searchMargin);
// 搜索区域宽高
const searchWidth = searchMaxX - searchMinX;
const searchHeight = searchMaxY - searchMinY;
// 网格尺寸,较小的网格可以更精确地找到空白区域
const gridSize = Math.min(textWidth, textHeight) / 2;
const gridRows = Math.max(3, Math.ceil(searchHeight / gridSize));
const gridCols = Math.max(3, Math.ceil(searchWidth / gridSize));
// 初始化网格密度
const gridDensity = Array(gridRows).fill(0).map(() => Array(gridCols).fill(0));
// 计算每个网格的密度
for (const pos of placedWords) {
const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation);
// 计算此词覆盖的网格范围
const startRow = Math.max(0, Math.floor((box.minY - searchMinY) / gridSize));
const endRow = Math.min(gridRows - 1, Math.floor((box.maxY - searchMinY) / gridSize));
const startCol = Math.max(0, Math.floor((box.minX - searchMinX) / gridSize));
const endCol = Math.min(gridCols - 1, Math.floor((box.maxX - searchMinX) / gridSize));
// 增加网格密度值
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r >= 0 && r < gridRows && c >= 0 && c < gridCols) {
gridDensity[r]![c] += 1;
}
}
}
}
// 找出能容纳当前词的最低密度区域
let bestDensity = Infinity;
let bestRow = 0, bestCol = 0;
// 需要的网格数量
const needRows = Math.ceil(textHeight / gridSize);
const needCols = Math.ceil(textWidth / gridSize);
// 搜索最优位置
for (let r = 0; r <= gridRows - needRows; r++) {
for (let c = 0; c <= gridCols - needCols; c++) {
let totalDensity = 0;
let isValid = true;
// 计算区域总密度
for (let nr = 0; nr < needRows && isValid; nr++) {
for (let nc = 0; nc < needCols && isValid; nc++) {
if (r + nr < gridRows && c + nc < gridCols) {
totalDensity += gridDensity[r + nr]![c + nc];
// 如果单个网格密度过高,直接判定无效
if (gridDensity[r + nr]![c + nc] > 3) {
isValid = false;
}
}
}
}
// 更新最佳位置
if (isValid && totalDensity < bestDensity) {
bestDensity = totalDensity;
bestRow = r;
bestCol = c;
}
}
}
// 添加随机抖动避免太规则
const jitterX = (Math.random() - 0.5) * gridSize * 0.6;
const jitterY = (Math.random() - 0.5) * gridSize * 0.6;
// 计算最终位置
const x = searchMinX + bestCol * gridSize + jitterX;
const y = searchMinY + bestRow * gridSize + jitterY;
return {
x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, x)),
y: Math.max(textHeight + options.safetyMargin, Math.min(initialHeight - options.safetyMargin, y))
};
};
// 新增: 边缘扩展策略
const getEdgeExtendPosition = (
textWidth: number,
textHeight: number,
attempt: number,
canvasShape: { width: number, height: number, ratio: number, density: number }
) => {
// 如果无已放置词,回退到螺旋
if (placedWords.length === 0) {
return getSpiralPosition(textWidth, textHeight, attempt, canvasShape);
}
// 随机选择一个已放置的词作为参考点
const referenceIndex = Math.floor(Math.random() * placedWords.length);
const reference = placedWords[referenceIndex];
// 随机选择方向 (0=右, 1=下, 2=左, 3=上4-7=对角线)
const direction = Math.floor(Math.random() * 8);
// 基础位置
let baseX = reference!.x;
let baseY = reference!.y;
// 获取参考词的旋转后边界框
const refBox = getRotatedBoundingBox(
reference!.x, reference!.y, reference!.width, reference!.height, reference!.rotation
);
// 根据方向计算新位置
const margin = options.safetyMargin * 0.5; // 减小边距,增加紧凑度
switch (direction) {
case 0: // 右
baseX = refBox.maxX + margin;
baseY = refBox.minY + (refBox.maxY - refBox.minY) / 2 - textHeight / 2;
break;
case 1: // 下
baseX = refBox.minX + (refBox.maxX - refBox.minX) / 2 - textWidth / 2;
baseY = refBox.maxY + margin;
break;
case 2: // 左
baseX = refBox.minX - textWidth - margin;
baseY = refBox.minY + (refBox.maxY - refBox.minY) / 2 - textHeight / 2;
break;
case 3: // 上
baseX = refBox.minX + (refBox.maxX - refBox.minX) / 2 - textWidth / 2;
baseY = refBox.minY - textHeight - margin;
break;
case 4: // 右上
baseX = refBox.maxX + margin;
baseY = refBox.minY - textHeight - margin;
break;
case 5: // 右下
baseX = refBox.maxX + margin;
baseY = refBox.maxY + margin;
break;
case 6: // 左下
baseX = refBox.minX - textWidth - margin;
baseY = refBox.maxY + margin;
break;
case 7: // 左上
baseX = refBox.minX - textWidth - margin;
baseY = refBox.minY - textHeight - margin;
break;
}
// 添加少量随机抖动
baseX += (Math.random() - 0.5) * margin * 2;
baseY += (Math.random() - 0.5) * margin * 2;
return {
x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, baseX)),
y: Math.max(textHeight + options.safetyMargin, Math.min(initialHeight - options.safetyMargin, baseY))
};
};
// 新增: 多策略选择函数,根据情况选择最佳策略
const getPositionWithStrategy = (
textWidth: number,
textHeight: number,
attempt: number,
canvasShape: { width: number, height: number, ratio: number, density: number },
index: number
) => {
// 检测是否为小词云
const isSmallWordCloud = sortedWords.length < 15;
// 第一个词或前几个高频词仍然放在中心,小词云时范围更大
if (placedWords.length === 0 || (index < 3 && attempt < 5) || (isSmallWordCloud && index < Math.min(5, sortedWords.length / 2))) {
// 添加小偏移以避免完全重叠
const offset = isSmallWordCloud ? index * 8 : 0;
return {
x: initialWidth / 2 - textWidth / 2 + (Math.random() - 0.5) * offset,
y: initialHeight / 2 - textHeight / 2 + (Math.random() - 0.5) * offset
};
}
// 根据尝试次数选择不同策略
const attemptProgress = attempt / options.maxAttempts; // 0到1的进度值
// 小词云优先使用紧凑布局策略
if (isSmallWordCloud) {
if (attemptProgress < 0.6) {
return getSpiralPosition(textWidth, textHeight, attempt / 2, canvasShape); // 减少螺旋步长,更紧凑
} else {
return Math.random() < 0.7 ?
findGapPosition(textWidth, textHeight, canvasShape) :
getEdgeExtendPosition(textWidth, textHeight, attempt / 2, canvasShape);
}
}
// 高频词优先使用螺旋或中心布局
if (index < sortedWords.length * 0.1) {
if (attemptProgress < 0.5) {
return getSpiralPosition(textWidth, textHeight, attempt, canvasShape);
} else {
return Math.random() < 0.7 ?
findGapPosition(textWidth, textHeight, canvasShape) :
getEdgeExtendPosition(textWidth, textHeight, attempt, canvasShape);
}
}
// 不同阶段使用不同策略
if (attemptProgress < 0.3) {
// 前30%尝试: 主要使用改进的螺旋
return getSpiralPosition(textWidth, textHeight, attempt, canvasShape);
} else if (attemptProgress < 0.7) {
// 中间40%尝试: 主要寻找空白区域
return Math.random() < 0.8 ?
findGapPosition(textWidth, textHeight, canvasShape) :
getSpiralPosition(textWidth, textHeight, attempt, canvasShape);
} else {
// 后30%尝试: 主要使用边缘扩展和随机策略
const r = Math.random();
if (r < 0.6) {
return getEdgeExtendPosition(textWidth, textHeight, attempt, canvasShape);
} else if (r < 0.8) {
return findGapPosition(textWidth, textHeight, canvasShape);
} else {
return getSpiralPosition(textWidth, textHeight, attempt * 2, canvasShape); // 双倍螺旋步进,迅速扩展
}
}
};
// 记录所有单词的边界以自动调整画布大小
let minX = initialWidth;
let maxX = 0;
let minY = initialHeight;
let maxY = 0;
// 记录原始中心点,用于居中重定位
let originalCenterX = initialWidth / 2;
let originalCenterY = initialHeight / 2;
// 动态画布扩展计数
let canvasExpansionCount = 0;
// 第一轮:计算每个单词的位置并追踪边界
for (let i = 0; i < sortedWords.length; i++) {
const { word, frequency } = sortedWords[i]!;
// 安全检查 - 过滤不可渲染字符
const safeWord = filterUnrenderableChars(word);
if (!safeWord) continue;
// 使用增强的字体大小计算函数
const fontSize = calculateFontSize(frequency, i);
// 获取合适的字体
const fontFamily = getFontFamily(safeWord);
// 设置字体和测量文本
tempCtx.font = `bold ${fontSize}px ${fontFamily}`;
const metrics = tempCtx.measureText(safeWord);
// 更精确地计算文本高度
const textHeight = fontSize;
const textWidth = metrics.width;
// 获取当前云形状与密度
const cloudShape = getCloudShape();
// 获取最佳旋转角度
const rotation = getOptimalRotation(safeWord, textWidth, textHeight, i);
// 尝试定位
let positioned = false;
let finalX = 0, finalY = 0;
// 尝试放置单词,如果失败可能会扩展画布
for (let attempt = 0; attempt < options.maxAttempts && !positioned; attempt++) {
// 使用多策略获取位置,而不是仅用螺旋布局
const { x, y } = getPositionWithStrategy(textWidth, textHeight, attempt, cloudShape, i);
if (!isOverlapping(x, y, textWidth, textHeight, rotation, i)) {
finalX = x;
finalY = y;
positioned = true;
// 获取此单词旋转后的包围盒
const box = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation);
// 更新整体边界
minX = Math.min(minX, box.minX);
maxX = Math.max(maxX, box.maxX);
minY = Math.min(minY, box.minY);
maxY = Math.max(maxY, box.maxY);
// 记录位置,保存字体大小
placedWords.push({
x: finalX,
y: finalY,
width: textWidth,
height: textHeight,
rotation,
fontSize
});
} else if (attempt === options.maxAttempts - 1 && canvasExpansionCount < options.maxExpansionAttempts) {
// 如果所有尝试都失败,并且还有扩展余量,则扩展画布
canvasExpansionCount++;
// 计算当前中心点
const currentCenterX = (maxX + minX) / 2;
const currentCenterY = (maxY + minY) / 2;
// 保存原始画布尺寸
const oldWidth = initialWidth;
const oldHeight = initialHeight;
// 扩展画布尺寸 - 使用更小的扩展比例
initialWidth = Math.ceil(initialWidth * options.expansionRatio);
initialHeight = Math.ceil(initialHeight * options.expansionRatio);
// 计算扩展量
const widthIncrease = initialWidth - oldWidth;
const heightIncrease = initialHeight - oldHeight;
// 调整所有已放置单词的位置,使它们保持居中
placedWords.forEach(pos => {
// 相对于原中心的偏移
const offsetX = pos.x - originalCenterX;
const offsetY = pos.y - originalCenterY;
// 计算新位置,保持相对于中心的偏移不变
pos.x = originalCenterX + widthIncrease / 2 + offsetX;
pos.y = originalCenterY + heightIncrease / 2 + offsetY;
});
// 更新坐标系中心点
originalCenterX = originalCenterX + widthIncrease / 2;
originalCenterY = originalCenterY + heightIncrease / 2;
// 更新边界信息
minX += widthIncrease / 2;
maxX += widthIncrease / 2;
minY += heightIncrease / 2;
maxY += heightIncrease / 2;
// 重新创建临时画布
tempCanvas = createCanvas(initialWidth, initialHeight);
tempCtx = tempCanvas.getContext('2d');
// 重置尝试计数,在新的扩展画布上再次尝试
attempt = -1; // 会在循环中+1变成0
}
}
// 如果仍然无法放置,尝试增加重叠容忍度
if (!positioned) {
const maxOverlapThreshold = options.overlapThreshold * 2.0; // 允许更多重叠
for (let attempt = 0; attempt < options.maxAttempts && !positioned; attempt++) {
// 再次使用多策略获取位置
const { x, y } = getPositionWithStrategy(textWidth, textHeight, attempt, cloudShape, i);
// 获取当前词的包围盒
const currentBox = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation);
// 计算当前单词的面积
const currentArea = (currentBox.maxX - currentBox.minX) * (currentBox.maxY - currentBox.minY);
// 计算最大重叠面积
let maxOverlapArea = 0;
for (const pos of placedWords) {
const posBox = getRotatedBoundingBox(
pos.x, pos.y, pos.width, pos.height, pos.rotation
);
// 计算重叠区域
const overlapX = Math.max(0, Math.min(currentBox.maxX, posBox.maxX) - Math.max(currentBox.minX, posBox.minX));
const overlapY = Math.max(0, Math.min(currentBox.maxY, posBox.maxY) - Math.max(currentBox.minY, posBox.minY));
const overlapArea = overlapX * overlapY;
maxOverlapArea = Math.max(maxOverlapArea, overlapArea);
}
// 计算重叠率
const overlapRatio = maxOverlapArea / currentArea;
// 如果重叠率在允许范围内,则放置
if (overlapRatio <= maxOverlapThreshold) {
finalX = x;
finalY = y;
positioned = true;
// 获取此单词旋转后的包围盒
const box = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation);
// 更新整体边界(即使是增加重叠度放置的单词也计入边界)
minX = Math.min(minX, box.minX);
maxX = Math.max(maxX, box.maxX);
minY = Math.min(minY, box.minY);
maxY = Math.max(maxY, box.maxY);
// 记录位置
placedWords.push({
x: finalX,
y: finalY,
width: textWidth,
height: textHeight,
rotation,
fontSize
});
}
}
// 如果仍然无法放置,则跳过该词
if (!positioned) {
console.log(`无法放置单词: ${safeWord}`);
continue;
}
}
}
// 第二阶段:确定最终画布大小并绘制
// 添加内边距
minX = Math.max(0, minX - options.padding);
minY = Math.max(0, minY - options.padding);
maxX = maxX + options.padding;
maxY = maxY + options.padding;
// 计算最终画布尺寸
const finalWidth = Math.ceil(maxX - minX);
const finalHeight = Math.ceil(maxY - minY);
// 创建最终画布
const canvas = createCanvas(finalWidth, finalHeight);
const ctx = canvas.getContext('2d');
// 设置背景
ctx.fillStyle = options.backgroundColor;
ctx.fillRect(0, 0, finalWidth, finalHeight);
for (let i = 0; i < sortedWords.length; i++) {
if (i >= placedWords.length) continue;
const { word, frequency } = sortedWords[i]!;
const position = placedWords[i];
if (!position) continue;
const safeWord = filterUnrenderableChars(word);
if (!safeWord) continue;
const fontFamily = getFontFamily(safeWord);
ctx.font = `bold ${position.fontSize}px ${fontFamily}`;
ctx.fillStyle = getColorFromFrequency(frequency, i);
const adjustedX = position.x - minX;
const adjustedY = position.y - minY;
ctx.save();
ctx.translate(
adjustedX + position.width / 2,
adjustedY + position.height / 2
);
ctx.rotate(position.rotation * Math.PI / 180);
ctx.fillText(safeWord, -position.width / 2, position.height / 2);
ctx.restore();
}
const buffer = canvas.toBuffer('image/png');
return "base64://" + buffer.toString('base64');
}

View File

@@ -31,6 +31,9 @@ import { WebUiDataRuntime } from '@/webui/src/helper/Data';
import { napCatVersion } from '@/common/version'; import { napCatVersion } from '@/common/version';
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener'; import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
import { sleep } from '@/common/helper'; import { sleep } from '@/common/helper';
import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg';
import { FFmpegService } from '@/common/ffmpeg';
import { connectToNamedPipe } from '@/shell/pipe';
// NapCat Shell App ES 入口文件 // NapCat Shell App ES 入口文件
async function handleUncaughtExceptions(logger: LogWrapper) { async function handleUncaughtExceptions(logger: LogWrapper) {
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
@@ -114,18 +117,19 @@ async function handleLogin(
quickLoginUin: string | undefined, quickLoginUin: string | undefined,
historyLoginList: LoginListItem[] historyLoginList: LoginListItem[]
): Promise<SelfInfo> { ): Promise<SelfInfo> {
let context = { isLogined: false };
let inner_resolve: (value: SelfInfo) => void; let inner_resolve: (value: SelfInfo) => void;
let selfInfo: Promise<SelfInfo> = new Promise((resolve) => { let selfInfo: Promise<SelfInfo> = new Promise((resolve) => {
inner_resolve = resolve; inner_resolve = resolve;
}); });
// 连接服务 // 连接服务
let isLogined = false;
const loginListener = new NodeIKernelLoginListener(); const loginListener = new NodeIKernelLoginListener();
loginListener.onUserLoggedIn = (userid: string) => { loginListener.onUserLoggedIn = (userid: string) => {
logger.logError(`当前账号(${userid})已登录,无法重复登录`); logger.logError(`当前账号(${userid})已登录,无法重复登录`);
}; };
loginListener.onQRCodeLoginSucceed = async (loginResult) => { loginListener.onQRCodeLoginSucceed = async (loginResult) => {
isLogined = true; context.isLogined = true;
inner_resolve({ inner_resolve({
uid: loginResult.uid, uid: loginResult.uid,
uin: loginResult.uin, uin: loginResult.uin,
@@ -134,7 +138,12 @@ async function handleLogin(
}); });
}; };
loginListener.onLoginConnected = () => {
waitForNetworkConnection(loginService, logger).then(() => {
handleLoginInner(context, logger, loginService, quickLoginUin, historyLoginList).then().catch(e => logger.logError(e));
loginListener.onLoginConnected = () => { };
});
}
loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => { loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => {
WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl); WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl);
@@ -156,7 +165,7 @@ async function handleLogin(
}; };
loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => { loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => {
if (!isLogined) { if (!context.isLogined) {
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode); logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
if (errType == 1 && errCode == 3) { if (errType == 1 && errCode == 3) {
// 二维码过期刷新 // 二维码过期刷新
@@ -168,10 +177,12 @@ async function handleLogin(
loginListener.onLoginFailed = (...args) => { loginListener.onLoginFailed = (...args) => {
logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args)); logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
}; };
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger)); loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
loginService.connect(); loginService.connect();
await waitForNetworkConnection(loginService, logger); return await selfInfo;
// 等待网络 }
async function handleLoginInner(context: { isLogined: boolean }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => { WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
return await new Promise((resolve) => { return await new Promise((resolve) => {
if (uin) { if (uin) {
@@ -197,13 +208,13 @@ async function handleLogin(
.then(result => { .then(result => {
if (result.loginErrorInfo.errMsg) { if (result.loginErrorInfo.errMsg) {
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg); logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
if (!isLogined) loginService.getQRCodePicture(); if (!context.isLogined) loginService.getQRCodePicture();
} }
}) })
.catch(); .catch();
} else { } else {
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将使用二维码登录方式'); logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将使用二维码登录方式');
if (!isLogined) loginService.getQRCodePicture(); if (!context.isLogined) loginService.getQRCodePicture();
} }
} else { } else {
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式'); logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
@@ -214,6 +225,11 @@ async function handleLogin(
}`); }`);
} }
loginService.getQRCodePicture(); loginService.getQRCodePicture();
try {
await WebUiDataRuntime.runWebUiConfigQuickFunction();
} catch (error) {
logger.logError('WebUi 快速登录失败 执行失败', error);
}
} }
loginService.getLoginList().then((res) => { loginService.getLoginList().then((res) => {
@@ -222,8 +238,6 @@ async function handleLogin(
WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString())); WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString()));
WebUiDataRuntime.setQQNewLoginList(list); WebUiDataRuntime.setQQNewLoginList(list);
}); });
return await selfInfo;
} }
async function initializeSession( async function initializeSession(
@@ -285,7 +299,7 @@ async function waitForNetworkConnection(loginService: NodeIKernelLoginService, l
let network_ok = false; let network_ok = false;
let tryCount = 0; let tryCount = 0;
while (!network_ok) { while (!network_ok) {
network_ok = loginService.getMsfStatus() === 0; network_ok = loginService.getMsfStatus() !== 3;// win 11 0连接 1未连接
logger.log('等待网络连接...'); logger.log('等待网络连接...');
await sleep(500); await sleep(500);
tryCount++; tryCount++;
@@ -299,6 +313,16 @@ export async function NCoreInitShell() {
const pathWrapper = new NapCatPathWrapper(); const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath); const logger = new LogWrapper(pathWrapper.logsPath);
handleUncaughtExceptions(logger); handleUncaughtExceptions(logger);
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
downloadFFmpegIfNotExists(logger).then(({ path, reset }) => {
if (reset && path) {
FFmpegService.setFfmpegPath(path, logger);
}
}).catch(e => {
logger.logError('[Ffmpeg] Error:', e);
});
}
const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion()); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion());

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