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",
"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 | [![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
+ [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).
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 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",
"version": "9.9.18-32793",
"verHash": "d43f097e",
"linuxVersion": "3.2.16-32793",
"linuxVerHash": "ee4bd910",
"version": "9.9.18-32869",
"verHash": "e735296c",
"linuxVersion": "3.2.16-32869",
"linuxVerHash": "4c192ba9",
"private": true,
"description": "QQ",
"productName": "QQ",
@@ -34,7 +34,7 @@
"vuex@4.1.0": "patches/vuex@4.1.0.patch"
}
},
"buildVersion": "32793",
"buildVersion": "32869",
"isPureShell": true,
"isByteCodeShell": true,
"platform": "win32",

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill'
import { LogLevel } from '@/const/enum'
import { serverRequest } from '@/utils/request'
import CryptoJS from "crypto-js";
export interface Log {
level: LogLevel
message: string
@@ -17,9 +17,10 @@ export default class WebUIManager {
}
public static async loginWithToken(token: string) {
const sha256 = CryptoJS.SHA256(token + '.napcat').toString();
const { data } = await serverRequest.post<ServerResponse<AuthResponse>>(
'/auth/login',
{ token }
{ hash: sha256 }
)
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(() => {
if (token) {
onSubmit()
@@ -79,6 +95,7 @@ export default function WebLoginPage() {
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
<Input
isClearable
type="password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.7.0",
"version": "4.7.45",
"scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
@@ -21,7 +21,6 @@
"@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@ffmpeg.wasm/main": "^0.13.1",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4",
@@ -36,6 +35,7 @@
"@types/qrcode-terminal": "^0.12.2",
"@types/react-color": "^3.0.13",
"@types/type-is": "^1.6.7",
"@types/wordcloud": "^1.2.2",
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
@@ -45,7 +45,7 @@
"cors": "^2.8.5",
"esbuild": "0.25.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",
"express-rate-limit": "^7.5.0",
"fast-xml-parser": "^4.3.6",
@@ -57,16 +57,19 @@
"typescript": "^5.3.3",
"typescript-eslint": "^8.13.0",
"vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8",
"vite-plugin-cp": "^6.0.0",
"vite-tsconfig-paths": "^5.1.0",
"napcat.protobuf": "^1.1.3",
"napcat.protobuf": "^1.1.4",
"winston": "^3.17.0",
"compressing": "^1.10.1"
},
"dependencies": {
"@ffmpeg.wasm/core-mt": "^0.13.2",
"@napi-rs/canvas": "^0.1.67",
"@node-rs/jieba": "^2.0.1",
"express": "^5.0.0",
"napcat.protobuf": "^1.1.2",
"silk-wasm": "^3.6.1",
"wordcloud": "^1.2.3",
"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 { VideoInfo } from './video';
import path from 'path';
import { fileURLToPath } from 'url';
import { runTask } from './worker';
type EncodeArgs = {
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
args: any[];
import { readFileSync, statSync, existsSync, mkdirSync } from 'fs';
import path, { dirname } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
import imageSize from 'image-size';
import { fileURLToPath } from 'node:url';
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;
};
type EncodeResult = any;
function getWorkerPath() {
return path.join(path.dirname(fileURLToPath(import.meta.url)), './ffmpeg-worker.mjs');
}
export let FFMPEG_CMD = getFFmpegPath('ffmpeg');
export let FFPROBE_CMD = getFFmpegPath('ffprobe');
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> {
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> {
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> {
const result = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convert', args: [filePath, pcmPath] });
return result;
try {
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> {
const result = await await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
return result;
try {
// 并行执行获取文件信息和提取缩略图
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 hash = crypto.createHash('md5');
stream.on('data', (data: Buffer) => {
stream.on('data', (data) => {
// 当读取到数据时,更新哈希对象的状态
hash.update(data);
});
@@ -115,7 +115,7 @@ async function tryDownload(options: string | HttpDownloadOptions, useReferer: bo
if (useReferer && !headers['Referer']) {
headers['Referer'] = url;
}
const fetchRes = await fetch(url, { headers }).catch((err) => {
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
if (err.cause) {
throw err.cause;
}
@@ -145,8 +145,8 @@ export enum FileUriType {
export async function checkUriType(Uri: string) {
const LocalFileRet = await solveProblem((uri: string) => {
if (fs.existsSync(uri)) {
return { Uri: uri, Type: FileUriType.Local };
if (fs.existsSync(path.normalize(uri))) {
return { Uri: path.normalize(uri), Type: FileUriType.Local };
}
return undefined;
}, Uri);
@@ -182,28 +182,28 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string
const filePath = path.join(dir, filename);
switch (UriType) {
case FileUriType.Local: {
const fileExt = path.extname(HandledUri);
const localFileName = path.basename(HandledUri, fileExt) + fileExt;
const tempFilePath = path.join(dir, filename + fileExt);
fs.copyFileSync(HandledUri, tempFilePath);
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
}
case FileUriType.Local: {
const fileExt = path.extname(HandledUri);
const localFileName = path.basename(HandledUri, fileExt) + fileExt;
const tempFilePath = path.join(dir, filename + fileExt);
fs.copyFileSync(HandledUri, tempFilePath);
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
}
case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath };
}
case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath };
}
case FileUriType.Base64: {
const base64 = HandledUri.replace(/^base64:\/\//, '');
const base64Buffer = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, base64Buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath };
}
case FileUriType.Base64: {
const base64 = HandledUri.replace(/^base64:\/\//, '');
const base64Buffer = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, base64Buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath };
}
default:
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
default:
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
}
}

View File

@@ -109,7 +109,6 @@ export class RequestUtil {
req.end();
});
}
// 请求返回都是原始内容
static async HttpGetText(url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
return this.HttpGetJson<string>(url, method, data, headers, false, false);

View File

@@ -1 +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 {
return await new Promise<R>((resolve, reject) => {
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);
});

View File

@@ -41,10 +41,10 @@ export class NTQQFileApi {
this.context = context;
this.core = core;
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;
const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO);
context.deleteAfterSentFiles.push(path);
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
} catch {
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
if (_diyThumbPath) {
try {
await this.copyFile(_diyThumbPath, thumbPath);
} catch (e) {
this.context.logger.logError('复制自定义缩略图失败', e);
}
} else {
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
if (!fs.existsSync(thumbPath)) {
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
throw new Error('获取视频缩略图失败');
}
} catch (e) {
this.context.logger.logError('获取视频信息失败', e);
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
}
context.deleteAfterSentFiles.push(thumbPath);
const thumbSize = (await fsPromises.stat(thumbPath)).size;
@@ -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);
if (!silkPath) {
@@ -301,18 +308,18 @@ export class NTQQFileApi {
element.elementType === ElementType.FILE
) {
switch (element.elementType) {
case ElementType.PIC:
case ElementType.PIC:
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.VIDEO:
break;
case ElementType.VIDEO:
element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.PTT:
break;
case ElementType.PTT:
element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.FILE:
break;
case ElementType.FILE:
element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
break;
}
elementIndex++;
}
@@ -338,6 +345,7 @@ export class NTQQFileApi {
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
[{
fileModelId: '0',
downSourceType: 0,
downloadSourceType: 0,
triggerType: 1,
msgId: msgId,

View File

@@ -86,4 +86,31 @@ export class NTQQFriendApi {
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);
}
async transGroupFile(groupCode: string, fileId: string) {
return this.context.session.getRichMediaService().transGroupFile(groupCode, fileId);
}
async addGroupEssence(groupCode: string, msgId: string) {
const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
chatType: 2,

View File

@@ -17,6 +17,24 @@ export class NTQQMsgApi {
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) {
// https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取
return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime);
@@ -137,7 +155,6 @@ export class NTQQMsgApi {
}
async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) {
console.log(peer, SendersUid);
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
@@ -149,7 +166,30 @@ export class NTQQMsgApi {
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) {
return this.context.session.getMsgService().setMsgRead(peer);
}

View File

@@ -90,7 +90,30 @@ export class NTQQUserApi {
() => true,
(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.vasInfo,
...profile.commonExt,
@@ -101,33 +124,6 @@ export class NTQQUserApi {
pendantId: '',
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) {

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": {
"appid": 537246092,
"qua": "V1_WIN_NQ_9.9.15_28060_GW_B"
@@ -218,5 +290,277 @@
"9.9.18-32869": {
"appid": 537271294,
"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": {
"send": "7226630",
"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;
expired_time: number;
}
interface OneBotApiRet {
status: string,
retcode: number,
data: ServerRkeyData,
message: string,
wording: string,
}
interface UrlFailureInfo {
count: number;
lastTimestamp: number;
}
export class RkeyManager {
serverUrl: string[] = [];
@@ -15,9 +26,8 @@ export class RkeyManager {
private_rkey: '',
expired_time: 0,
};
private failureCount: number = 0;
private lastFailureTimestamp: number = 0;
private readonly FAILURE_LIMIT: number = 8;
private urlFailures: Map<string, UrlFailureInfo> = new Map();
private readonly FAILURE_LIMIT: number = 4;
private readonly ONE_DAY: number = 24 * 60 * 60 * 1000;
constructor(serverUrl: string[], logger: LogWrapper) {
@@ -26,50 +36,92 @@ export class RkeyManager {
}
async getRkey() {
const now = new Date().getTime();
if (now - this.lastFailureTimestamp > this.ONE_DAY) {
this.failureCount = 0; // 重置失败计数器
}
if (this.failureCount >= this.FAILURE_LIMIT) {
this.logger.logError('[Rkey] 服务存在异常, 图片使用FallBack机制');
throw new Error('获取rkey失败次数过多请稍后再试');
const availableUrls = this.getAvailableUrls();
if (availableUrls.length === 0) {
this.logger.logError('[Rkey] 所有服务均已禁用, 图片使用FallBack机制');
throw new Error('获取rkey失败所有服务URL均已被禁用');
}
if (this.isExpired()) {
try {
await this.refreshRkey();
} catch (e) {
throw new Error(`${e}`);//外抛
throw new Error(`${e}`);
}
}
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 {
const now = new Date().getTime() / 1000;
return now > this.rkeyData.expired_time;
}
async refreshRkey() {
//刷新rkey
for (const url of this.serverUrl) {
const availableUrls = this.getAvailableUrls();
if (availableUrls.length === 0) {
this.logger.logError('[Rkey] 所有服务均已禁用');
throw new Error('获取rkey失败所有服务URL均已被禁用');
}
for (const url of availableUrls) {
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 = {
group_rkey: temp.group_rkey.slice(6),
private_rkey: temp.private_rkey.slice(6),
expired_time: temp.expired_time
};
this.failureCount = 0;
return;
} catch (e) {
this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e);
this.failureCount++;
this.lastFailureTimestamp = new Date().getTime();
//是否为最后一个url
if (url === this.serverUrl[this.serverUrl.length - 1]) {
throw new Error(`获取rkey失败: ${e}`);//外抛
this.updateUrlFailure(url);
if (url === availableUrls[availableUrls.length - 1]) {
throw new Error(`获取rkey失败: ${e}`);
}
}
}

View File

@@ -40,12 +40,30 @@ export class NodeIKernelBuddyListener {
}
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 {

View File

@@ -21,7 +21,8 @@ export interface OnRichMediaDownloadCompleteParams {
clientMsg: string,
businessId: number,
userTotalSpacePerDay: unknown,
userUsedSpacePerDay: unknown
userUsedSpacePerDay: unknown,
chatType: number,
}
export interface GroupFileInfoUpdateParamType {
@@ -97,112 +98,112 @@ export interface TempOnRecvParams {
}
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;
eventType: number;
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
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
onBroadcastHelperProgerssUpdate(...args: unknown[]): any {
onBroadcastHelperProgerssUpdate(..._args: unknown[]): any {
}
}

View File

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

View File

@@ -8,7 +8,8 @@ import { LRUCache } from '@/common/lru-cache';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import { ProtoBufDecode } from 'napcat.protobuf';
export const MsgData = new LRUCache<string, string>(5000);
// 0 send 1 recv
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;
@@ -38,12 +39,12 @@ export class NativePacketClient extends IPacketClient {
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 moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node');
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');
if (type === 0 && this.cb.get(trace_id + 'recv')) {
//此时为send 提取seq
@@ -56,6 +57,19 @@ export class NativePacketClient extends IPacketClient {
// console.log('callback:', callback, trace_id);
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.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 { NativePacketClient } from '@/core/packet/client/nativeClient';
import { WsPacketClient } from '@/core/packet/client/wsClient';
import { OidbPacket } from '@/core/packet/transformer/base';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
@@ -10,8 +9,7 @@ type clientPriorityType = {
}
const clientPriority: clientPriorityType = {
10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack),
1: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new WsPacketClient(napCore, logger, logStack),
10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack)
};
export class LogStack {
@@ -88,10 +86,6 @@ export class PacketClientContext {
this.logger.info('使用指定的 NativePacketClient 作为后端');
client = new NativePacketClient(this.napCore, this.logger, this.logStack);
break;
case 'frida':
this.logger.info('[Core] [Packet] 使用指定的 FridaPacketClient 作为后端');
client = new WsPacketClient(this.napCore, this.logger, this.logStack);
break;
case 'auto':
case undefined:
client = this.judgeClient();

View File

@@ -6,13 +6,14 @@ import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgVideoElement
PacketMsgReplyElement,
PacketMsgVideoElement,
} from '@/core/packet/message/element';
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
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 { ImageOcrResult } from '@/core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib';
@@ -68,30 +69,32 @@ export class PacketOperationContext {
}
}
async SetGroupSpecialTitle(groupUin: number, uid: string, tittle: string) {
const req = trans.SetSpecialTitle.build(groupUin, uid, tittle);
async SetGroupSpecialTitle(groupUin: number, uid: string, title: string) {
const req = trans.SetSpecialTitle.build(groupUin, uid, title);
await this.context.client.sendOidbPacket(req);
}
async UploadResources(msg: PacketMsg[], groupUin: number = 0) {
const chatType = groupUin ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C;
const peerUid = groupUin ? String(groupUin) : this.context.napcore.basicInfo.uid;
const reqList = msg.flatMap(m =>
m.msg.map(e => {
if (e instanceof PacketMsgPicElement) {
return this.context.highway.uploadImage({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgVideoElement) {
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgPttElement) {
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgFileElement) {
return this.context.highway.uploadFile({ chatType, peerUid }, e);
}
return null;
}).filter(Boolean)
const reqList = msg.flatMap((m) =>
m.msg
.map((e) => {
if (e instanceof PacketMsgPicElement) {
return this.context.highway.uploadImage({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgVideoElement) {
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgPttElement) {
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgFileElement) {
return this.context.highway.uploadFile({ chatType, peerUid }, e);
}
return null;
})
.filter(Boolean)
);
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) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
@@ -100,10 +103,13 @@ export class PacketOperationContext {
}
async UploadImage(img: PacketMsgPicElement) {
await this.context.highway.uploadImage({
chatType: ChatType.KCHATTYPEC2C,
peerUid: this.context.napcore.basicInfo.uid
}, img);
await this.context.highway.uploadImage(
{
chatType: ChatType.KCHATTYPEC2C,
peerUid: this.context.napcore.basicInfo.uid,
},
img
);
const index = img.msgInfo?.msgInfoBody?.at(0)?.index;
if (!index) {
throw new Error('img.msgInfo?.msgInfoBody![0].index! is undefined');
@@ -137,23 +143,79 @@ export class PacketOperationContext {
coordinates: item.polygon.coordinates.map((c) => {
return {
x: c.x,
y: c.y
y: c.y,
};
}),
};
}),
language: res.ocrRspBody.language
language: res.ocrRspBody.language,
} 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);
}
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 resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.UploadForwardMsg.parse(resp);
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) {
const req = trans.DownloadGroupFile.build(groupUin, fileUUID);
const resp = await this.context.client.sendOidbPacket(req, true);
@@ -189,12 +251,17 @@ export class PacketOperationContext {
return res.content.map((item) => {
return {
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;
const reqMaxTime = 30;
const sessionId = crypto.randomBytes(4).readUInt32BE(0);
@@ -222,6 +289,7 @@ export class PacketOperationContext {
if (!main?.actionData.msgBody) {
throw new Error('msgBody is empty');
}
this.context.logger.debug('rawChains ', inflate.toString('hex'));
const messagesPromises = main.actionData.msgBody.map(async (msg) => {
if (!msg?.body?.richText?.elems) {
@@ -237,12 +305,12 @@ export class PacketOperationContext {
const groupUin = msg?.responseHead.grp?.groupUin ?? 0;
element.picElement = {
...element.picElement,
originImageUrl: await this.GetGroupImageUrl(groupUin, index!)
originImageUrl: await this.GetGroupImageUrl(groupUin, index!),
};
} else {
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;
@@ -255,7 +323,7 @@ export class PacketOperationContext {
elements: elements,
guildId: '',
isOnlineMsg: false,
msgId: '7467703692092974645', // TODO: no necessary
msgId: '7467703692092974645', // TODO: no necessary
msgRandom: '0',
msgSeq: String(msg.contentHead.sequence ?? 0),
msgTime: String(msg.contentHead.timeStamp ?? 0),

View File

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

View File

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

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();
}
build(groupCode: number, uid: string, tittle: string): OidbPacket {
const oidb_0x8FC_2_body = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2_Body).encode({
targetUid: uid,
specialTitle: tittle,
expiredTime: -1,
uinName: tittle
});
build(groupCode: number, uid: string, title: string): OidbPacket {
const oidb_0x8FC_2 = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2).encode({
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);
}

View File

@@ -6,3 +6,5 @@ export { default as GetStrangerInfo } from './GetStrangerInfo';
export { default as SendPoke } from './SendPoke';
export { default as SetSpecialTitle } from './SetSpecialTitle';
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 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 = {
type: ProtoField(1, ScalarType.UINT32),
subType: ProtoField(2, ScalarType.UINT32, true),
divSeq: ProtoField(3, ScalarType.UINT32, true),
msgId: ProtoField(4, ScalarType.UINT32, true),
c2cCmd: ProtoField(3, ScalarType.UINT32, true),
ranDom: ProtoField(4, ScalarType.UINT32, true),
sequence: ProtoField(5, ScalarType.UINT32, true),
timeStamp: ProtoField(6, ScalarType.UINT32, true),
field7: ProtoField(7, ScalarType.UINT64, true),
field8: ProtoField(8, ScalarType.UINT32, true),
field9: ProtoField(9, ScalarType.UINT32, true),
pkgNum: ProtoField(7, ScalarType.UINT64, true),
pkgIndex: ProtoField(8, 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),
forward: ProtoField(15, () => ForwardHead, true),
};

View File

@@ -4,12 +4,12 @@ import { ProtoField, ScalarType } from '@napneko/nap-proto-core';
//设置群头衔 OidbSvcTrpcTcp.0x8fc_2
export const OidbSvcTrpcTcp0X8FC_2_Body = {
targetUid: ProtoField(1, ScalarType.STRING),
specialTitle: ProtoField(5, ScalarType.STRING),
expiredTime: ProtoField(6, ScalarType.SINT32),
uinName: ProtoField(7, ScalarType.STRING),
specialTitle: ProtoField(5, ScalarType.STRING, true),
expiredTime: ProtoField(6, ScalarType.INT32),
uinName: ProtoField(7, ScalarType.STRING, true),
targetName: ProtoField(8, ScalarType.STRING),
};
export const OidbSvcTrpcTcp0X8FC_2 = {
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<
{
categoryId: number,//9999应该跳过 那是兜底数据吧
categoryId: number,//9999为特别关心
categorySortId: number,//排序方式
categroyName: string,//分类名
categroyMbCount: number,//不懂
@@ -106,15 +106,15 @@ export interface NodeIKernelBuddyService {
getAddMeSetting(): unknown;
getDoubtBuddyReq(): unknown;
getDoubtBuddyReq(reqId: string, num: number,uk:string): Promise<GeneralCallResult>;
getDoubtBuddyUnreadNum(): number;
approvalDoubtBuddyReq(uid: number, isAgree: boolean): void;
approvalDoubtBuddyReq(uid: string, str1: string, str2: string): void;
delDoubtBuddyReq(uid: number): void;
delAllDoubtBuddyReq(): void;
delAllDoubtBuddyReq(): Promise<GeneralCallResult>;
reportDoubtBuddyReqUnread(): void;

View File

@@ -425,7 +425,20 @@ export interface NodeIKernelMsgService {
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: {
peerUid: string

View File

@@ -1,5 +1,5 @@
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';
export interface NodeIKernelProfileService {
@@ -15,7 +15,13 @@ export interface NodeIKernelProfileService {
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;

View File

@@ -198,9 +198,29 @@ export interface NodeIKernelRichMediaService {
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(
keywords: Array<string>,

View File

@@ -54,7 +54,7 @@ export interface NodeIKernelSearchService {
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

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'>;
@@ -213,6 +224,9 @@ export interface ReplyElement {
senderUidStr?: string;
replyMsgTime?: 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 {

View File

@@ -501,6 +501,7 @@ export interface RawMessage {
elements: MessageElement[];// 消息元素
sourceType: MsgSourceType;// 消息来源类型
isOnlineMsg: boolean;// 是否为在线消息
clientSeq?: string;
}
/**

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import { NodeIKernelLoginService } from '@/core/services';
import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper';
import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui';
import { NapCatOneBot11Adapter } from '@/onebot';
import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg';
import { FFmpegService } from '@/common/ffmpeg';
//Framework ES入口文件
export async function getWebUiUrl() {
@@ -36,6 +38,15 @@ export async function NCoreInitFramework(
const logger = new LogWrapper(pathWrapper.logsPath);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
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 loginListener = new NodeIKernelLoginListener();

View File

@@ -3,6 +3,7 @@ import Ajv, { ErrorObject, ValidateFunction } from 'ajv';
import { NapCatCore } from '@/core';
import { NapCatOneBot11Adapter, OB11Return } from '@/onebot';
import { NetworkAdapterConfig } from '../config/config';
import { TSchema } from '@sinclair/typebox';
export class OB11Response {
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;
core: NapCatCore;
private validate?: ValidateFunction<unknown> = undefined;
payloadSchema?: unknown = undefined;
payloadSchema?: TSchema = undefined;
obContext: NapCatOneBot11Adapter;
constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
@@ -43,7 +44,7 @@ export abstract class OneBotAction<PayloadType, ReturnDataType> {
protected async check(payload: PayloadType): Promise<BaseCheckResult> {
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)) {
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>;
export class SetSpecialTittle extends GetPacketStatusDepends<Payload, void> {
override actionName = ActionName.SetSpecialTittle;
export class SetSpecialTitle extends GetPacketStatusDepends<Payload, void> {
override actionName = ActionName.SetSpecialTitle;
override payloadSchema = SchemaData;
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 { Static, Type } from '@sinclair/typebox';
import { ChatType, ElementType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
import { isNumeric } from '@/common/helper';
const SchemaData = Type.Object({
message_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
message_id: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
messages: OB11Message[] | undefined;
}> {
@@ -53,19 +53,21 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
}
async _handle(payload: Payload) {
// 1. 检查消息ID是否存在
const msgId = payload.message_id || payload.id;
if (!msgId) {
throw new Error('message_id is required');
}
const fakeForwardMsg = (res_id: string) => {
// 2. 定义辅助函数 - 创建伪转发消息对象
const createFakeForwardMsg = (resId: string): RawMessage => {
return {
chatType: ChatType.KCHATTYPEGROUP,
elements: [{
elementType: ElementType.MULTIFORWARD,
elementId: '',
multiForwardMsgElement: {
resId: res_id,
resId: resId,
fileName: '',
xmlContent: '',
}
@@ -96,8 +98,9 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
} as RawMessage;
};
const protocolFallbackLogic = async (res_id: string) => {
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(fakeForwardMsg(res_id)))?.arrayMsg;
// 3. 定义协议回退逻辑函数
const protocolFallbackLogic = async (resId: string) => {
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId)))?.arrayMsg;
if (ob) {
return {
messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content
@@ -105,31 +108,37 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
}
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 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) {
return await protocolFallbackLogic(msgId.toString());
}
if (rootMsg) {
// 5. 获取消息内容
const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
const singleMsg = data.msgList[0];
if (!singleMsg) {
return await protocolFallbackLogic(msgId.toString());
}
const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;//强制array 以便处理
if (!(resMsg?.message?.[0] as OB11MessageForward)?.data?.content) {
return await protocolFallbackLogic(msgId.toString());
}
return {
messages: (resMsg?.message?.[0] as OB11MessageForward)?.data?.content
};
//}
if (data && data.result === 0 && data.msgList.length > 0) {
const singleMsg = data.msgList[0];
if (!singleMsg) {
throw new Error('消息不存在或已过期');
}
// 6. 解析消息内容
const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;
// 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[];
}
const SchemaData = Type.Object({
user_id: Type.Union([Type.Number(), Type.String()]),
message_seq: Type.Optional(Type.Union([Type.Number(), Type.String()])),
count: Type.Union([Type.Number(), Type.String()], { default: 20 }),
reverseOrder: Type.Optional(Type.Union([Type.Boolean(), Type.String()]))
user_id: Type.String(),
message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }),
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> {
//处理参数
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}不存在`);
const friend = await this.core.apis.FriendApi.isBuddy(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 startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
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 (isReverseOrder) msgList.reverse();
//转换序号
await Promise.all(msgList.map(async msg => {
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({
group_id: Type.Union([Type.Number(), Type.String()]),
message_seq: Type.Optional(Type.Union([Type.Number(), Type.String()])),
count: Type.Union([Type.Number(), Type.String()], { default: 20 }),
reverseOrder: Type.Optional(Type.Union([Type.Boolean(), Type.String()]))
group_id: Type.String(),
message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }),
reverseOrder: Type.Boolean({ default: false })
});
@@ -26,17 +26,13 @@ export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction<Payload, Re
override payloadSchema = SchemaData;
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 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 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 (isReverseOrder) msgList.reverse();
//转换序号
await Promise.all(msgList.map(async msg => {
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({
user_id: Type.Union([Type.Number(), Type.String()]),
no_cache: Type.Union([Type.Boolean(), Type.String()], { default: false }),
});
type Payload = Static<typeof SchemaData>;
@@ -16,10 +17,11 @@ export default class GoCQHTTPGetStrangerInfo extends OneBotAction<Payload, OB11U
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
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);
let uid = (await this.core.apis.UserApi.getUidByUinV2(user_id));
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 {
...extendData.detail.simpleInfo.coreInfo,
...extendData.detail.commonExt ?? {},

View File

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

View File

@@ -38,6 +38,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, null>
deleteAfterSentFiles: []
};
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);
return null;
}

View File

@@ -23,7 +23,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
if (payload.user_id) {
const peerUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
if (!peerUid) {
throw new Error( `私聊${payload.user_id}不存在`);
throw new Error(`私聊${payload.user_id}不存在`);
}
const isBuddy = await this.core.apis.FriendApi.isBuddy(peerUid);
return { chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid };
@@ -48,6 +48,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
deleteAfterSentFiles: []
};
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);
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());
return {
...data,
group_all_shut: data.shutUpAllTimestamp > 0 ? -1 : 0,
group_remark: '',
group_id: +payload.group_id,
group_name: data.groupName,
member_count: data.memberNum,

View File

@@ -32,7 +32,7 @@ class GetGroupMemberInfo extends OneBotAction<Payload, OB11GroupMember> {
const [member, info] = await Promise.all([
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}不存在`);

View File

@@ -81,7 +81,7 @@ import { GetGroupSystemMsg } from './system/GetSystemMsg';
import { GroupPoke } from './group/GroupPoke';
import { GetUserStatus } from './extends/GetUserStatus';
import { GetRkey } from './extends/GetRkey';
import { SetSpecialTittle } from './extends/SetSpecialTittle';
import { SetSpecialTitle } from './extends/SetSpecialTitle';
import { GetGroupShutList } from './group/GetGroupShutList';
import { GetGroupMemberList } from './group/GetGroupMemberList';
import { GetGroupFileUrl } from '@/onebot/action/file/GetGroupFileUrl';
@@ -109,10 +109,24 @@ import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton';
import { GetPrivateFileUrl } from './file/GetPrivateFileUrl';
import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList';
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) {
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 GetGroupInfoEx(obContext, core),
new FetchEmojiLike(obContext, core),
@@ -132,6 +146,9 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new SetGroupSign(obContext, core),
new SendGroupSign(obContext, core),
new GetClientkey(obContext, core),
new MoveGroupFile(obContext, core),
new RenameGroupFile(obContext, core),
new TransGroupFile(obContext, core),
// onebot11
new SendLike(obContext, core),
new GetMsg(obContext, core),
@@ -215,7 +232,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new FriendPoke(obContext, core),
new GetUserStatus(obContext, core),
new GetRkey(obContext, core),
new SetSpecialTittle(obContext, core),
new SetSpecialTitle(obContext, core),
new SetDiyOnlineStatus(obContext, core),
// new UploadForwardMsg(obContext, core),
new GetGroupShutList(obContext, core),
@@ -231,6 +248,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new ClickInlineKeyboardButton(obContext, core),
new GetPrivateFileUrl(obContext, core),
new GetUnidirectionalFriendList(obContext, core),
new CleanCache(obContext, core),
];
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> {
if (!payload) {
throw new Error('请指定 group_id 或 user_id');
throw new Error('请传递请求内容');
}
if ((contextMode === ContextMode.Group || contextMode === ContextMode.Normal) && payload.group_id) {
return {
@@ -48,7 +48,16 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext |
}
if ((contextMode === ContextMode.Private || contextMode === ContextMode.Normal) && payload.user_id) {
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);
if (!isBuddy) {
const ret = await core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, Uid);
@@ -78,7 +87,13 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext |
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 {

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 = {
// 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',
NapCat_GetPrivateFileUrl: 'get_private_file_url',
ClickInlineKeyboardButton: 'click_inline_keyboard_button',
@@ -31,8 +37,9 @@ export const ActionName = {
SetGroupCard: 'set_group_card',
SetGroupName: 'set_group_name',
SetGroupLeave: 'set_group_leave',
SetSpecialTittle: 'set_group_special_title',
SetSpecialTitle: 'set_group_special_title',
SetFriendAddRequest: 'set_friend_add_request',
SetFriendRemark: 'set_friend_remark',
SetGroupAddRequest: 'set_group_add_request',
GetLoginInfo: 'get_login_info',
GoCQHTTP_GetStrangerInfo: 'get_stranger_info',
@@ -52,7 +59,7 @@ export const ActionName = {
GetStatus: 'get_status',
GetVersionInfo: 'get_version_info',
// Reboot : 'set_restart',
// CleanCache : 'clean_cache',
CleanCache : 'clean_cache',
Exit: 'bot_exit',
// go-cqhttp
SetQQProfile: 'set_qq_profile',
@@ -130,6 +137,10 @@ export const ActionName = {
GetRkey: 'nc_get_rkey',
GetGroupShutList: 'get_group_shut_list',
MoveGroupFile: 'move_group_file',
TransGroupFile: 'trans_group_file',
RenameGroupFile: 'rename_group_file',
GetGuildList: 'get_guild_list',
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 payloadSchema = SchemaData;
async _handle(payload: Payload) {
//全新逻辑
return OB11Construct.friends(await this.core.apis.FriendApi.getBuddy(typeof payload.no_cache === 'string' ? payload.no_cache === 'true' : !!payload.no_cache));
async _handle(_payload: Payload) {
const buddyMap = await this.core.apis.FriendApi.getBuddyV2SimpleInfoMap();
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) {
if (msg.senderUin && msg.senderUin !== '0') {
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) {
const newCardName = msg.sendMemberName ?? '';
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) {
const json = JSON.parse(jsonStr);
//判断业务类型
//Poke事件
const pokedetail: Array<{ uid: string }> = json.items;
@@ -151,14 +149,15 @@ export class OneBotGroupApi {
async parseOtherJsonEvent(msg: RawMessage, jsonStr: string, context: InstanceContext) {
const json = JSON.parse(jsonStr);
const type = json.items[json.items.length - 1]?.txt;
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (type === '头衔') {
const memberUin = json.items[1].param[0];
const title = json.items[3].txt;
context.logger.logDebug('收到群成员新头衔消息', json);
return new OB11GroupTitleEvent(
this.core,
parseInt(msg.peerUid),
parseInt(memberUin),
+msg.peerUid,
+memberUin,
title,
);
} else if (type === '移出') {
@@ -251,7 +250,34 @@ export class OneBotGroupApi {
'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) {
if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_GROUP) {
// 解析群组事件 由sysmsg解析
@@ -283,6 +309,9 @@ export class OneBotGroupApi {
return await this.parsePaiYiPai(msg, grayTipElement.jsonGrayTipElement.jsonStr);
} else if (grayTipElement.jsonGrayTipElement.busiId == JsonGrayBusiId.AIO_GROUP_ESSENCE_MSG_TIP) {
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 {
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 { uriToLocalFile } from '@/common/file';
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 { ForwardMsgBuilder } from '@/common/forward-msg-builder';
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 { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest';
import { LRUCache } from '@/common/lru-cache';
import { cleanTaskQueue } from '@/common/clean-task';
type RawToOb11Converters = {
[Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: (
@@ -372,7 +373,8 @@ export class OneBotMsgApi {
try {
multiMsgs = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsg(element.resId);
} 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;
}
}
@@ -465,6 +467,8 @@ export class OneBotMsgApi {
replayMsgId: replyMsg.msgId, // raw.msgId
senderUin: replyMsg.senderUin,
senderUinStr: replyMsg.senderUin,
replyMsgClientSeq: replyMsg.clientSeq,
_replyMsgPeer: replyMsgM.Peer
},
} :
undefined;
@@ -554,7 +558,7 @@ export class OneBotMsgApi {
},
[OB11MessageDataType.voice]: async (sendMsg, context) =>
this.core.apis.FileApi.createValidSendPttElement(
this.core.apis.FileApi.createValidSendPttElement(context,
(await this.handleOb11FileLikeMessage(sendMsg, context)).path),
[OB11MessageDataType.json]: async ({ data: { data } }) => ({
@@ -712,6 +716,56 @@ export class OneBotMsgApi {
this.obContext = obContext;
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) {
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);
resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode);
resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话';
resMsg.temp_source = resMsg.group_id;
resMsg.temp_source = 0;
} else {
resMsg.group_id = 284840486;
resMsg.temp_source = resMsg.group_id;
resMsg.temp_source = 0;
resMsg.sender.nickname = '临时会话';
}
}
@@ -970,7 +1024,6 @@ export class OneBotMsgApi {
});
const timeout = 10000 + (totalSize / 1024 / 256 * 1000);
try {
const returnMsg = await this.core.apis.MsgApi.sendMsg(peer, sendElements, timeout);
if (!returnMsg) throw new Error('发送消息失败');
@@ -983,18 +1036,19 @@ export class OneBotMsgApi {
} catch (error) {
throw new Error((error as Error).message);
} finally {
setTimeout(async () => {
const deletePromises = deleteAfterSentFiles.map(async file => {
try {
if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) {
await fsPromise.unlink(file);
}
} catch (e) {
this.core.context.logger.logError('发送消息删除文件失败', e);
}
});
await Promise.all(deletePromises);
}, 60000);
cleanTaskQueue.addFiles(deleteAfterSentFiles, timeout);
// setTimeout(async () => {
// const deletePromises = deleteAfterSentFiles.map(async file => {
// try {
// if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) {
// await fsPromise.unlink(file);
// }
// } catch (e) {
// this.core.context.logger.logError('发送消息删除文件失败', e);
// }
// });
// await Promise.all(deletePromises);
// }, 60000);
}
}
@@ -1053,6 +1107,8 @@ export class OneBotMsgApi {
return 'kick';
case 3:
return 'kick_me';
case 129:
return 'disband';
default:
return 'kick';
}
@@ -1213,6 +1269,41 @@ export class OneBotMsgApi {
} else if (SysMessage.contentHead.type == 528 && SysMessage.contentHead.subType == 39 && 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;
}
}

View File

@@ -1,7 +1,7 @@
import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent';
import { NapCatCore } from '@/core';
export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me';
export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me' | 'disband';
export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
notice_type = 'group_decrease';

View File

@@ -20,16 +20,36 @@ export class OB11Construct {
static friends(friends: FriendV2[]): OB11User[] {
return friends.map(rawFriend => ({
...rawFriend.baseInfo,
...rawFriend.coreInfo,
birthday_year: rawFriend.baseInfo.birthday_year,
birthday_month: rawFriend.baseInfo.birthday_month,
birthday_day: rawFriend.baseInfo.birthday_day,
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 ?? '',
remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick,
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 {
return {
4: OB11GroupMemberRole.owner,
@@ -73,6 +93,8 @@ export class OB11Construct {
static group(group: Group): OB11Group {
return {
group_all_shut: (+group.groupShutupExpireTime > 0 )? -1 : 0,
group_remark: group.remarkName,
group_id: +group.groupCode,
group_name: group.groupName,
member_count: group.memberCount,

View File

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

View File

@@ -15,14 +15,14 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
messagePostFormat: 'array',
reportSelfMessage: false,
enable: true,
debug: false,
debug: true,
};
super(name, config, core, obContext, actions);
}
onEvent<T extends OB11EmitEventContent>(event: T) {
if (event.post_type === 'message') {
plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch();
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.isEnable = true;
await this.tryConnect();
try {
await this.tryConnect();
} catch (error) {
this.logger.logError('[OneBot] [WebSocket Client] TryConnect Error , info -> ', error);
}
}
close() {

View File

@@ -1,4 +1,10 @@
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
nickname: string; // 昵称
remark?: string; // 备注
@@ -57,6 +63,8 @@ export interface OB11GroupMember {
}
export interface OB11Group {
group_all_shut: number; // 群全员禁言
group_remark: string; // 群备注
group_id: number; // 群ID
group_name: string; // 群名称
member_count?: number; // 成员数量

View File

@@ -180,7 +180,7 @@ export interface OB11MessageNode {
id?: string;
user_id?: number | string; // number
uin?: number | string; // number, compatible with go-cqhttp
nickname: string;
nickname?: string;
name?: string; // compatible with go-cqhttp
content: OB11MessageMixType;
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 { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
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 入口文件
async function handleUncaughtExceptions(logger: LogWrapper) {
process.on('uncaughtException', (err) => {
@@ -114,18 +117,19 @@ async function handleLogin(
quickLoginUin: string | undefined,
historyLoginList: LoginListItem[]
): Promise<SelfInfo> {
let context = { isLogined: false };
let inner_resolve: (value: SelfInfo) => void;
let selfInfo: Promise<SelfInfo> = new Promise((resolve) => {
inner_resolve = resolve;
});
// 连接服务
let isLogined = false;
const loginListener = new NodeIKernelLoginListener();
loginListener.onUserLoggedIn = (userid: string) => {
logger.logError(`当前账号(${userid})已登录,无法重复登录`);
};
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
isLogined = true;
context.isLogined = true;
inner_resolve({
uid: loginResult.uid,
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 }) => {
WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl);
@@ -156,7 +165,7 @@ async function handleLogin(
};
loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => {
if (!isLogined) {
if (!context.isLogined) {
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
if (errType == 1 && errCode == 3) {
// 二维码过期刷新
@@ -168,10 +177,12 @@ async function handleLogin(
loginListener.onLoginFailed = (...args) => {
logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
};
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
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) => {
return await new Promise((resolve) => {
if (uin) {
@@ -197,13 +208,13 @@ async function handleLogin(
.then(result => {
if (result.loginErrorInfo.errMsg) {
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
if (!isLogined) loginService.getQRCodePicture();
if (!context.isLogined) loginService.getQRCodePicture();
}
})
.catch();
} else {
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将使用二维码登录方式');
if (!isLogined) loginService.getQRCodePicture();
if (!context.isLogined) loginService.getQRCodePicture();
}
} else {
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
@@ -214,6 +225,11 @@ async function handleLogin(
}`);
}
loginService.getQRCodePicture();
try {
await WebUiDataRuntime.runWebUiConfigQuickFunction();
} catch (error) {
logger.logError('WebUi 快速登录失败 执行失败', error);
}
}
loginService.getLoginList().then((res) => {
@@ -222,8 +238,6 @@ async function handleLogin(
WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString()));
WebUiDataRuntime.setQQNewLoginList(list);
});
return await selfInfo;
}
async function initializeSession(
@@ -285,7 +299,7 @@ async function waitForNetworkConnection(loginService: NodeIKernelLoginService, l
let network_ok = false;
let tryCount = 0;
while (!network_ok) {
network_ok = loginService.getMsfStatus() === 0;
network_ok = loginService.getMsfStatus() !== 3;// win 11 0连接 1未连接
logger.log('等待网络连接...');
await sleep(500);
tryCount++;
@@ -299,6 +313,16 @@ export async function NCoreInitShell() {
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);
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 wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion());

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