Compare commits

...

202 Commits

Author SHA1 Message Date
linyuchen
54179cb686 fix: can't get qq of the at member 2024-03-16 03:28:43 +08:00
linyuchen
c9a5ee69cf Merge pull request #130 from super1207/main 2024-03-15 21:17:25 +08:00
super1207
e348103e84 新增设置头像的api,set_qq_avatar 2024-03-15 20:45:22 +08:00
linyuchen
fccb0852aa feat: 新增主动获取被过滤的加群通知 2024-03-15 18:54:56 +08:00
linyuchen
b3ea8fbc0c Merge branch 'config-api' into dev
# Conflicts:
#	src/onebot11/action/index.ts
2024-03-15 17:42:15 +08:00
linyuchen
ee483dd0cc Merge branch 'dev' 2024-03-15 17:28:56 +08:00
linyuchen
ed681b8adf feat: 群文件上传事件
feat: 群文件上传接口
2024-03-15 17:28:32 +08:00
linyuchen
dcd4533eb3 Merge branch 'dev' 2024-03-15 14:37:31 +08:00
linyuchen
178c32053b fix: 转发消息id时顺序不对
fix: 以文件名发送文件失败
2024-03-15 14:37:05 +08:00
linyuchen
49ba276f5d fix: 收到的文件没有删干净 2024-03-15 11:41:37 +08:00
linyuchen
2bfe9e236b fix: 独立窗口下撤回消息重复上报 2024-03-15 11:41:11 +08:00
linyuchen
05fd258afd feat: config api 2024-03-13 21:51:17 +08:00
linyuchen
3b3098e017 docs: update plugin description 2024-03-13 11:30:13 +08:00
linyuchen
ddf9eed3a5 docs: update readme 2024-03-13 10:00:14 +08:00
linyuchen
712f0a8256 refactor: auto delete db memory cache 2024-03-13 09:11:58 +08:00
linyuchen
a93220f9d2 Merge branch 'main' into dev
# Conflicts:
#	manifest.json
2024-03-13 08:44:13 +08:00
linyuchen
253cee7458 chore: ver 3.14.1 2024-03-13 04:23:18 +08:00
linyuchen
82f9a4c63f Merge branch 'short-video' 2024-03-13 04:22:25 +08:00
linyuchen
de6c8a5558 feat: send video by videoElement 2024-03-13 04:22:11 +08:00
linyuchen
c75337b8cb fix: 开启独立窗口后重复上报 2024-03-13 01:40:44 +08:00
linyuchen
2b796e33fe test: try to send video element 2024-03-13 01:20:50 +08:00
linyuchen
175307d980 docs: update readme 2024-03-12 09:18:39 +08:00
linyuchen
993f8a9e8f docs: update readme 2024-03-12 09:07:47 +08:00
linyuchen
0130b8f6f7 fix: plugin icon 2024-03-12 08:35:41 +08:00
linyuchen
ba482d492f fix: plugin icon 2024-03-12 08:32:51 +08:00
linyuchen
6e71cd6064 feat: group whole ban event 2024-03-11 11:55:10 +08:00
linyuchen
83dc1abd4a refactor: optimize json parser 2024-03-11 11:44:17 +08:00
linyuchen
4cabb9696e refactor: custom json parser 2024-03-11 10:46:58 +08:00
linyuchen
75883e9cae refactor: refactor new group member event
feat: group ban event
2024-03-11 09:55:50 +08:00
linyuchen
eeadaa12e9 fix: group notify db cache 2024-03-10 22:44:33 +08:00
linyuchen
192736c8be docs: add friend link 2024-03-10 10:05:13 +08:00
linyuchen
586fbb6518 refactor: host input component add attribute parameter 2024-03-10 10:04:09 +08:00
linyuchen
0a42e2df5b Merge remote-tracking branch 'origin/main' 2024-03-10 00:04:37 +08:00
linyuchen
97a637f0c6 chore: ver 3.13.10 2024-03-10 00:04:09 +08:00
linyuchen
3f10b7a002 fix: message real_id use int32 2024-03-10 00:03:37 +08:00
linyuchen
f638e48260 fix: 消息重复入库导致message_id每次都是+2 2024-03-10 00:03:18 +08:00
linyuchen
354ee389bc feat: clean cache api 2024-03-09 22:58:21 +08:00
linyuchen
7188946d7a refactor: try to refactor forward msg 2024-03-09 22:30:16 +08:00
linyuchen
53055e9eab Merge pull request #124 from super1207/main
fix cqcode encode
2024-03-09 22:24:24 +08:00
super1207
7bdb84b11b fix cqcode format 2024-03-09 15:04:09 +08:00
linyuchen
c906bcf7ea docs: update todo list 2024-03-09 12:53:16 +08:00
linyuchen
cdc82562a3 docs: update readme 2024-03-09 12:49:35 +08:00
linyuchen
c34ce8ce0c docs: update readme 2024-03-09 12:40:52 +08:00
linyuchen
d1c94754ee fix: send forward msg too fast 2024-03-09 09:59:39 +08:00
linyuchen
2626555c51 fix: check pic and ptt size 2024-03-09 02:43:58 +08:00
linyuchen
5ff6ceec6d chore: ver 3.13.8 2024-03-09 02:38:05 +08:00
linyuchen
17af156451 fix: update msg seqId 2024-03-09 02:34:21 +08:00
linyuchen
c3c9e74832 fix: image cache url 2024-03-08 23:52:35 +08:00
linyuchen
0480208738 feat: file cache db 2024-03-08 23:27:20 +08:00
linyuchen
62eefbdb69 fix: sent pic message url 2024-03-08 22:16:05 +08:00
linyuchen
566537cbe3 fix: 构建转发消息的文件没有自动删除 2024-03-07 20:19:00 +08:00
linyuchen
ed831ae4cd fix: 网络下载文件大小异常提示 2024-03-07 19:07:00 +08:00
linyuchen
501031b39b fix: 网络视频后缀识别
fix: 网络文件下载失败的错误提示
2024-03-07 18:43:47 +08:00
linyuchen
7bfb3f2003 fix: 私聊带@报错,现已过滤私聊的at消息 2024-03-07 17:37:57 +08:00
linyuchen
ba8ed36c6a fix: can not send reply msg
fix: send like return error
2024-03-07 15:34:09 +08:00
linyuchen
55d046b4f9 fix: reply msg id 2024-03-07 14:18:24 +08:00
linyuchen
ac417cedd3 fix: reply msg id 2024-03-07 10:43:17 +08:00
linyuchen
ade509f26d perf: 优化数据库缓存 2024-03-07 09:00:22 +08:00
linyuchen
a66c1a9779 Merge remote-tracking branch 'origin/main' 2024-03-06 23:33:35 +08:00
linyuchen
239cf18887 fix: init db failed 2024-03-06 23:33:19 +08:00
linyuchen
936554cca2 chore: ver 3.13.2 2024-03-06 22:57:36 +08:00
linyuchen
e1d47f55bf fix: hotfix db initialize failed 2024-03-06 22:55:33 +08:00
linyuchen
cc8e8f108b docs: update readme, file msg add new field name 2024-03-06 22:02:11 +08:00
linyuchen
e6d36dc6c3 fix: send video filename
fix: send msg don't return message_id on Linux
2024-03-06 21:14:13 +08:00
linyuchen
aedc8cfc91 Merge branch 'main' into dev 2024-03-06 20:30:59 +08:00
linyuchen
00c80bf181 feat: send file api add name field 2024-03-06 20:30:26 +08:00
linyuchen
72890a8b59 Merge pull request #111 from lgc2333/patch-1
fix typo
2024-03-06 19:48:53 +08:00
student_2333
3b2577bcad fix typo 2024-03-06 19:46:15 +08:00
linyuchen
063b2460f8 feat: some link on setting ui 2024-03-06 11:44:02 +08:00
linyuchen
9427377f30 Merge remote-tracking branch 'origin/main' 2024-03-06 11:24:58 +08:00
linyuchen
ecff16050a feat: history msg db cache
test: try send music card
2024-03-06 11:24:37 +08:00
手瓜一十雪
873cc6d6a5 docs: update readme 2024-03-05 22:26:57 +08:00
手瓜一十雪
eeb429048b docs: update readme 2024-03-05 22:26:09 +08:00
linyuchen
f240f28ea6 docs: update readme 2024-03-05 11:53:42 +08:00
linyuchen
f993846230 fix: send silk
fix: try get group qq from temp msg
2024-03-05 11:22:12 +08:00
linyuchen
276767e8bb ui: report self message tips 2024-03-05 09:39:55 +08:00
linyuchen
38368afa10 ui: report self message tips 2024-03-05 09:38:43 +08:00
linyuchen
b23170e24f fix: send ws heart packet 2024-03-05 09:28:50 +08:00
linyuchen
3c73826788 refactor: 'heartInterval', 'token', 'ffmpeg' not need auto save 2024-03-05 09:03:38 +08:00
linyuchen
3fb4b6a8da Merge branch 'dev' of github.com:linyuchen/LiteLoaderQQNT-OneBotApi into dev 2024-03-05 08:33:51 +08:00
linyuchen
48768c18a9 fix: same url same path 2024-03-05 08:33:23 +08:00
linyuchen
2fe0df5ab9 Merge pull request #106 from MisaLiu/feat_setting_ui
重写设置页面
2024-03-05 07:22:50 +08:00
Misa Liu
3cea991839 chore: Edit config(s) 2024-03-05 00:25:28 +08:00
Misa Liu
f02ad6f788 feat: Finishing code 2024-03-05 00:25:28 +08:00
Misa Liu
beb372d102 feat: Add style to link(s) 2024-03-05 00:25:27 +08:00
Misa Liu
1cc726bcdc feat: Made open config path button work 2024-03-05 00:25:27 +08:00
Misa Liu
9ff851ebb4 feat: Made ffmpeg select button work 2024-03-05 00:25:27 +08:00
Misa Liu
7d36e49bb2 feat: Made add button of reverse list work 2024-03-05 00:25:26 +08:00
Misa Liu
aec06d37b6 feat: Made reverse host list editable 2024-03-05 00:25:26 +08:00
Misa Liu
68dc2222d4 feat: Made delete work in host list 2024-03-05 00:25:26 +08:00
Misa Liu
0f51db62c9 feat: Generate reverse host list 2024-03-05 00:25:26 +08:00
Misa Liu
67cb8b2f0e fix: Add missing setting option 2024-03-05 00:25:25 +08:00
Misa Liu
9e6ec92628 feat: Made save button work 2024-03-05 00:25:25 +08:00
Misa Liu
afacc79b56 feat: Made select works 2024-03-05 00:25:25 +08:00
Misa Liu
cbb732c778 feat: Made inputs work 2024-03-05 00:25:25 +08:00
Misa Liu
66fbce9e4c fix: Add missing setting option 2024-03-05 00:25:24 +08:00
Misa Liu
a5877fec17 feat: Write config 2024-03-05 00:25:24 +08:00
Misa Liu
9acb0665d8 fix: Missing setting option 2024-03-05 00:25:24 +08:00
Misa Liu
f4fbe198e9 feat: Made switch works (UI) 2024-03-05 00:25:23 +08:00
Misa Liu
b668f948df feat: Init new setting page 2024-03-05 00:25:17 +08:00
Misa Liu
1fc7356628 feat: Create setting components 2024-03-05 00:24:34 +08:00
linyuchen
b672a47d4e Update README.md 2024-03-04 23:56:00 +08:00
linyuchen
cf423972ab chore: ver 3.11.1 2024-03-04 22:59:50 +08:00
linyuchen
91baad9488 chore: ver 3.11.1 2024-03-04 22:59:37 +08:00
linyuchen
8417450c3c fix: group member role change not sync group member list 2024-03-04 22:58:25 +08:00
linyuchen
15fe2837dc fix: gif not work 2024-03-04 21:52:52 +08:00
linyuchen
4a09a51722 fix: http file ext
fix: vite build ws
2024-03-04 21:12:44 +08:00
linyuchen
c22965275c feat: more group member info field 2024-03-03 21:26:27 +08:00
linyuchen
9aeb328952 refactor: remove sample rate from silk encode 2024-03-03 17:05:22 +08:00
linyuchen
d1e4135442 style: format 2024-03-03 00:22:07 +08:00
linyuchen
f7b9d599c3 chore: use electron-vite build 2024-03-02 10:45:42 +08:00
手瓜一十雪
e9e8288f34 Merge pull request #97 from MliKiowa/main
chore: webpack to vite
2024-03-02 00:15:27 +08:00
手瓜一十雪
55a35bbfe1 fix:version 2024-03-02 00:10:05 +08:00
手瓜一十雪
71ab1e6ff0 chore:vite 2024-03-02 00:08:11 +08:00
linyuchen
906fa4c382 Create LICENSE 2024-03-01 23:11:52 +08:00
linyuchen
ebff21affd docs: update support api 2024-03-01 23:05:01 +08:00
linyuchen
912834572b docs: install by termux 2024-03-01 22:40:15 +08:00
linyuchen
96d4f79b83 fix: GitHub action npm install 2024-03-01 22:11:12 +08:00
linyuchen
4aadd81e60 feat: auto delete file when call get_file 2024-03-01 22:00:38 +08:00
linyuchen
57ef8ed3e4 chore: ver 3.11.0 2024-03-01 21:49:32 +08:00
linyuchen
4249f4e088 refactor: remove mention field from at message 2024-03-01 21:44:41 +08:00
linyuchen
3d0b90db35 fix: support get_status online
feat: seconds of auto delete file
refactor: file report
2024-03-01 21:43:05 +08:00
linyuchen
fdaf0e5269 fix: forward recall msg 2024-03-01 03:29:27 +08:00
linyuchen
f23abb1d9c chore: write version that has modified 2024-03-01 03:20:35 +08:00
linyuchen
72aeefd501 chore: auto gen version 2024-03-01 02:54:15 +08:00
linyuchen
f4a53c5aec fix: forward msg by msg id
fix: send wav voice msg
2024-03-01 02:53:33 +08:00
linyuchen
f0790b03bb Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.github/ISSUE_TEMPLATE/bug_report.md
2024-02-29 18:23:38 +08:00
手瓜一十雪
f8bf5afd3d fix:syntax error 2024-02-29 00:50:57 +08:00
手瓜一十雪
66c823e3bd fix:timestamp 2024-02-29 00:48:12 +08:00
linyuchen
8f80da8c5b Merge pull request #94 from MliKiowa/patch-1 2024-02-28 16:04:30 +08:00
手瓜一十雪
1ceee49d1a docs: update readme 2024-02-28 16:01:34 +08:00
Misa Liu
66ca936148 style: Finishing code 2024-02-28 00:16:04 +08:00
Misa Liu
5088112864 feat: Now clean_cache API can delete cache files 2024-02-28 00:16:04 +08:00
Misa Liu
91075e192b feat: Add getFileCacheInfo to NTQQApi 2024-02-28 00:16:03 +08:00
Misa Liu
11108bc13f fix: Fix type 2024-02-28 00:16:03 +08:00
Misa Liu
3ec1134204 feat: Add clean_cache API to OneBot adapter 2024-02-28 00:16:02 +08:00
Misa Liu
de41dab846 fix: Fix a typo 2024-02-28 00:16:02 +08:00
Misa Liu
ededfe0f8c fix: Delete specific IPC channel for cache related APIs 2024-02-28 00:16:02 +08:00
Misa Liu
6548876c74 fix: Use a specific IPC channel for cache related API 2024-02-28 00:16:01 +08:00
Misa Liu
839fd7f1ab feat: Add addCacheScannedPaths to NTQQApi 2024-02-28 00:16:01 +08:00
Misa Liu
2f9cd8ba19 feat: Add getDesktopTmpPath to NTQQApi 2024-02-28 00:16:00 +08:00
Misa Liu
85d648622d feat: Add getHotUpdateCachePath to NTQQApi 2024-02-28 00:16:00 +08:00
Misa Liu
f08c816286 feat: Add clearCache to NTQQApi 2024-02-28 00:16:00 +08:00
Misa Liu
6c267044f0 fix: Use a specific IPC channel for cache related API 2024-02-28 00:15:59 +08:00
Misa Liu
b548fd3f0e feat: Add getCacheSessingPathList to NTQQApi 2024-02-28 00:15:59 +08:00
Misa Liu
f110c2d3df style: Fix typo 2024-02-28 00:15:58 +08:00
Misa Liu
f521873ba7 feat: Add scanCache to NTQQApi 2024-02-28 00:15:58 +08:00
Misa Liu
afe0ff89a7 feat: Add chat cache scan & clear to NTQQApi 2024-02-28 00:15:58 +08:00
linyuchen
c600c38a92 chore: ver 3.10.0 2024-02-27 23:23:53 +08:00
linyuchen
3eda104a78 docs: comment 2024-02-27 20:58:51 +08:00
linyuchen
b8aa3131b0 fix: 群通知重复上报 2024-02-27 20:31:58 +08:00
linyuchen
320aa964f9 doc: update 2024-02-27 20:08:26 +08:00
linyuchen
0fd75b338f feat: 加群邀请上报
fix: 加群和加好友post_type字段改为request
2024-02-27 20:06:20 +08:00
linyuchen
9faa56ec32 refactor: 统一时间戳为毫秒,优化发送消息逻辑代码
fix: 发送文件的文件名保持原样
2024-02-27 19:47:17 +08:00
linyuchen
c636af0b0e chore: ver 3.9.0 2024-02-27 04:10:08 +08:00
linyuchen
b8af582749 docs: update readme 2024-02-27 03:38:03 +08:00
linyuchen
8e09a9e0fd fix: receive video and file 2024-02-27 03:37:52 +08:00
linyuchen
001dfc4db2 docs: update readme 2024-02-27 03:15:25 +08:00
linyuchen
a164884b76 refactor: video and file only support local file uri 2024-02-27 03:15:13 +08:00
linyuchen
58f0a99d0b Merge branch 'main' into dev 2024-02-27 02:47:32 +08:00
linyuchen
528c6061e2 feat: 群管理功能 2024-02-27 02:46:57 +08:00
linyuchen
f5ac499861 feat: 发送视频和文件 2024-02-27 01:28:42 +08:00
linyuchen
621d9df450 docs: update todo list 2024-02-26 23:59:58 +08:00
linyuchen
1f657f3e84 Merge branch 'dev' 2024-02-26 23:24:03 +08:00
linyuchen
329dc433fb refactor: ffmpeg setting ui 2024-02-26 23:22:34 +08:00
linyuchen
90f64ab04e docs: update readme thanks 2024-02-26 22:30:27 +08:00
linyuchen
1583a36c2e Merge remote-tracking branch 'origin/main' 2024-02-26 22:26:38 +08:00
linyuchen
d70e95a451 chore: ver 3.8 2024-02-26 22:25:21 +08:00
linyuchen
c6256abcb2 fix: report image url filed 2024-02-26 22:21:20 +08:00
linyuchen
d57c14a8b9 feat: convert wav by ffmpeg 2024-02-26 22:19:37 +08:00
linyuchen
82268c619c Merge pull request #82 from disymayufei/patch-1 2024-02-26 17:37:53 +08:00
Disy
befdf8571a chore: Update README.md
补全README的一些信息
2024-02-26 16:04:57 +08:00
linyuchen
730294236c feat: convert wav by ffmpeg 2024-02-25 12:46:37 +08:00
linyuchen
d9d7e9e830 feat: auto delete receive file 2024-02-25 02:17:18 +08:00
linyuchen
6170307241 Merge remote-tracking branch 'origin/dev' into dev 2024-02-25 01:28:44 +08:00
linyuchen
138614cc4a feat: 好友请求时间,处理好友请求api 2024-02-25 01:28:15 +08:00
linyuchen
62870576a1 feat: 好友请求时间,处理还有请求api 2024-02-25 01:27:25 +08:00
linyuchen
cfb066971f feat: 上报支持CQCode 2024-02-24 18:27:49 +08:00
linyuchen
4941f0071a Merge remote-tracking branch 'origin/dev' into dev 2024-02-24 17:53:35 +08:00
linyuchen
6e61621f44 Merge pull request #75 from MisaLiu/feat_msg_format
增加对 `event.message_format` 和 CQ 码(仅接收)的支持
2024-02-24 17:47:44 +08:00
linyuchen
eb1a867a0e refactor: senderShowName of forward message 2024-02-24 17:24:30 +08:00
Misa Liu
f9ec7eddf2 feat: Support CQCode message format 2024-02-24 01:06:41 +08:00
Misa Liu
ffdec86209 feat: Add setting section of messagePostFormat 2024-02-24 00:40:45 +08:00
Misa Liu
66de0076d4 feat: Add message_format to message event 2024-02-23 21:48:36 +08:00
linyuchen
2eb0ad589a chore: ver 3.7.0 2024-02-23 19:57:20 +08:00
linyuchen
829aba18f8 feat: 管理员变动事件
feat: 加群事件
feat: 加群请求处理api
feat: 退群api
fix: 回复消息id改为string
2024-02-23 19:56:20 +08:00
linyuchen
67dfd7c22f Merge branch 'main' into dev 2024-02-23 14:06:36 +08:00
linyuchen
27745087ad Merge pull request #69 from MisaLiu/fix_app_version 2024-02-23 11:50:37 +08:00
linyuchen
4ba333b6f5 Merge pull request #68 from MisaLiu/fix_echo 2024-02-23 11:50:28 +08:00
Misa Liu
f4fe26fbe1 fix: Fix app_version in get_version_info 2024-02-23 10:33:15 +08:00
Misa Liu
30e488aeaf fix: Fix var type of echo 2024-02-23 10:22:42 +08:00
linyuchen
1f0dad786c feat: group admin change notice 2024-02-23 04:08:20 +08:00
linyuchen
8dfc71ab6d fix: message id int32 2024-02-22 23:05:07 +08:00
linyuchen
12d1f87ad5 fix: message id int32 2024-02-22 23:02:23 +08:00
linyuchen
b27dadbbca temp save 2024-02-22 22:55:52 +08:00
linyuchen
688624500f docs: tg 2024-02-22 17:35:14 +08:00
linyuchen
eefb919f0f docs: update readme 2024-02-21 22:21:32 +08:00
linyuchen
5044d24ee1 feat: go-cqhttp api get_stranger_info
feat: api send_like
fix: some image of message use base64 instead of http url
2024-02-21 22:19:02 +08:00
linyuchen
7664e746b4 feat: some go-cqhttp feature 2024-02-21 17:17:15 +08:00
linyuchen
ebea755731 perf: log long string 2024-02-21 16:43:10 +08:00
linyuchen
e4508ea5c7 feat: go-cqhttp api send_private_forward_msg & send_group_forward_msg 2024-02-21 16:36:40 +08:00
linyuchen
a98ce843ef chore:Optimized GitHub issue template 2024-02-07 18:13:36 +08:00
102 changed files with 8610 additions and 4359 deletions

View File

@@ -17,7 +17,9 @@ jobs:
node-version: 18
- name: install dependenies
run: npm install
run: |
export ELECTRON_SKIP_BINARY_DOWNLOAD=1
npm install
- name: build
run: npm run build

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
node_modules/
package-lock.json
dist/
out/
.idea/
.DS_Store

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 LLOneBot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

126
README.md
View File

@@ -1,116 +1,52 @@
# LLOneBot API
LiteLoaderQQNT插件使你的NTQQ支持OneBot11协议进行QQ机器人开发
LiteLoaderQQNT的OneBot11协议插件
*注意:本文档对应的是 LiteLoader 1.0.0及以上版本如果你使用的是旧版本请切换到本项目v1分支查看文档*
*V3之后不再需要LLAPI*
TG群<https://t.me/+nLZEnpne-pQ1OWFl>
## 安装方法
1.安装[LiteLoaderQQNT](https://liteloaderqqnt.github.io/guide/install.html)
<https://llonebot.github.io/zh-CN/guide/getting-started>
2.安装本项目插件[OneBotApi](https://github.com/linyuchen/LiteLoaderQQNT-OneBotApi/releases/), 注意本插件2.0以下的版本不支持LiteLoader 1.0.0及以上版本
## 设置界面
*关于插件的安装方法: 下载后解压复制到插件目录*
<img src="./doc/image/setting.png" width="500px" alt="图片名称"/>
*插件目录:`LiteLoaderQQNT/plugins`*
## 支持的API
目前支持的协议
- [x] http调用api
- [x] http事件上报
- [x] 正向websocket
- [x] 反向websocket
主要功能:
- [x] 发送好友消息
- [x] 发送群消息
- [x] 获取好友列表
- [x] 获取群列表
- [x] 获取群成员列表
- [x] 撤回消息
- [x] 上报好友消息
- [x] 上报群消息
- [x] 上报好友、群消息撤回
消息格式支持:
- [x] 文字
- [x] 表情
- [x] 图片
- [x] 引用消息
- [x] @群成员
- [x] 语音(支持mp3、wav等多种音频格式直接发送)
- [x] json消息(只上报)
- [x] 转发消息记录(目前只能发不能收)
- [ ] 红包
- [ ] xml
支持的api:
- [x] get_login_info
- [x] send_msg
- [x] send_group_msg
- [x] send_private_msg
- [x] delete_msg
- [x] get_group_list
- [x] get_group_info
- [x] get_group_member_list
- [x] get_group_member_info
- [x] get_friend_list
- [x] get_msg
- [x] get_version_info
- [x] get_status
- [x] can_send_image
- [x] can_send_record
## 示例
## HTTP 调用示例
![](doc/image/example.jpg)
## 一些坑
## 支持的 api 和功能详情
<details>
<summary>下载了插件但是没有看到在NTQQ中生效</summary>
<br/>
检查是否下载的是插件release的版本如果是源码的话需要自行编译。依然不生效请查阅<a href="https://liteloaderqqnt.github.io/guide/plugins.html">LiteLoaderQQNT的文档</a>
</details>
<br/>
<details>
<summary>调用接口报404</summary>
<br/>
目前没有支持全部的onebot规范接口请检查是否调用了不支持的接口
</details>
<br/>
<details>
<summary>发送不了图片和语音</summary>
<br/>
检查当前操作用户是否有LiteLoaderQQNT/data/LLOneBot的写入权限如Windows把QQ上安装到C盘有可能会出现无权限导致发送失败
</details>
<br/>
<details>
<summary>不支持cq码</summary>
<br/>
cq码已经过时了没有支持的打算(主要是我不用这玩意儿,加上我懒)
</details>
<br/>
<details>
<summary>QQ变得很卡</summary>
<br/>
这是你的群特别多导致的,因为启动后会批量获取群成员列表,获取完之后就正常了
</details>
<br/>
<https://llonebot.github.io/zh-CN/develop/api>
## TODO
- [x] 重构摆脱LLAPI目前调用LLAPI只能在renderer进程调用需重构成在main进程调用
- [x] 支持正、反向websocket感谢@disymayufei的PR
- [x] 转发消息记录
- [ ] 好友点赞api
- [x] 好友点赞api
- [x] 群管理功能,禁言、踢人,改群名片等
- [x] 视频消息
- [x] 文件消息
- [x] 群禁言事件上报
- [x] 优化加群成功事件上报
- [x] 清理缓存api
- [ ] 无头模式
- [ ] 框架对接文档
## onebot11文档
<https://11.onebot.dev/>
## Stargazers over time
[![Stargazers over time](https://starchart.cc/LLOneBot/LLOneBot.svg?variant=adaptive)](https://starchart.cc/LLOneBot/LLOneBot)
## 鸣谢
* [LiteLoaderQQNT](https://liteloaderqqnt.github.io/guide/install.html)
* [LLAPI](https://github.com/Night-stars-1/LiteLoaderQQNT-Plugin-LLAPI)
* [chronocat](https://github.com/chrononeko/chronocat/)
* [koishi-plugin-adapter-onebot](https://github.com/koishijs/koishi-plugin-adapter-onebot)
## 友链
* [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core) 一款用C#实现的NTQQ纯协议跨平台QQ机器人框架

BIN
doc/image/setting.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

71
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,71 @@
import cp from 'vite-plugin-cp';
import "./scripts/gen-version"
const external = ["silk-wasm", "ws",
"level", "classic-level", "abstract-level", "level-supports", "level-transcoder",
"module-error", "catering", "node-gyp-build"];
function genCpModule(module: string) {
return { src: `./node_modules/${module}`, dest: `dist/node_modules/${module}`, flatten: false }
}
let config = {
main: {
build: {
outDir: "dist/main",
emptyOutDir: true,
lib: {
formats: ["cjs"],
entry: { "main": "src/main/main.ts" },
},
rollupOptions: {
external,
input: "src/main/main.ts",
}
},
resolve:{
alias: {
'./lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg'
},
},
plugins: [cp({ targets: [...external.map(genCpModule),
{ src: './manifest.json', dest: 'dist' }, {src: './icon.jpg', dest: 'dist' }]
})]
},
preload: {
// vite config options
build: {
outDir: "dist/preload",
emptyOutDir: true,
lib: {
formats: ["cjs"],
entry: { "preload": "src/preload.ts" },
},
rollupOptions: {
// external: externalAll,
input: "src/preload.ts",
}
},
resolve:{
}
},
renderer: {
// vite config options
build: {
outDir: "dist/renderer",
emptyOutDir: true,
lib: {
formats: ["es"],
entry: { "renderer": "src/renderer/index.ts" },
},
rollupOptions: {
// external: externalAll,
input: "src/renderer/index.ts",
}
},
resolve:{
}
}
}
export default config;

BIN
icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,11 +1,11 @@
{
"manifest_version": 4,
"type": "extension",
"name": "LLOneBot",
"name": "LLOneBot v3.15.1",
"slug": "LLOneBot",
"description": "LiteLoaderQQNT的OneBotApi",
"version": "3.4.0",
"thumbnail": "./icon.png",
"description": "LiteLoaderQQNT的OneBotApi,不支持商店在线更新",
"version": "3.15.1",
"icon": "./icon.jpg",
"authors": [
{
"name": "linyuchen",
@@ -26,8 +26,8 @@
"darwin"
],
"injects": {
"renderer": "./renderer.js",
"main": "./main.js",
"preload": "./preload.js"
"renderer": "./renderer/index.js",
"main": "./main/main.cjs",
"preload": "./preload/preload.cjs"
}
}
}

7540
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,44 @@
{
"name": "llonebot",
"version": "1.0.0",
"type": "module",
"description": "NTQQLiteLoaderOneBotApi",
"main": "dist/main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"postinstall": "cross-env ELECTRON_SKIP_BINARY_DOWNLOAD=1 && npm install electron --no-save",
"build": "npm run build-main && npm run build-preload && npm run build-renderer",
"build-main": "webpack --config webpack.main.config.js",
"build-preload": "webpack --config webpack.preload.config.js",
"build-renderer": "webpack --config webpack.renderer.config.js",
"build-mac": "npm run build && cp manifest.json dist/ && npm run deploy-mac",
"deploy-mac": "cp -r dist/* ~/Library/Containers/com.tencent.qq/Data/LiteLoaderQQNT/plugins/LLOnebot/",
"build-win": "npm run build && cp manifest.json dist/ && npm run deploy-win",
"deploy-win": "cmd /c \"copy dist\\* %USERPROFILE%\\documents\\LiteLoaderQQNT\\plugins\\LLOnebot\\\""
"build": "electron-vite build",
"build-mac": "npm run build && npm run deploy-mac",
"deploy-mac": "cp -r dist/* ~/Library/Containers/com.tencent.qq/Data/LiteLoaderQQNT/plugins/LLOneBot/",
"build-win": "npm run build && npm run deploy-win",
"deploy-win": "cmd /c \"xcopy /C /S /Y dist\\* %USERPROFILE%\\documents\\LiteLoaderQQNT\\plugins\\LLOneBot\\\""
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"json-bigint": "^1.0.0",
"music-metadata": "^8.1.4",
"file-type": "^19.0.0",
"fluent-ffmpeg": "^2.1.2",
"level": "^8.0.1",
"silk-wasm": "^3.2.3",
"utf-8-validate": "^6.0.3",
"uuid": "^9.0.1",
"ws": "^8.16.0"
},
"devDependencies": {
"@babel/preset-env": "^7.23.2",
"@types/express": "^4.17.20",
"@types/node": "^20.11.19",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/node": "^20.11.24",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"cross-env": "^7.0.3",
"ts-loader": "^9.5.0",
"typescript": "^5.2.2",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4"
"@typescript-eslint/eslint-plugin": "^6.4.0",
"electron": "^29.0.1",
"electron-vite": "^2.0.0",
"eslint": "^8.0.1",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
"eslint-plugin-promise": "^6.0.0",
"ts-node": "^10.9.2",
"typescript": "*",
"vite": "^5.1.4",
"vite-plugin-cp": "^4.0.8"
}
}

22
scripts/gen-version.ts Normal file
View File

@@ -0,0 +1,22 @@
import fs from 'fs'
import path from 'path'
import { version } from '../src/version'
const manifestPath = path.join(__dirname, '../manifest.json')
function readManifest (): any {
if (fs.existsSync(manifestPath)) {
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
}
}
function writeManifest (manifest: any) {
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
}
const manifest = readManifest()
if (version !== manifest.version) {
manifest.version = version
manifest.name = `LLOneBot v${version}`
writeManifest(manifest)
}

17
scripts/test/test_db.ts Normal file
View File

@@ -0,0 +1,17 @@
import {Level} from "level"
const db = new Level(process.env["level_db_path"], {valueEncoding: 'json'});
async function getGroupNotify() {
let keys = await db.keys().all();
let result = []
for (const key of keys) {
// console.log(key)
if (key.startsWith("group_notify_")) {
result.push(key)
}
}
return result
}
getGroupNotify().then(console.log)

View File

@@ -1,5 +1,5 @@
import {Peer} from "../ntqqapi/ntcall";
export const CHANNEL_GET_CONFIG = "llonebot_get_config"
export const CHANNEL_SET_CONFIG = "llonebot_set_config"
export const CHANNEL_LOG = "llonebot_log"
export const CHANNEL_GET_CONFIG = 'llonebot_get_config'
export const CHANNEL_SET_CONFIG = 'llonebot_set_config'
export const CHANNEL_LOG = 'llonebot_log'
export const CHANNEL_ERROR = 'llonebot_error'
export const CHANNEL_SELECT_FILE = 'llonebot_select_ffmpeg'

View File

@@ -1,7 +1,11 @@
import fs from "fs";
import {Config, OB11Config} from "./types";
import {Config, OB11Config} from './types';
import {mergeNewProperties} from "./utils";
export const HOOK_LOG = false;
export const ALLOW_SEND_TEMP_MSG = false;
export class ConfigUtil {
private readonly configPath: string;
private config: Config | null = null;
@@ -10,7 +14,7 @@ export class ConfigUtil {
this.configPath = configPath;
}
getConfig(cache=true) {
getConfig(cache = true) {
if (this.config && cache) {
return this.config;
}
@@ -27,7 +31,8 @@ export class ConfigUtil {
enableHttp: true,
enableHttpPost: true,
enableWs: true,
enableWsReverse: false
enableWsReverse: false,
messagePostFormat: "array",
}
let defaultConfig: Config = {
ob11: ob11Default,
@@ -36,7 +41,9 @@ export class ConfigUtil {
enableLocalFile2Url: false,
debug: false,
log: false,
reportSelfMessage: false
reportSelfMessage: false,
autoDeleteFile: false,
autoDeleteFileSecond: 60,
};
if (!fs.existsSync(this.configPath)) {

View File

@@ -1,90 +1,105 @@
import {NTQQApi} from '../ntqqapi/ntcall';
import {Friend, Group, GroupMember, RawMessage, SelfInfo} from "../ntqqapi/types";
import {NTQQApi} from '../ntqqapi/ntcall'
import {
type Friend,
type FriendRequest,
type Group,
type GroupMember,
type GroupNotify,
type RawMessage,
type SelfInfo
} from '../ntqqapi/types'
import {type FileCache, type LLOneBotError} from './types'
import {dbUtil} from "./db";
import {raw} from "express";
import {isNumeric, log} from "./utils";
export const selfInfo: SelfInfo = {
uid: '',
uin: '',
nick: '',
online: true
}
export let groups: Group[] = []
export let friends: Friend[] = []
export let msgHistory: Record<string, RawMessage> = {} // msgId: RawMessage
let globalMsgId = Date.now()
export function addHistoryMsg(msg: RawMessage): boolean{
let existMsg = msgHistory[msg.msgId]
if (existMsg){
Object.assign(existMsg, msg)
msg.msgShortId = existMsg.msgShortId;
return false
}
msg.msgShortId = ++globalMsgId
msgHistory[msg.msgId] = msg
return true
}
export function getHistoryMsgByShortId(shortId: number | string){
// log("getHistoryMsgByShortId", shortId, Object.values(msgHistory).map(m=>m.msgShortId))
return Object.values(msgHistory).find(msg => msg.msgShortId.toString() == shortId.toString())
export let friendRequests: Map<number, FriendRequest> = new Map<number, FriendRequest>()
export const llonebotError: LLOneBotError = {
ffmpegError: '',
otherError: ''
}
export async function getFriend(qq: string): Promise<Friend | undefined> {
let friend = friends.find(friend => friend.uin === qq)
// if (!friend){
// friends = (await NTQQApi.getFriends(true))
// friend = friends.find(friend => friend.uin === qq)
export async function getFriend(uinOrUid: string): Promise<Friend | undefined> {
let filterKey = isNumeric(uinOrUid) ? "uin" : "uid"
let filterValue = uinOrUid
let friend = friends.find(friend => friend[filterKey] === filterValue.toString())
// if (!friend) {
// try {
// friends = (await NTQQApi.getFriends(true))
// friend = friends.find(friend => friend[filterKey] === filterValue.toString())
// } catch (e) {
// // log("刷新好友列表失败", e.stack.toString())
// }
// }
return friend
}
export async function getGroup(qq: string): Promise<Group | undefined> {
let group = groups.find(group => group.groupCode === qq)
// if (!group){
// groups = await NTQQApi.getGroups(true);
// group = groups.find(group => group.groupCode === qq)
// }
let group = groups.find(group => group.groupCode === qq.toString())
if (!group) {
try {
const _groups = await NTQQApi.getGroups(true);
group = _groups.find(group => group.groupCode === qq.toString())
if (group) {
groups.push(group)
}
} catch (e) {
}
}
return group
}
export async function getGroupMember(groupQQ: string, memberQQ: string=null, memberUid: string=null) {
export async function getGroupMember(groupQQ: string | number, memberUinOrUid: string | number) {
groupQQ = groupQQ.toString()
memberUinOrUid = memberUinOrUid.toString()
const group = await getGroup(groupQQ)
if (group) {
let filterFunc: (member: GroupMember) => boolean
if (memberQQ){
filterFunc = member => member.uin === memberQQ
}
else if (memberUid){
filterFunc = member => member.uid === memberUid
}
const filterKey = isNumeric(memberUinOrUid) ? "uin" : "uid"
const filterValue = memberUinOrUid
let filterFunc: (member: GroupMember) => boolean = member => member[filterKey] === filterValue
let member = group.members?.find(filterFunc)
if (!member){
const _members = await NTQQApi.getGroupMembers(groupQQ)
if (_members.length){
group.members = _members
if (!member) {
try {
const _members = await NTQQApi.getGroupMembers(groupQQ)
if (_members.length > 0) {
group.members = _members
}
} catch (e) {
// log("刷新群成员列表失败", e.stack.toString())
}
member = group.members?.find(filterFunc)
}
return member
}
return null
}
export let selfInfo: SelfInfo = {
uid: "",
uin: "",
nick: "",
export async function refreshGroupMembers(groupQQ: string) {
const group = groups.find(group => group.groupCode === groupQQ)
if (group) {
group.members = await NTQQApi.getGroupMembers(groupQQ)
}
}
export function getHistoryMsgBySeq(seq: string) {
return Object.values(msgHistory).find(msg => msg.msgSeq === seq)
}
export let uidMaps:Record<string, string> = {} // 一串加密的字符串(uid) -> qq号
export const uidMaps: Record<string, string> = {} // 一串加密的字符串(uid) -> qq号
export function getUidByUin(uin: string) {
for (const key in uidMaps) {
if (uidMaps[key] === uin) {
return key;
return key
}
}
}
export const version = "v3.4.0"
export let tempGroupCodeMap: Record<string, string> = {} // peerUid => 群号

263
src/common/db.ts Normal file
View File

@@ -0,0 +1,263 @@
import {Level} from "level";
import {type GroupNotify, RawMessage} from "../ntqqapi/types";
import {DATA_DIR, log} from "./utils";
import {selfInfo} from "./data";
import {FileCache} from "./types";
class DBUtil {
public readonly DB_KEY_PREFIX_MSG_ID = "msg_id_";
public readonly DB_KEY_PREFIX_MSG_SHORT_ID = "msg_short_id_";
public readonly DB_KEY_PREFIX_MSG_SEQ_ID = "msg_seq_id_";
public readonly DB_KEY_PREFIX_FILE = "file_";
public readonly DB_KEY_PREFIX_GROUP_NOTIFY = "group_notify_";
public db: Level;
public cache: Record<string, RawMessage | string | FileCache | GroupNotify> = {} // <msg_id_ | msg_short_id_ | msg_seq_id_><id>: RawMessage
private currentShortId: number;
/*
* 数据库结构
* msg_id_101231230999: {} // 长id: RawMessage
* msg_short_id_1: 101231230999 // 短id: 长id
* msg_seq_id_1: 101231230999 // 序列id: 长id
* file_7827DBAFJFW2323.png: {} // 文件名: FileCache
* */
constructor() {
let initCount = 0;
new Promise((resolve, reject) => {
const initDB = () => {
initCount++;
// if (initCount > 50) {
// return reject("init db fail")
// }
try {
if (!selfInfo.uin) {
setTimeout(initDB, 300);
return
}
const DB_PATH = DATA_DIR + `/msg_${selfInfo.uin}`;
this.db = new Level(DB_PATH, {valueEncoding: 'json'});
console.log("llonebot init db success")
resolve(null)
} catch (e) {
console.log("init db fail", e.stack.toString())
setTimeout(initDB, 300);
}
}
initDB();
}).then()
const expiredMilliSecond = 1000 * 60 * 60;
setInterval(() => {
// this.cache = {}
// 清理时间较久的缓存
const now = Date.now()
for (let key in this.cache) {
let message: RawMessage = this.cache[key] as RawMessage;
if (message?.msgTime){
if ((now - (parseInt(message.msgTime) * 1000)) > expiredMilliSecond) {
delete this.cache[key]
// log("clear cache", key, message.msgTime);
}
}
}
}, expiredMilliSecond)
}
private addCache(msg: RawMessage) {
const longIdKey = this.DB_KEY_PREFIX_MSG_ID + msg.msgId
const shortIdKey = this.DB_KEY_PREFIX_MSG_SHORT_ID + msg.msgShortId
const seqIdKey = this.DB_KEY_PREFIX_MSG_SEQ_ID + msg.msgSeq
this.cache[longIdKey] = this.cache[shortIdKey] = msg
}
public clearCache() {
this.cache = {}
}
async getMsgByShortId(shortMsgId: number): Promise<RawMessage> {
const shortMsgIdKey = this.DB_KEY_PREFIX_MSG_SHORT_ID + shortMsgId;
if (this.cache[shortMsgIdKey]) {
// log("getMsgByShortId cache", shortMsgIdKey, this.cache[shortMsgIdKey])
return this.cache[shortMsgIdKey] as RawMessage
}
try {
const longId = await this.db.get(shortMsgIdKey);
const msg = await this.getMsgByLongId(longId)
this.addCache(msg)
return msg
} catch (e) {
log("getMsgByShortId db error", e.stack.toString())
}
}
async getMsgByLongId(longId: string): Promise<RawMessage> {
const longIdKey = this.DB_KEY_PREFIX_MSG_ID + longId;
if (this.cache[longIdKey]) {
return this.cache[longIdKey] as RawMessage
}
try {
const data = await this.db.get(longIdKey)
const msg = JSON.parse(data)
this.addCache(msg)
return msg
} catch (e) {
// log("getMsgByLongId db error", e.stack.toString())
}
}
async getMsgBySeqId(seqId: string): Promise<RawMessage> {
const seqIdKey = this.DB_KEY_PREFIX_MSG_SEQ_ID + seqId;
if (this.cache[seqIdKey]) {
return this.cache[seqIdKey] as RawMessage
}
try {
const longId = await this.db.get(seqIdKey);
const msg = await this.getMsgByLongId(longId)
this.addCache(msg)
return msg
} catch (e) {
log("getMsgBySeqId db error", e.stack.toString())
}
}
async addMsg(msg: RawMessage) {
// 有则更新,无则添加
// log("addMsg", msg.msgId, msg.msgSeq, msg.msgShortId);
const longIdKey = this.DB_KEY_PREFIX_MSG_ID + msg.msgId
let existMsg = this.cache[longIdKey] as RawMessage
if (!existMsg) {
try {
existMsg = await this.getMsgByLongId(msg.msgId)
} catch (e) {
// log("addMsg getMsgByLongId error", e.stack.toString())
}
}
if (existMsg) {
// log("消息已存在", existMsg.msgSeq, existMsg.msgShortId, existMsg.msgId)
this.updateMsg(msg).then()
return existMsg.msgShortId
}
this.addCache(msg);
const shortMsgId = await this.genMsgShortId();
const shortIdKey = this.DB_KEY_PREFIX_MSG_SHORT_ID + shortMsgId;
const seqIdKey = this.DB_KEY_PREFIX_MSG_SEQ_ID + msg.msgSeq;
msg.msgShortId = shortMsgId;
// log("新增消息记录", msg.msgId)
this.db.put(shortIdKey, msg.msgId).then().catch();
this.db.put(longIdKey, JSON.stringify(msg)).then().catch();
try {
await this.db.get(seqIdKey)
} catch (e) {
// log("新的seqId", seqIdKey)
this.db.put(seqIdKey, msg.msgId).then().catch();
}
if (!this.cache[seqIdKey]) {
this.cache[seqIdKey] = msg;
}
return shortMsgId
// log(`消息入库 ${seqIdKey}: ${msg.msgId}, ${shortMsgId}: ${msg.msgId}`);
}
async updateMsg(msg: RawMessage) {
const longIdKey = this.DB_KEY_PREFIX_MSG_ID + msg.msgId
let existMsg = this.cache[longIdKey] as RawMessage
if (!existMsg) {
try {
existMsg = await this.getMsgByLongId(msg.msgId)
} catch (e) {
existMsg = msg
}
}
Object.assign(existMsg, msg)
this.db.put(longIdKey, JSON.stringify(existMsg)).then().catch();
const shortIdKey = this.DB_KEY_PREFIX_MSG_SHORT_ID + existMsg.msgShortId;
const seqIdKey = this.DB_KEY_PREFIX_MSG_SEQ_ID + msg.msgSeq;
if (!this.cache[seqIdKey]) {
this.cache[seqIdKey] = existMsg;
}
this.db.put(shortIdKey, msg.msgId).then().catch();
try {
await this.db.get(seqIdKey)
} catch (e) {
this.db.put(seqIdKey, msg.msgId).then().catch();
// log("更新seqId error", e.stack, seqIdKey);
}
// log("更新消息", existMsg.msgSeq, existMsg.msgShortId, existMsg.msgId);
}
private async genMsgShortId(): Promise<number> {
const key = "msg_current_short_id";
if (this.currentShortId === undefined) {
try {
let id: string = await this.db.get(key);
this.currentShortId = parseInt(id);
} catch (e) {
this.currentShortId = -2147483640
}
}
this.currentShortId++;
this.db.put(key, this.currentShortId.toString()).then().catch();
return this.currentShortId;
}
async addFileCache(fileName: string, data: FileCache) {
const key = this.DB_KEY_PREFIX_FILE + fileName;
if (this.cache[key]) {
return
}
let cacheDBData = {...data}
delete cacheDBData['downloadFunc']
this.cache[fileName] = data;
try {
await this.db.put(key, JSON.stringify(cacheDBData));
} catch (e) {
log("addFileCache db error", e.stack.toString())
}
}
async getFileCache(fileName: string): Promise<FileCache | undefined> {
const key = this.DB_KEY_PREFIX_FILE + fileName;
if (this.cache[key]) {
return this.cache[key] as FileCache
}
try {
let data = await this.db.get(key);
return JSON.parse(data);
} catch (e) {
// log("getFileCache db error", e.stack.toString())
}
}
async addGroupNotify(notify: GroupNotify) {
const key = this.DB_KEY_PREFIX_GROUP_NOTIFY + notify.seq;
let existNotify = this.cache[key] as GroupNotify
if (existNotify) {
return
}
this.cache[key] = notify;
this.db.put(key, JSON.stringify(notify)).then().catch();
}
async getGroupNotify(seq: string): Promise<GroupNotify | undefined> {
const key = this.DB_KEY_PREFIX_GROUP_NOTIFY + seq;
if (this.cache[key]) {
return this.cache[key] as GroupNotify
}
try {
let data = await this.db.get(key);
return JSON.parse(data);
} catch (e) {
// log("getGroupNotify db error", e.stack.toString())
}
}
}
export const dbUtil = new DBUtil();

View File

@@ -1,9 +1,7 @@
import express, {Express, Request, Response} from "express";
import express, {Express, json, Request, Response} from "express";
import {getConfigUtil, log} from "../utils";
import http from "http";
const JSONbig = require('json-bigint')({storeAsString: true});
type RegisterHandler = (res: Response, payload: any) => Promise<any>
export abstract class HttpServerBase {
@@ -13,20 +11,17 @@ export abstract class HttpServerBase {
constructor() {
this.expressAPP = express();
this.expressAPP.use(express.urlencoded({extended: true, limit: "500mb"}));
this.expressAPP.use(express.urlencoded({extended: true, limit: "5000mb"}));
this.expressAPP.use((req, res, next) => {
let data = '';
req.on('data', chunk => {
data += chunk.toString();
});
req.on('end', () => {
if (data) {
try {
// log("receive raw", data)
req.body = JSONbig.parse(data);
} catch (e) {
return next(e);
}
// 兼容处理没有带content-type的请求
// log("req.headers['content-type']", req.headers['content-type'])
req.headers['content-type'] = 'application/json';
const originalJson = express.json({limit: "5000mb"});
// 调用原始的express.json()处理器
originalJson(req, res, (err) => {
if (err) {
log("Error parsing JSON:", err);
return res.status(400).send("Invalid JSON");
}
next();
});
@@ -63,13 +58,13 @@ export abstract class HttpServerBase {
}
stop() {
if (this.server){
if (this.server) {
this.server.close()
this.server = null;
}
}
restart(port: number){
restart(port: number) {
this.stop()
this.start(port)
}
@@ -81,19 +76,20 @@ export abstract class HttpServerBase {
url = "/" + url
}
if (!this.expressAPP[method]){
if (!this.expressAPP[method]) {
const err = `${this.name} register router failed${method} not exist`;
log(err);
throw err;
}
this.expressAPP[method](url, this.authorize, async (req: Request, res: Response) => {
let payload = req.body;
if (method == "get"){
if (method == "get") {
payload = req.query
}
try{
log("收到http请求", url, payload);
try {
res.send(await handler(res, payload))
}catch (e) {
} catch (e) {
this.handleFailed(res, payload, e.stack.toString())
}
});

View File

@@ -1,4 +1,4 @@
import {Server, WebSocket} from "ws";
import {WebSocket, WebSocketServer} from "ws";
import {getConfigUtil, log} from "../utils";
import urlParse from "url";
import {IncomingMessage} from "node:http";
@@ -15,25 +15,25 @@ class WebsocketClientBase {
}
}
onMessage(msg: string){
onMessage(msg: string) {
}
}
export class WebsocketServerBase {
private ws: Server = null;
private ws: WebSocketServer = null;
constructor() {
console.log(`llonebot websocket service started`)
}
start(port: number) {
this.ws = new Server({port});
this.ws.on("connection", (wsClient, req)=>{
this.ws = new WebSocketServer({port});
this.ws.on("connection", (wsClient, req) => {
const url = req.url.split("?").shift()
this.authorize(wsClient, req);
this.onConnect(wsClient, url, req);
wsClient.on("message", async (msg)=>{
wsClient.on("message", async (msg) => {
this.onMessage(wsClient, url, msg.toString())
})
})
@@ -45,7 +45,8 @@ export class WebsocketServerBase {
});
this.ws = null;
}
restart(port: number){
restart(port: number) {
this.stop();
this.start(port);
}
@@ -85,7 +86,7 @@ export class WebsocketServerBase {
}
onMessage(wsClient: WebSocket, url: string, msg: string) {
onMessage(wsClient: WebSocket, url: string, msg: string) {
}

View File

@@ -1,20 +1,37 @@
export interface OB11Config {
httpPort: number
httpHosts: string[]
wsPort: number
wsHosts: string[]
enableHttp?: boolean
enableHttpPost?: boolean
enableWs?: boolean
enableWsReverse?: boolean
httpPort: number
httpHosts: string[]
wsPort: number
wsHosts: string[]
enableHttp?: boolean
enableHttpPost?: boolean
enableWs?: boolean
enableWsReverse?: boolean
messagePostFormat?: 'array' | 'string'
}
export interface Config {
ob11: OB11Config
token?: string
heartInterval?: number // ms
enableLocalFile2Url?: boolean // 开启后本地文件路径图片会转成http链接, 语音会转成base64
debug?: boolean
reportSelfMessage?: boolean
log?: boolean
}
ob11: OB11Config
token?: string
heartInterval?: number // ms
enableLocalFile2Url?: boolean // 开启后本地文件路径图片会转成http链接, 语音会转成base64
debug?: boolean
reportSelfMessage?: boolean
log?: boolean
autoDeleteFile?: boolean
autoDeleteFileSecond?: number
ffmpeg?: string // ffmpeg路径
}
export interface LLOneBotError {
ffmpegError?: string
otherError?: string
}
export interface FileCache {
fileName: string
filePath: string
fileSize: string
url?: string
downloadFunc?: () => Promise<void>
}

View File

@@ -1,21 +1,45 @@
import * as path from "path";
import * as path from "node:path";
import {selfInfo} from "./data";
import {ConfigUtil} from "./config";
import util from "util";
import {encode, getDuration} from "silk-wasm";
import {encode, getDuration, isWav} from "silk-wasm";
import fs from 'fs';
import * as crypto from 'crypto';
import {v4 as uuidv4} from "uuid";
import ffmpeg from "fluent-ffmpeg"
export const CONFIG_DIR = global.LiteLoader.plugins["LLOneBot"].path.data;
export const DATA_DIR = global.LiteLoader.plugins["LLOneBot"].path.data;
export function getConfigUtil() {
const configFilePath = path.join(CONFIG_DIR, `config_${selfInfo.uin}.json`)
const configFilePath = path.join(DATA_DIR, `config_${selfInfo.uin}.json`)
return new ConfigUtil(configFilePath)
}
function truncateString(obj: any, maxLength = 500) {
if (obj !== null && typeof obj === 'object') {
Object.keys(obj).forEach(key => {
if (typeof obj[key] === 'string') {
// 如果是字符串且超过指定长度,则截断
if (obj[key].length > maxLength) {
obj[key] = obj[key].substring(0, maxLength) + '...';
}
} else if (typeof obj[key] === 'object') {
// 如果是对象或数组,则递归调用
truncateString(obj[key], maxLength);
}
});
}
return obj;
}
export function isNumeric(str: string) {
return /^\d+$/.test(str);
}
export function log(...msg: any[]) {
if (!getConfigUtil().getConfig().log) {
return
return //console.log(...msg);
}
let currentDateTime = new Date().toLocaleString();
const date = new Date();
@@ -28,7 +52,8 @@ export function log(...msg: any[]) {
for (let msgItem of msg) {
// 判断是否是对象
if (typeof msgItem === "object") {
logMsg += JSON.stringify(msgItem) + " ";
let obj = JSON.parse(JSON.stringify(msgItem));
logMsg += JSON.stringify(truncateString(obj)) + " ";
continue;
}
logMsg += msgItem + " ";
@@ -36,7 +61,7 @@ export function log(...msg: any[]) {
logMsg = `${currentDateTime} ${userInfo}: ${logMsg}\n\n`
// sendLog(...msg);
// console.log(msg)
fs.appendFile(path.join(CONFIG_DIR, `llonebot-${currentDate}.log`), logMsg, (err: any) => {
fs.appendFile(path.join(DATA_DIR, `llonebot-${currentDate}.log`), logMsg, (err: any) => {
})
}
@@ -118,7 +143,26 @@ export function mergeNewProperties(newObj: any, oldObj: any) {
});
}
export function checkFfmpeg(newPath: string = null): Promise<boolean> {
return new Promise((resolve, reject) => {
if (newPath) {
ffmpeg.setFfmpegPath(newPath);
ffmpeg.getAvailableFormats((err, formats) => {
if (err) {
log('ffmpeg is not installed or not found in PATH:', err);
resolve(false)
} else {
log('ffmpeg is installed.');
resolve(true);
}
})
}
});
}
export async function encodeSilk(filePath: string) {
const fsp = require("fs").promises
function getFileHeader(filePath: string) {
// 定义要读取的字节数
const bytesToRead = 7;
@@ -136,35 +180,76 @@ export async function encodeSilk(filePath: string) {
}
}
async function getAudioSampleRate(filePath: string) {
try {
const mm = await import('music-metadata');
const metadata = await mm.parseFile(filePath);
log(`${filePath}采样率`, metadata.format.sampleRate);
return metadata.format.sampleRate;
} catch (error) {
log(`${filePath}采样率获取失败`, error.stack);
// console.error(error);
}
async function isWavFile(filePath: string) {
return isWav(fs.readFileSync(filePath));
}
// async function getAudioSampleRate(filePath: string) {
// try {
// const mm = await import('music-metadata');
// const metadata = await mm.parseFile(filePath);
// log(`${filePath}采样率`, metadata.format.sampleRate);
// return metadata.format.sampleRate;
// } catch (error) {
// log(`${filePath}采样率获取失败`, error.stack);
// // console.error(error);
// }
// }
try {
const fileName = path.basename(filePath);
const pcm = fs.readFileSync(filePath);
const pttPath = path.join(CONFIG_DIR, uuidv4());
const pttPath = path.join(DATA_DIR, uuidv4());
if (getFileHeader(filePath) !== "02232153494c4b") {
log(`语音文件${filePath}需要转换`)
const sampleRate = await getAudioSampleRate(filePath) || 44100;
const silk = await encode(pcm, sampleRate);
log(`语音文件${filePath}需要转换成silk`)
const _isWav = await isWavFile(filePath);
const wavPath = pttPath + ".wav"
if (!_isWav) {
log(`语音文件${filePath}正在转换成wav`)
// let voiceData = await fsp.readFile(filePath)
await new Promise((resolve, reject) => {
const ffmpegPath = getConfigUtil().getConfig().ffmpeg;
if (ffmpegPath) {
ffmpeg.setFfmpegPath(ffmpegPath);
}
ffmpeg(filePath).toFormat("wav").audioChannels(2).on('end', function () {
log('wav转换完成');
})
.on('error', function (err) {
log(`wav转换出错: `, err.message,);
reject(err);
})
.save(wavPath)
.on("end", () => {
filePath = wavPath
resolve(wavPath);
});
})
}
// const sampleRate = await getAudioSampleRate(filePath) || 0;
// log("音频采样率", sampleRate)
const pcm = fs.readFileSync(filePath);
const silk = await encode(pcm, 0);
fs.writeFileSync(pttPath, silk.data);
log(`语音文件${filePath}转换成功!`)
fs.unlink(wavPath, (err) => { });
log(`语音文件${filePath}转换成功!`, pttPath)
return {
converted: true,
path: pttPath,
duration: silk.duration,
};
} else {
const duration = getDuration(pcm);
const pcm = fs.readFileSync(filePath);
let duration = 0;
try {
duration = getDuration(pcm);
} catch (e) {
log("获取语音文件时长失败", filePath, e.stack)
duration = fs.statSync(filePath).size / 1024 / 3 // 每3kb大约1s
duration = Math.floor(duration)
duration = Math.max(1, duration)
log("使用文件大小估算时长", duration)
}
return {
converted: false,
path: filePath,
@@ -175,4 +260,83 @@ export async function encodeSilk(filePath: string) {
log("convert silk failed", error.stack);
return {};
}
}
}
export async function getVideoInfo(filePath: string) {
const size = fs.statSync(filePath).size;
return new Promise<{ width: number, height: number, time: number, format: string, size: number, filePath: string }>((resolve, reject) => {
ffmpeg(filePath).ffprobe( (err, metadata) => {
if (err) {
reject(err);
} else {
const videoStream = metadata.streams.find(s => s.codec_type === 'video');
if (videoStream) {
console.log(`视频尺寸: ${videoStream.width}x${videoStream.height}`);
} else {
console.log('未找到视频流信息。');
}
resolve({
width: videoStream.width, height: videoStream.height,
time: parseInt(videoStream.duration),
format: metadata.format.format_name,
size,
filePath
});
}
});
})
}
export async function encodeMp4(filePath: string) {
let videoInfo = await getVideoInfo(filePath);
log("视频信息", videoInfo)
if (videoInfo.format.indexOf("mp4") === -1) {
log("视频需要转换为MP4格式", filePath)
// 转成mp4
const newPath: string = await new Promise<string>((resolve, reject) => {
const newPath = filePath + ".mp4"
ffmpeg(filePath)
.toFormat('mp4')
.on('error', (err) => {
reject(`转换视频格式失败: ${err.message}`);
})
.on('end', () => {
log('视频转换为MP4格式完成');
resolve(newPath); // 返回转换后的文件路径
})
.save(newPath);
});
return await getVideoInfo(newPath)
}
return videoInfo
}
export function isNull(value: any) {
return value === undefined || value === null;
}
export function calculateFileMD5(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
// 创建一个流式读取器
const stream = fs.createReadStream(filePath);
const hash = crypto.createHash('md5');
stream.on('data', (data: Buffer) => {
// 当读取到数据时,更新哈希对象的状态
hash.update(data);
});
stream.on('end', () => {
// 文件读取完成,计算哈希
const md5 = hash.digest('hex');
resolve(md5);
});
stream.on('error', (err: Error) => {
// 处理可能的读取错误
reject(err);
});
});
}

14
src/global.d.ts vendored
View File

@@ -1,10 +1,8 @@
import {LLOneBot} from "./preload";
import { type LLOneBot } from './preload'
declare global {
interface Window {
llonebot: typeof llonebot;
LiteLoader: any;
}
}
interface Window {
llonebot: LLOneBot
LiteLoader: any
}
}

View File

@@ -1,21 +1,41 @@
// 运行在 Electron 主进程 下的插件入口
import {BrowserWindow, ipcMain} from 'electron';
import fs from 'fs';
import {BrowserWindow, dialog, ipcMain} from 'electron';
import * as fs from 'node:fs';
import {Config} from "../common/types";
import {CHANNEL_GET_CONFIG, CHANNEL_LOG, CHANNEL_SET_CONFIG,} from "../common/channels";
import {
CHANNEL_ERROR,
CHANNEL_GET_CONFIG,
CHANNEL_LOG,
CHANNEL_SELECT_FILE,
CHANNEL_SET_CONFIG,
} from "../common/channels";
import {ob11WebsocketServer} from "../onebot11/server/ws/WebsocketServer";
import {CONFIG_DIR, getConfigUtil, log} from "../common/utils";
import {addHistoryMsg, getGroupMember, msgHistory, selfInfo} from "../common/data";
import {checkFfmpeg, DATA_DIR, getConfigUtil, log} from "../common/utils";
import {
friendRequests,
getFriend,
getGroup,
getGroupMember,
llonebotError,
refreshGroupMembers,
selfInfo
} from "../common/data";
import {hookNTQQApiCall, hookNTQQApiReceive, ReceiveCmd, registerReceiveHook} from "../ntqqapi/hook";
import {OB11Constructor} from "../onebot11/constructor";
import {NTQQApi} from "../ntqqapi/ntcall";
import {ChatType, RawMessage} from "../ntqqapi/types";
import {ChatType, FriendRequestNotify, GroupNotifies, GroupNotifyTypes, RawMessage} from "../ntqqapi/types";
import {ob11HTTPServer} from "../onebot11/server/http";
import {OB11FriendRecallNoticeEvent} from "../onebot11/event/notice/OB11FriendRecallNoticeEvent";
import {OB11GroupRecallNoticeEvent} from "../onebot11/event/notice/OB11GroupRecallNoticeEvent";
import {postEvent} from "../onebot11/server/postevent";
import {postOB11Event} from "../onebot11/server/postOB11Event";
import {ob11ReverseWebsockets} from "../onebot11/server/ws/ReverseWebsocket";
import {OB11GroupAdminNoticeEvent} from "../onebot11/event/notice/OB11GroupAdminNoticeEvent";
import {OB11GroupRequestEvent} from "../onebot11/event/request/OB11GroupRequest";
import {OB11FriendRequestEvent} from "../onebot11/event/request/OB11FriendRequest";
import * as path from "node:path";
import {dbUtil} from "../common/db";
import {setConfig} from "./setConfig";
let running = false;
@@ -24,111 +44,111 @@ let running = false;
// 加载插件时触发
function onLoad() {
log("llonebot main onLoad");
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, {recursive: true});
}
ipcMain.handle(CHANNEL_GET_CONFIG, (event: any, arg: any) => {
return getConfigUtil().getConfig()
})
ipcMain.on(CHANNEL_SET_CONFIG, (event: any, arg: Config) => {
let oldConfig = getConfigUtil().getConfig();
getConfigUtil().setConfig(arg)
if (arg.ob11.httpPort != oldConfig.ob11.httpPort && arg.ob11.enableHttp) {
ob11HTTPServer.restart(arg.ob11.httpPort);
}
// 判断是否启用或关闭HTTP服务
if (!arg.ob11.enableHttp) {
ob11HTTPServer.stop();
} else {
ob11HTTPServer.start(arg.ob11.httpPort);
}
// 正向ws端口变化重启服务
if (arg.ob11.wsPort != oldConfig.ob11.wsPort) {
ob11WebsocketServer.restart(arg.ob11.wsPort);
}
// 判断是否启用或关闭正向ws
if (arg.ob11.enableWs != oldConfig.ob11.enableWs) {
if (arg.ob11.enableWs) {
ob11WebsocketServer.start(arg.ob11.wsPort);
} else {
ob11WebsocketServer.stop();
}
}
// 判断是否启用或关闭反向ws
if (arg.ob11.enableWsReverse != oldConfig.ob11.enableWsReverse) {
if (arg.ob11.enableWsReverse) {
ob11ReverseWebsockets.start();
} else {
ob11ReverseWebsockets.stop();
}
}
if (arg.ob11.enableWsReverse) {
// 判断反向ws地址有变化
if (arg.ob11.wsHosts.length != oldConfig.ob11.wsHosts.length) {
ob11ReverseWebsockets.restart();
} else {
for (const newHost of arg.ob11.wsHosts) {
if (!oldConfig.ob11.wsHosts.includes(newHost)) {
ob11ReverseWebsockets.restart();
break;
ipcMain.handle(CHANNEL_SELECT_FILE, async (event, arg) => {
const selectPath = new Promise<string>((resolve, reject) => {
dialog
.showOpenDialog({
title: "请选择ffmpeg",
properties: ["openFile"],
buttonLabel: "确定",
})
.then((result) => {
log("选择文件", result);
if (!result.canceled) {
const _selectPath = path.join(result.filePaths[0]);
resolve(_selectPath);
// let config = getConfigUtil().getConfig()
// config.ffmpeg = path.join(result.filePaths[0]);
// getConfigUtil().setConfig(config);
}
}
}
resolve("")
})
.catch((err) => {
reject(err);
});
})
try {
return await selectPath;
} catch (e) {
log("选择文件出错", e)
return ""
}
})
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, {recursive: true});
}
ipcMain.handle(CHANNEL_ERROR, (event, arg) => {
return llonebotError;
})
ipcMain.handle(CHANNEL_GET_CONFIG, async (event, arg) => {
const config = getConfigUtil().getConfig()
return config;
})
ipcMain.on(CHANNEL_SET_CONFIG, (event, config: Config) => {
setConfig(config).then();
})
ipcMain.on(CHANNEL_LOG, (event: any, arg: any) => {
ipcMain.on(CHANNEL_LOG, (event, arg) => {
log(arg);
})
function postReceiveMsg(msgList: RawMessage[]) {
async function postReceiveMsg(msgList: RawMessage[]) {
const {debug, reportSelfMessage} = getConfigUtil().getConfig();
for (let message of msgList) {
// log("收到新消息", message)
message.msgShortId = msgHistory[message.msgId]?.msgShortId
if (!message.msgShortId) {
addHistoryMsg(message);
}
// log("收到新消息", message.msgId, message.msgSeq)
// if (message.senderUin !== selfInfo.uin){
message.msgShortId = await dbUtil.addMsg(message);
// }
OB11Constructor.message(message).then((msg) => {
if (debug) {
msg.raw = message;
}
if (msg.user_id.toString() == selfInfo.uin && !reportSelfMessage) {
const isSelfMsg = msg.user_id.toString() == selfInfo.uin
if (isSelfMsg && !reportSelfMessage) {
return
}
postEvent(msg);
postOB11Event(msg);
// log("post msg", msg)
}).catch(e => log("constructMessage error: ", e.toString()));
}).catch(e => log("constructMessage error: ", e.stack.toString()));
OB11Constructor.GroupEvent(message).then(groupEvent => {
if (groupEvent) {
// log("post group event", groupEvent);
postOB11Event(groupEvent);
}
})
}
}
async function start() {
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, (payload) => {
async function startReceiveHook() {
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, async (payload) => {
try {
postReceiveMsg(payload.msgList);
await postReceiveMsg(payload.msgList);
} catch (e) {
log("report message error: ", e.toString());
log("report message error: ", e.stack.toString());
}
})
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.UPDATE_MSG, async (payload) => {
for (const message of payload.msgList) {
// log("message update", message.sendStatus, message)
if (message.recallTime != "0") {
// log("message update", message.sendStatus, message.msgId, message.msgSeq)
if (message.recallTime != "0") { //todo: 这个判断方法不太好,应该使用灰色消息元素来判断
// 撤回消息上报
const oriMessage = msgHistory[message.msgId]
const oriMessage = await dbUtil.getMsgByLongId(message.msgId)
if (!oriMessage) {
continue
}
oriMessage.recallTime = message.recallTime
dbUtil.updateMsg(oriMessage).then();
if (message.chatType == ChatType.friend) {
const friendRecallEvent = new OB11FriendRecallNoticeEvent(parseInt(message.senderUin), oriMessage.msgShortId);
postEvent(friendRecallEvent);
postOB11Event(friendRecallEvent);
} else if (message.chatType == ChatType.group) {
let operatorId = message.senderUin
for (const element of message.elements) {
const operatorUid = element.grayTipElement?.revokeElement.operatorUid
const operator = await getGroupMember(message.peerUin, null, operatorUid)
const operator = await getGroupMember(message.peerUin, operatorUid)
operatorId = operator.uin
}
const groupRecallEvent = new OB11GroupRecallNoticeEvent(
@@ -138,27 +158,154 @@ function onLoad() {
oriMessage.msgShortId
)
postEvent(groupRecallEvent);
postOB11Event(groupRecallEvent);
}
// 不让入库覆盖原来消息,不然就获取不到撤回的消息内容了
continue
}
addHistoryMsg(message)
dbUtil.updateMsg(message).then();
}
})
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, (payload) => {
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, async (payload) => {
const {reportSelfMessage} = getConfigUtil().getConfig();
if (!reportSelfMessage) {
return
}
// log("reportSelfMessage", payload)
try {
postReceiveMsg([payload.msgRecord]);
await postReceiveMsg([payload.msgRecord]);
} catch (e) {
log("report self message error: ", e.toString());
log("report self message error: ", e.stack.toString());
}
})
registerReceiveHook<{
"doubt": boolean,
"oldestUnreadSeq": string,
"unreadCount": number
}>(ReceiveCmd.UNREAD_GROUP_NOTIFY, async (payload) => {
if (payload.unreadCount) {
// log("开始获取群通知详情")
let notify: GroupNotifies;
try {
notify = await NTQQApi.getGroupNotifies();
} catch (e) {
// log("获取群通知详情失败", e);
return
}
const notifies = notify.notifies.slice(0, payload.unreadCount)
// log("获取群通知详情完成", notifies, payload);
for (const notify of notifies) {
try {
notify.time = Date.now();
// const notifyTime = parseInt(notify.seq) / 1000
// log(`加群通知时间${notifyTime}`, `LLOneBot启动时间${startTime}`);
// if (notifyTime < startTime) {
// continue;
// }
let existNotify = await dbUtil.getGroupNotify(notify.seq);
if (existNotify) {
continue
}
log("收到群通知", notify);
await dbUtil.addGroupNotify(notify);
// let member2: GroupMember;
// if (notify.user2.uid) {
// member2 = await getGroupMember(notify.group.groupCode, null, notify.user2.uid);
// }
if ([GroupNotifyTypes.ADMIN_SET, GroupNotifyTypes.ADMIN_UNSET].includes(notify.type)) {
const member1 = await getGroupMember(notify.group.groupCode, notify.user1.uid);
log("有管理员变动通知");
refreshGroupMembers(notify.group.groupCode).then()
let groupAdminNoticeEvent = new OB11GroupAdminNoticeEvent()
groupAdminNoticeEvent.group_id = parseInt(notify.group.groupCode);
log("开始获取变动的管理员")
if (member1) {
log("变动管理员获取成功")
groupAdminNoticeEvent.user_id = parseInt(member1.uin);
groupAdminNoticeEvent.sub_type = notify.type == GroupNotifyTypes.ADMIN_UNSET ? "unset" : "set";
postOB11Event(groupAdminNoticeEvent, true);
} else {
log("获取群通知的成员信息失败", notify, getGroup(notify.group.groupCode));
}
} else if (notify.type == GroupNotifyTypes.MEMBER_EXIT) {
// log("有成员退出通知");
// const member1 = await getGroupMember(notify.group.groupCode, null, notify.user1.uid);
// let groupDecreaseEvent = new OB11GroupDecreaseEvent(parseInt(notify.group.groupCode), parseInt(member1.uin))
// postEvent(groupDecreaseEvent, true);
} else if ([GroupNotifyTypes.JOIN_REQUEST].includes(notify.type)) {
log("有加群请求");
let groupRequestEvent = new OB11GroupRequestEvent();
groupRequestEvent.group_id = parseInt(notify.group.groupCode);
let requestQQ = ""
try {
requestQQ = (await NTQQApi.getUserDetailInfo(notify.user1.uid)).uin;
} catch (e) {
log("获取加群人QQ号失败", e)
}
groupRequestEvent.user_id = parseInt(requestQQ) || 0;
groupRequestEvent.sub_type = "add"
groupRequestEvent.comment = notify.postscript;
groupRequestEvent.flag = notify.seq;
postOB11Event(groupRequestEvent);
} else if (notify.type == GroupNotifyTypes.INVITE_ME) {
log("收到邀请我加群通知")
let groupInviteEvent = new OB11GroupRequestEvent();
groupInviteEvent.group_id = parseInt(notify.group.groupCode);
let user_id = (await getFriend(notify.user2.uid))?.uin
if (!user_id) {
user_id = (await NTQQApi.getUserDetailInfo(notify.user2.uid))?.uin
}
groupInviteEvent.user_id = parseInt(user_id);
groupInviteEvent.sub_type = "invite";
groupInviteEvent.flag = notify.seq;
postOB11Event(groupInviteEvent);
}
} catch (e) {
log("解析群通知失败", e.stack.toString());
}
}
} else if (payload.doubt) {
// 可能有群管理员变动
}
})
registerReceiveHook<FriendRequestNotify>(ReceiveCmd.FRIEND_REQUEST, async (payload) => {
for (const req of payload.data.buddyReqs) {
if (req.isUnread && !friendRequests[req.sourceId] && (parseInt(req.reqTime) > startTime / 1000)) {
friendRequests[req.sourceId] = req;
log("有新的好友请求", req);
let friendRequestEvent = new OB11FriendRequestEvent();
try {
let requester = await NTQQApi.getUserDetailInfo(req.friendUid)
friendRequestEvent.user_id = parseInt(requester.uin);
} catch (e) {
log("获取加好友者QQ号失败", e);
}
friendRequestEvent.flag = req.sourceId.toString();
friendRequestEvent.comment = req.extWords;
postOB11Event(friendRequestEvent);
}
}
})
}
let startTime = 0;
async function start() {
log("llonebot pid", process.pid)
startTime = Date.now();
startReceiveHook().then();
NTQQApi.getGroups(true).then()
const config = getConfigUtil().getConfig()
// 检查ffmpeg
checkFfmpeg(config.ffmpeg).then(exist => {
if (!exist) {
llonebotError.ffmpegError = `没有找到ffmpeg,音频只能发送wav和silk`
}
})
if (config.ob11.enableHttp) {
try {
ob11HTTPServer.start(config.ob11.httpPort)
@@ -166,33 +313,39 @@ function onLoad() {
log("http server start failed", e);
}
}
if (config.ob11.enableWs){
if (config.ob11.enableWs) {
ob11WebsocketServer.start(config.ob11.wsPort);
}
if (config.ob11.enableWsReverse){
if (config.ob11.enableWsReverse) {
ob11ReverseWebsockets.start();
}
log("LLOneBot start")
}
let getSelfNickCount = 0;
const init = async () => {
try {
log("start get self info")
const _ = await NTQQApi.getSelfInfo();
log("get self info api result:", _);
Object.assign(selfInfo, _);
selfInfo.nick = selfInfo.uin;
log("get self simple info", _);
} catch (e) {
log("retry get self info");
log("retry get self info", e);
}
log("self info", selfInfo);
if (selfInfo.uin) {
try {
const userInfo = (await NTQQApi.getUserInfo(selfInfo.uid));
const userInfo = (await NTQQApi.getUserDetailInfo(selfInfo.uid));
log("self info", userInfo);
if (userInfo) {
selfInfo.nick = userInfo.nick;
} else {
return setTimeout(init, 1000);
getSelfNickCount++;
if (getSelfNickCount < 10) {
return setTimeout(init, 1000);
}
}
} catch (e) {
log("get self nickname failed", e.toString());
@@ -209,6 +362,10 @@ function onLoad() {
// 创建窗口时触发
function onBrowserWindowCreated(window: BrowserWindow) {
if (selfInfo.uid) {
return
}
log("window create", window.webContents.getURL().toString())
try {
hookNTQQApiCall(window);
hookNTQQApiReceive(window);

63
src/main/setConfig.ts Normal file
View File

@@ -0,0 +1,63 @@
import {Config} from "../common/types";
import {checkFfmpeg, getConfigUtil} from "../common/utils";
import {ob11HTTPServer} from "../onebot11/server/http";
import {ob11WebsocketServer} from "../onebot11/server/ws/WebsocketServer";
import {ob11ReverseWebsockets} from "../onebot11/server/ws/ReverseWebsocket";
import {llonebotError} from "../common/data";
export async function setConfig(config: Config) {
let oldConfig = getConfigUtil().getConfig();
getConfigUtil().setConfig(config)
if (config.ob11.httpPort != oldConfig.ob11.httpPort && config.ob11.enableHttp) {
ob11HTTPServer.restart(config.ob11.httpPort);
}
// 判断是否启用或关闭HTTP服务
if (!config.ob11.enableHttp) {
ob11HTTPServer.stop();
} else {
ob11HTTPServer.start(config.ob11.httpPort);
}
// 正向ws端口变化重启服务
if (config.ob11.wsPort != oldConfig.ob11.wsPort) {
ob11WebsocketServer.restart(config.ob11.wsPort);
}
// 判断是否启用或关闭正向ws
if (config.ob11.enableWs != oldConfig.ob11.enableWs) {
if (config.ob11.enableWs) {
ob11WebsocketServer.start(config.ob11.wsPort);
} else {
ob11WebsocketServer.stop();
}
}
// 判断是否启用或关闭反向ws
if (config.ob11.enableWsReverse != oldConfig.ob11.enableWsReverse) {
if (config.ob11.enableWsReverse) {
ob11ReverseWebsockets.start();
} else {
ob11ReverseWebsockets.stop();
}
}
if (config.ob11.enableWsReverse) {
// 判断反向ws地址有变化
if (config.ob11.wsHosts.length != oldConfig.ob11.wsHosts.length) {
ob11ReverseWebsockets.restart();
} else {
for (const newHost of config.ob11.wsHosts) {
if (!oldConfig.ob11.wsHosts.includes(newHost)) {
ob11ReverseWebsockets.restart();
break;
}
}
}
}
// 检查ffmpeg
if (config.ffmpeg) {
checkFfmpeg(config.ffmpeg).then(success => {
if (success) {
llonebotError.ffmpegError = ''
}
})
}
}

View File

@@ -1,15 +1,20 @@
import {
AtType,
ElementType,
PicType,
SendArkElement,
SendFaceElement,
SendFileElement,
SendPicElement,
SendPttElement,
SendReplyElement,
SendTextElement
SendTextElement,
SendVideoElement
} from "./types";
import {NTQQApi} from "./ntcall";
import {encodeSilk, log} from "../common/utils";
import fs from "fs";
import {calculateFileMD5, encodeSilk, getVideoInfo, isGIF, log, sleep} from "../common/utils";
import {promises as fs} from "node:fs";
import ffmpeg from "fluent-ffmpeg"
export class SendMsgElementConstructor {
@@ -55,7 +60,10 @@ export class SendMsgElementConstructor {
}
static async pic(picPath: string): Promise<SendPicElement> {
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(picPath);
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(picPath, ElementType.PIC);
if (fileSize === 0) {
throw "文件异常大小为0";
}
const imageSize = await NTQQApi.getImageSize(picPath);
const picElement = {
md5HexStr: md5,
@@ -65,7 +73,7 @@ export class SendMsgElementConstructor {
fileName: fileName,
sourcePath: path,
original: true,
picType: 1001,
picType: isGIF(picPath) ? PicType.gif : PicType.jpg,
picSubType: 0,
fileUuid: "",
fileSubId: "",
@@ -80,12 +88,101 @@ export class SendMsgElementConstructor {
};
}
static async file(filePath: string, fileName: string = ""): Promise<SendFileElement> {
const {md5, fileName: _fileName, path, fileSize} = await NTQQApi.uploadFile(filePath, ElementType.FILE);
if (fileSize === 0) {
throw "文件异常大小为0";
}
let element: SendFileElement = {
elementType: ElementType.FILE,
elementId: "",
fileElement: {
fileName: fileName || _fileName,
"filePath": path,
"fileSize": (fileSize).toString(),
}
}
return element;
}
static async video(filePath: string, fileName: string = ""): Promise<SendVideoElement> {
let {fileName: _fileName, path, fileSize, md5} = await NTQQApi.uploadFile(filePath, ElementType.VIDEO);
if (fileSize === 0) {
throw "文件异常大小为0";
}
// const videoInfo = await encodeMp4(path);
// path = videoInfo.filePath
// md5 = videoInfo.md5;
// fileSize = videoInfo.size;
// log("上传视频", md5, path, fileSize, fileName || _fileName)
const pathLib = require("path");
let thumb = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`)
thumb = pathLib.dirname(thumb)
// log("thumb 目录", thumb)
const videoInfo = await getVideoInfo(path);
log("视频信息", videoInfo)
const createThumb = new Promise<string>((resolve, reject) => {
const thumbFileName = `${md5}_0.png`
ffmpeg(filePath)
.on("end", () => {
})
.on("error", (err) => {
reject(err);
})
.screenshots({
timestamps: [0],
filename: thumbFileName,
folder: thumb,
size: videoInfo.width + "x" + videoInfo.height
}).on("end", () => {
resolve(pathLib.join(thumb, thumbFileName));
});
})
let thumbPath = new Map()
const _thumbPath = await createThumb;
const thumbSize = (await fs.stat(_thumbPath)).size;
// log("生成缩略图", _thumbPath)
thumbPath.set(0, _thumbPath)
const thumbMd5 = await calculateFileMD5(_thumbPath);
let element: SendVideoElement = {
elementType: ElementType.VIDEO,
elementId: "",
videoElement: {
fileName: fileName || _fileName,
filePath: path,
videoMd5: md5,
thumbMd5,
fileTime: videoInfo.time,
thumbPath: thumbPath,
thumbSize,
thumbWidth: videoInfo.width,
thumbHeight: videoInfo.height,
fileSize: "" + fileSize,
// fileUuid: "",
// transferStatus: 0,
// progress: 0,
// invalidState: 0,
// fileSubId: "",
// fileBizId: null,
// originVideoMd5: "",
// fileFormat: 2,
// import_rich_media_context: null,
// sourceVideoCodecFormat: 2
}
}
return element;
}
static async ptt(pttPath: string): Promise<SendPttElement> {
const {converted, path: silkPath, duration} = await encodeSilk(pttPath);
// log("生成语音", silkPath, duration);
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(silkPath);
if (converted){
fs.unlink(silkPath, ()=>{});
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(silkPath, ElementType.PTT);
if (fileSize === 0) {
throw "文件异常大小为0";
}
if (converted) {
fs.unlink(silkPath).then();
}
return {
elementType: ElementType.PTT,
@@ -121,4 +218,12 @@ export class SendMsgElementConstructor {
}
}
}
static ark(data: any): SendArkElement {
return {
elementType: ElementType.ARK,
elementId: "",
arkElement: data
}
}
}

View File

@@ -1,12 +1,15 @@
import {BrowserWindow} from 'electron';
import {log, sleep} from "../common/utils";
import {getConfigUtil, log, sleep} from "../common/utils";
import {NTQQApi, NTQQApiClass, sendMessagePool} from "./ntcall";
import {Group, RawMessage, User} from "./types";
import {addHistoryMsg, friends, groups, msgHistory} from "../common/data";
import {friends, groups, selfInfo, tempGroupCodeMap} from "../common/data";
import {OB11GroupDecreaseEvent} from "../onebot11/event/notice/OB11GroupDecreaseEvent";
import {OB11GroupIncreaseEvent} from "../onebot11/event/notice/OB11GroupIncreaseEvent";
import {v4 as uuidv4} from "uuid"
import {postEvent} from "../onebot11/server/postevent";
import {postOB11Event} from "../onebot11/server/postOB11Event";
import {HOOK_LOG} from "../common/config";
import fs from "fs";
import {dbUtil} from "../common/db";
export let hookApiCallbacks: Record<string, (apiReturn: any) => void> = {}
@@ -15,9 +18,17 @@ export enum ReceiveCmd {
NEW_MSG = "nodeIKernelMsgListener/onRecvMsg",
SELF_SEND_MSG = "nodeIKernelMsgListener/onAddSendMsg",
USER_INFO = "nodeIKernelProfileListener/onProfileSimpleChanged",
USER_DETAIL_INFO = "nodeIKernelProfileListener/onProfileDetailInfoChanged",
GROUPS = "nodeIKernelGroupListener/onGroupListUpdate",
GROUPS_UNIX = "onGroupListUpdate",
FRIENDS = "onBuddyListChange"
FRIENDS = "onBuddyListChange",
MEDIA_DOWNLOAD_COMPLETE = "nodeIKernelMsgListener/onRichMediaDownloadComplete",
UNREAD_GROUP_NOTIFY = "nodeIKernelGroupListener/onGroupNotifiesUnreadCountUpdated",
GROUP_NOTIFY = "nodeIKernelGroupListener/onGroupSingleScreenNotifies",
FRIEND_REQUEST = "nodeIKernelBuddyListener/onBuddyReqChange",
SELF_STATUS = 'nodeIKernelProfileListener/onSelfStatusChanged',
CACHE_SCAN_FINISH = "nodeIKernelStorageCleanListener/onFinishScan",
MEDIA_UPLOAD_COMPLETE = "nodeIKernelMsgListener/onRichMediaUploadComplete",
}
interface NTQQApiReturnData<PayloadType = unknown> extends Array<any> {
@@ -43,7 +54,7 @@ let receiveHooks: Array<{
export function hookNTQQApiReceive(window: BrowserWindow) {
const originalSend = window.webContents.send;
const patchSend = (channel: string, ...args: NTQQApiReturnData) => {
// log(`received ntqq api message: ${channel}`, JSON.stringify(args))
HOOK_LOG && log(`received ntqq api message: ${channel}`, JSON.stringify(args))
if (args?.[1] instanceof Array) {
for (let receiveData of args?.[1]) {
const ntQQApiMethodName = receiveData.cmdName;
@@ -87,15 +98,15 @@ export function hookNTQQApiCall(window: BrowserWindow) {
const proxyIpcMsg = new Proxy(ipc_message_proxy, {
apply(target, thisArg, args) {
log("call NTQQ api", thisArg, args);
HOOK_LOG && log("call NTQQ api", thisArg, args);
return target.apply(thisArg, args);
},
});
// if (webContents._events["-ipc-message"]?.[0]) {
// webContents._events["-ipc-message"][0] = proxyIpcMsg;
// } else {
// webContents._events["-ipc-message"] = proxyIpcMsg;
// }
if (webContents._events["-ipc-message"]?.[0]) {
webContents._events["-ipc-message"][0] = proxyIpcMsg;
} else {
webContents._events["-ipc-message"] = proxyIpcMsg;
}
}
export function registerReceiveHook<PayloadType>(method: ReceiveCmd, hookFunc: (payload: PayloadType) => void): string {
@@ -118,8 +129,7 @@ async function updateGroups(_groups: Group[], needUpdate: boolean = true) {
let existGroup = groups.find(g => g.groupCode == group.groupCode);
if (existGroup) {
Object.assign(existGroup, group);
}
else {
} else {
groups.push(group);
existGroup = group;
}
@@ -155,26 +165,7 @@ async function processGroupEvent(payload) {
for (const member of oldMembers) {
if (!newMembersSet.has(member.uin)) {
postEvent(new OB11GroupDecreaseEvent(group.groupCode, parseInt(member.uin)));
break;
}
}
}
else if (existGroup.memberCount < group.memberCount) {
const oldMembers = existGroup.members;
const oldMembersSet = new Set<string>();
for (const member of oldMembers) {
oldMembersSet.add(member.uin);
}
await sleep(200);
const newMembers = await NTQQApi.getGroupMembers(group.groupCode);
group.members = newMembers;
for (const member of newMembers) {
if (!oldMembersSet.has(member.uin)) {
postEvent(new OB11GroupIncreaseEvent(group.groupCode, parseInt(member.uin)));
postOB11Event(new OB11GroupDecreaseEvent(group.groupCode, parseInt(member.uin)));
break;
}
}
@@ -183,18 +174,17 @@ async function processGroupEvent(payload) {
}
updateGroups(newGroupList, false).then();
}
catch (e) {
} catch (e) {
updateGroups(payload.groupList).then();
console.log(e);
}
}
// 群列表变动
registerReceiveHook<{ groupList: Group[], updateType: number }>(ReceiveCmd.GROUPS, (payload) => {
if (payload.updateType != 2) {
updateGroups(payload.groupList).then();
}
else {
} else {
if (process.platform == "win32") {
processGroupEvent(payload).then();
}
@@ -203,13 +193,14 @@ registerReceiveHook<{ groupList: Group[], updateType: number }>(ReceiveCmd.GROUP
registerReceiveHook<{ groupList: Group[], updateType: number }>(ReceiveCmd.GROUPS_UNIX, (payload) => {
if (payload.updateType != 2) {
updateGroups(payload.groupList).then();
}
else {
} else {
if (process.platform != "win32") {
processGroupEvent(payload).then();
}
}
})
// 好友列表变动
registerReceiveHook<{
data: { categoryId: number, categroyName: string, categroyMbCount: number, buddyList: User[] }[]
}>(ReceiveCmd.FRIENDS, payload => {
@@ -226,14 +217,44 @@ registerReceiveHook<{
}
})
// 新消息
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, (payload) => {
for (const message of payload.msgList) {
// log("收到新消息push到历史记录", message)
addHistoryMsg(message)
const {autoDeleteFile} = getConfigUtil().getConfig();
if (!autoDeleteFile) {
return
}
const msgIds = Object.keys(msgHistory);
if (msgIds.length > 30000) {
delete msgHistory[msgIds.sort()[0]]
for (const message of payload.msgList) {
// log("收到新消息push到历史记录", message.msgId)
// dbUtil.addMsg(message).then()
// 清理文件
for (const msgElement of message.elements) {
setTimeout(() => {
const picPath = msgElement.picElement?.sourcePath
const picThumbPath = [...msgElement.picElement?.thumbPath.values()]
const pttPath = msgElement.pttElement?.filePath
const filePath = msgElement.fileElement?.filePath
const videoPath = msgElement.videoElement?.filePath
const videoThumbPath: string[] = [...msgElement.videoElement?.thumbPath.values()]
const pathList = [picPath, ...picThumbPath, pttPath, filePath, videoPath, ...videoThumbPath]
if (msgElement.picElement) {
pathList.push(...Object.values(msgElement.picElement.thumbPath))
}
const aioOpGrayTipElement = msgElement.grayTipElement?.aioOpGrayTipElement
if (aioOpGrayTipElement){
tempGroupCodeMap[aioOpGrayTipElement.peerUid] = aioOpGrayTipElement.fromGrpCodeOfTmpChat;
}
// log("需要清理的文件", pathList);
for (const path of pathList) {
if (path) {
fs.unlink(picPath, () => {
log("删除文件成功", path)
});
}
}
}, getConfigUtil().getConfig().autoDeleteFileSecond * 1000)
}
}
})
@@ -241,7 +262,9 @@ registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, ({msgRe
const message = msgRecord;
const peerUid = message.peerUid;
// log("收到自己发送成功的消息", Object.keys(sendMessagePool), message);
const sendCallback = sendMessagePool[peerUid];
// log("收到自己发送成功的消息", message.msgId, message.msgSeq);
dbUtil.addMsg(message).then()
const sendCallback = sendMessagePool[peerUid]
if (sendCallback) {
try {
sendCallback(message);
@@ -250,3 +273,7 @@ registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, ({msgRe
}
}
})
registerReceiveHook<{ info: { status: number } }>(ReceiveCmd.SELF_STATUS, (info) => {
selfInfo.online = info.info.status !== 20
})

View File

@@ -1,10 +1,30 @@
import {ipcMain} from "electron";
import {BrowserWindow, ipcMain} from "electron";
import {hookApiCallbacks, ReceiveCmd, registerReceiveHook, removeReceiveHook} from "./hook";
import {log} from "../common/utils";
import {ChatType, Friend, Group, GroupMember, RawMessage, SelfInfo, SendMessageElement, User} from "./types";
import {log, sleep} from "../common/utils";
import {
ChatType,
ElementType,
Friend,
FriendRequest,
Group,
GroupMember,
GroupMemberRole,
GroupNotifies,
GroupNotify,
GroupRequestOperateTypes,
RawMessage,
SelfInfo,
SendMessageElement,
User,
CacheScanResult,
ChatCacheList, ChatCacheListItemBasic,
CacheFileList, CacheFileListItem, CacheFileType,
} from "./types";
import * as fs from "fs";
import {addHistoryMsg, msgHistory, selfInfo, uidMaps} from "../common/data";
import {friendRequests, selfInfo, uidMaps} from "../common/data";
import {v4 as uuidv4} from "uuid"
import path from "path";
import {dbUtil} from "../common/db";
interface IPCReceiveEvent {
eventName: string
@@ -21,10 +41,15 @@ export type IPCReceiveDetail = [
export enum NTQQApiClass {
NT_API = "ns-ntApi",
FS_API = "ns-FsApi",
OS_API = "ns-OsApi",
WINDOW_API = "ns-WindowApi",
HOTUPDATE_API = "ns-HotUpdateApi",
BUSINESS_API = "ns-BusinessApi",
GLOBAL_DATA = "ns-GlobalDataApi"
}
export enum NTQQApiMethod {
SET_HEADER = "nodeIKernelProfileService/setHeader",
LIKE_FRIEND = "nodeIKernelProfileLikeService/setBuddyProfileLike",
SELF_INFO = "fetchAuthData",
FRIENDS = "nodeIKernelBuddyService/getBuddyList",
@@ -32,6 +57,7 @@ export enum NTQQApiMethod {
GROUP_MEMBER_SCENE = "nodeIKernelGroupService/createMemberListScene",
GROUP_MEMBERS = "nodeIKernelGroupService/getNextMemberList",
USER_INFO = "nodeIKernelProfileService/getUserSimpleInfo",
USER_DETAIL_INFO = "nodeIKernelProfileService/getUserDetailInfo",
FILE_TYPE = "getFileType",
FILE_MD5 = "getFileMd5",
FILE_COPY = "copyFile",
@@ -41,7 +67,37 @@ export enum NTQQApiMethod {
RECALL_MSG = "nodeIKernelMsgService/recallMsg",
SEND_MSG = "nodeIKernelMsgService/sendMsg",
DOWNLOAD_MEDIA = "nodeIKernelMsgService/downloadRichMedia",
MULTI_FORWARD_MSG = "nodeIKernelMsgService/multiForwardMsgWithComment" // 合并转发
FORWARD_MSG = "nodeIKernelMsgService/forwardMsgWithComment",
MULTI_FORWARD_MSG = "nodeIKernelMsgService/multiForwardMsgWithComment", // 合并转发
GET_GROUP_NOTICE = "nodeIKernelGroupService/getSingleScreenNotifies",
HANDLE_GROUP_REQUEST = "nodeIKernelGroupService/operateSysNotify",
QUIT_GROUP = "nodeIKernelGroupService/quitGroup",
// READ_FRIEND_REQUEST = "nodeIKernelBuddyListener/onDoubtBuddyReqUnreadNumChange"
HANDLE_FRIEND_REQUEST = "nodeIKernelBuddyService/approvalFriendRequest",
KICK_MEMBER = "nodeIKernelGroupService/kickMember",
MUTE_MEMBER = "nodeIKernelGroupService/setMemberShutUp",
MUTE_GROUP = "nodeIKernelGroupService/setGroupShutUp",
SET_MEMBER_CARD = "nodeIKernelGroupService/modifyMemberCardName",
SET_MEMBER_ROLE = "nodeIKernelGroupService/modifyMemberRole",
PUBLISH_GROUP_BULLETIN = "nodeIKernelGroupService/publishGroupBulletinBulletin",
SET_GROUP_NAME = "nodeIKernelGroupService/modifyGroupName",
SET_GROUP_TITLE = "nodeIKernelGroupService/modifyMemberSpecialTitle",
CACHE_SET_SILENCE = 'nodeIKernelStorageCleanService/setSilentScan',
CACHE_ADD_SCANNED_PATH = 'nodeIKernelStorageCleanService/addCacheScanedPaths',
CACHE_PATH_HOT_UPDATE = 'getHotUpdateCachePath',
CACHE_PATH_DESKTOP_TEMP = 'getDesktopTmpPath',
CACHE_PATH_SESSION = 'getCleanableAppSessionPathList',
CACHE_SCAN = 'nodeIKernelStorageCleanService/scanCache',
CACHE_CLEAR = 'nodeIKernelStorageCleanService/clearCacheDataByKeys',
CACHE_CHAT_GET = 'nodeIKernelStorageCleanService/getChatCacheInfo',
CACHE_FILE_GET = 'nodeIKernelStorageCleanService/getFileCacheInfo',
CACHE_CHAT_CLEAR = 'nodeIKernelStorageCleanService/clearChatCacheInfo',
OPEN_EXTRA_WINDOW = 'openExternalWindow',
SET_QQ_AVATAR = 'nodeIKernelProfileService/setHeader'
}
enum NTQQApiChannel {
@@ -56,35 +112,40 @@ export interface Peer {
guildId?: ""
}
enum CallBackType {
UUID,
METHOD
}
interface NTQQApiParams {
methodName: NTQQApiMethod,
methodName: NTQQApiMethod | string,
className?: NTQQApiClass,
channel?: NTQQApiChannel,
classNameIsRegister?: boolean
args?: unknown[],
cbCmd?: ReceiveCmd | null
cbCmd?: ReceiveCmd | null,
cmdCB?: (payload: any) => boolean;
afterFirstCmd?: boolean, // 是否在methodName调用完之后再去hook cbCmd
timeoutSecond?: number,
}
function callNTQQApi<ReturnType>(params: NTQQApiParams) {
let {
className, methodName, channel, args,
cbCmd, timeoutSecond: timeout
cbCmd, timeoutSecond: timeout,
classNameIsRegister, cmdCB, afterFirstCmd
} = params;
className = className ?? NTQQApiClass.NT_API;
channel = channel ?? NTQQApiChannel.IPC_UP_2;
args = args ?? [];
timeout = timeout ?? 5;
afterFirstCmd = afterFirstCmd ?? true;
const uuid = uuidv4();
// log("callNTQQApi", channel, className, methodName, args, uuid)
return new Promise((resolve: (data: ReturnType) => void, reject) => {
// log("callNTQQApiPromise", channel, className, methodName, args, uuid)
const _timeout = timeout * 1000
let success = false
let eventName = className + "-" + channel[channel.length - 1];
if (classNameIsRegister) {
eventName += "-register";
}
const apiArgs = [methodName, ...args]
if (!cbCmd) {
// QQ后端会返回结果并且可以插根据uuid识别
hookApiCallbacks[uuid] = (r: ReturnType) => {
@@ -93,15 +154,27 @@ function callNTQQApi<ReturnType>(params: NTQQApiParams) {
};
} else {
// 这里的callback比较特殊QQ后端先返回是否调用成功再返回一条结果数据
hookApiCallbacks[uuid] = (result: GeneralCallResult) => {
log(`${methodName} callback`, result)
if (result.result == 0) {
const hookId = registerReceiveHook<ReturnType>(cbCmd, (payload) => {
log(methodName, "second callback", cbCmd, payload);
const secondCallback = () => {
const hookId = registerReceiveHook<ReturnType>(cbCmd, (payload) => {
// log(methodName, "second callback", cbCmd, payload, cmdCB);
if (!!cmdCB) {
if (cmdCB(payload)) {
removeReceiveHook(hookId);
success = true
resolve(payload);
}
} else {
removeReceiveHook(hookId);
success = true
resolve(payload);
})
}
})
}
!afterFirstCmd && secondCallback();
hookApiCallbacks[uuid] = (result: GeneralCallResult) => {
log(`${methodName} callback`, result)
if (result?.result == 0 || result === undefined) {
afterFirstCmd && secondCallback();
} else {
success = true
reject(`ntqq api call failed, ${result.errMsg}`);
@@ -111,12 +184,11 @@ function callNTQQApi<ReturnType>(params: NTQQApiParams) {
setTimeout(() => {
// log("ntqq api timeout", success, channel, className, methodName)
if (!success) {
log(`ntqq api timeout ${channel}, ${className}, ${methodName}`)
reject(`ntqq api timeout ${channel}, ${className}, ${methodName}`)
log(`ntqq api timeout ${channel}, ${eventName}, ${methodName}`, apiArgs);
reject(`ntqq api timeout ${channel}, ${eventName}, ${methodName}, ${apiArgs}`)
}
}, _timeout)
const eventName = className + "-" + channel[channel.length - 1];
const apiArgs = [methodName, ...args]
ipcMain.emit(
channel,
{},
@@ -136,9 +208,15 @@ interface GeneralCallResult {
export class NTQQApi {
// static likeFriend = defineNTQQApi<void>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.LIKE_FRIEND)
static likeFriend(uid: string, count = 1) {
return callNTQQApi({
static async setHeader(path: string) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.SET_HEADER,
args: [path]
})
}
static async likeFriend(uid: string, count = 1) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.LIKE_FRIEND,
args: [{
doLikeUserInfo: {
@@ -151,8 +229,8 @@ export class NTQQApi {
})
}
static getSelfInfo() {
return callNTQQApi<SelfInfo>({
static async getSelfInfo() {
return await callNTQQApi<SelfInfo>({
className: NTQQApiClass.GLOBAL_DATA,
methodName: NTQQApiMethod.SELF_INFO, timeoutSecond: 2
})
@@ -167,6 +245,30 @@ export class NTQQApi {
return result.profiles.get(uid)
}
static async getUserDetailInfo(uid: string) {
const result = await callNTQQApi<{ info: User }>({
methodName: NTQQApiMethod.USER_DETAIL_INFO,
cbCmd: ReceiveCmd.USER_DETAIL_INFO,
afterFirstCmd: false,
cmdCB: (payload) => {
const success = payload.info.uid == uid
// log("get user detail info", success, uid, payload)
return success
},
args: [
{
uid
},
null
]
})
const info = result.info
if (info?.uin) {
uidMaps[info.uid] = info.uin
}
return info
}
static async getFriends(forced = false) {
const data = await callNTQQApi<{
data: {
@@ -200,7 +302,7 @@ export class NTQQApi {
return result.groupList
}
static async getGroupMembers(groupQQ: string, num = 3000) {
static async getGroupMembers(groupQQ: string, num = 3000): Promise<GroupMember[]> {
const sceneId = await callNTQQApi({
methodName: NTQQApiMethod.GROUP_MEMBER_SCENE,
args: [{
@@ -222,14 +324,15 @@ export class NTQQApi {
]
})
// log("members info", typeof result.result.infos, Object.keys(result.result.infos))
let values = result.result.infos.values()
const values = result.result.infos.values()
let members = Array.from(values) as GroupMember[]
for(const member of members){
const members: GroupMember[] = Array.from(values)
for (const member of members) {
uidMaps[member.uid] = member.uin;
}
log(uidMaps);
// log(uidMaps);
// log("members info", values);
log(`get group ${groupQQ} members success`)
return members
} catch (e) {
log(`get group ${groupQQ} members failed`, e)
@@ -237,44 +340,45 @@ export class NTQQApi {
}
}
static getFileType(filePath: string) {
return callNTQQApi<{ ext: string }>({
static async getFileType(filePath: string) {
return await callNTQQApi<{ ext: string }>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.FILE_TYPE, args: [filePath]
})
}
static getFileMd5(filePath: string) {
return callNTQQApi<string>({
static async getFileMd5(filePath: string) {
return await callNTQQApi<string>({
className: NTQQApiClass.FS_API,
methodName: NTQQApiMethod.FILE_MD5,
args: [filePath]
})
}
static copyFile(filePath: string, destPath: string) {
return callNTQQApi<string>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.FILE_COPY, args: [{
static async copyFile(filePath: string, destPath: string) {
return await callNTQQApi<string>({
className: NTQQApiClass.FS_API,
methodName: NTQQApiMethod.FILE_COPY,
args: [{
fromPath: filePath,
toPath: destPath
}]
})
}
static getImageSize(filePath: string) {
return callNTQQApi<{ width: number, height: number }>({
static async getImageSize(filePath: string) {
return await callNTQQApi<{ width: number, height: number }>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.IMAGE_SIZE, args: [filePath]
})
}
static getFileSize(filePath: string) {
return callNTQQApi<number>({
static async getFileSize(filePath: string) {
return await callNTQQApi<number>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.FILE_SIZE, args: [filePath]
})
}
// 上传文件到QQ的文件夹
static async uploadFile(filePath: string) {
static async uploadFile(filePath: string, elementType: ElementType = ElementType.PIC) {
const md5 = await NTQQApi.getFileMd5(filePath);
let ext = (await NTQQApi.getFileType(filePath))?.ext
if (ext) {
@@ -282,14 +386,17 @@ export class NTQQApi {
} else {
ext = ""
}
const fileName = `${md5}${ext}`;
let fileName = `${path.basename(filePath)}`;
if (fileName.indexOf(".") === -1) {
fileName += ext;
}
const mediaPath = await callNTQQApi<string>({
methodName: NTQQApiMethod.MEDIA_FILE_PATH,
args: [{
path_info: {
md5HexStr: md5,
fileName: fileName,
elementType: 2,
elementType: elementType,
elementSubType: 0,
thumbSize: 0,
needCreate: true,
@@ -328,87 +435,109 @@ export class NTQQApi {
},
undefined,
]
await callNTQQApi({methodName: NTQQApiMethod.DOWNLOAD_MEDIA, args: apiParams})
// log("需要下载media", sourcePath);
await callNTQQApi({
methodName: NTQQApiMethod.DOWNLOAD_MEDIA,
args: apiParams,
cbCmd: ReceiveCmd.MEDIA_DOWNLOAD_COMPLETE,
cmdCB: (payload: { notifyInfo: { filePath: string } }) => {
// log("media 下载完成判断", payload.notifyInfo.filePath, sourcePath);
return payload.notifyInfo.filePath == sourcePath;
}
})
return sourcePath
}
static recallMsg(peer: Peer, msgIds: string[]) {
return callNTQQApi({
methodName: NTQQApiMethod.RECALL_MSG, args: [{
static async recallMsg(peer: Peer, msgIds: string[]) {
return await callNTQQApi({
methodName: NTQQApiMethod.RECALL_MSG,
args: [{
peer,
msgIds
}, null]
})
}
static sendMsg(peer: Peer, msgElements: SendMessageElement[], waitComplete = false) {
const sendTimeout = 10 * 1000
static async sendMsg(peer: Peer, msgElements: SendMessageElement[], waitComplete = true, timeout = 10000) {
const peerUid = peer.peerUid
return new Promise<RawMessage>((resolve, reject) => {
const peerUid = peer.peerUid;
let usingTime = 0;
let success = false;
let isTimeout = false;
const checkSuccess = () => {
if (!success) {
sendMessagePool[peerUid] = null;
isTimeout = true;
reject("发送超时")
}
// 等待上一个相同的peer发送完
let checkLastSendUsingTime = 0;
const waitLastSend = async () => {
if (checkLastSendUsingTime > timeout) {
throw ("发送超时")
}
setTimeout(checkSuccess, sendTimeout);
let lastSending = sendMessagePool[peer.peerUid]
if (lastSending) {
// log("有正在发送的消息,等待中...")
await sleep(500);
checkLastSendUsingTime += 500;
return await waitLastSend();
} else {
return;
}
}
await waitLastSend();
const checkLastSend = () => {
let lastSending = sendMessagePool[peerUid]
if (sendTimeout < usingTime) {
sendMessagePool[peerUid] = null;
isTimeout = true;
reject("发送超时")
}
if (!!lastSending) {
// log("有正在发送的消息,等待中...")
usingTime += 500;
setTimeout(checkLastSend, 500);
} else {
log("可以进行发送消息,设置发送成功回调", sendMessagePool)
sendMessagePool[peerUid] = (rawMessage: RawMessage) => {
sendMessagePool[peerUid] = null;
const checkSendComplete = () => {
if (isTimeout) {
return reject("发送超时")
}
if (msgHistory[rawMessage.msgId]?.sendStatus == 2) {
success = true;
resolve(rawMessage);
} else {
setTimeout(checkSendComplete, 500)
}
}
if (waitComplete) {
checkSendComplete();
} else {
success = true;
resolve(rawMessage);
}
let sentMessage: RawMessage = null;
sendMessagePool[peerUid] = async (rawMessage: RawMessage) => {
delete sendMessagePool[peerUid];
sentMessage = rawMessage;
}
let checkSendCompleteUsingTime = 0;
const checkSendComplete = async (): Promise<RawMessage> => {
if (sentMessage) {
if (waitComplete) {
if ((await dbUtil.getMsgByLongId(sentMessage.msgId)).sendStatus == 2) {
return sentMessage
}
} else {
return sentMessage
}
// log(`给${peerUid}发送消息成功`)
}
checkLastSend()
callNTQQApi({
methodName: NTQQApiMethod.SEND_MSG,
args: [{
msgId: "0",
peer, msgElements,
msgAttributeInfos: new Map(),
}, null]
}).then()
})
checkSendCompleteUsingTime += 500
if (checkSendCompleteUsingTime > timeout) {
throw ('发送超时')
}
await sleep(500)
return await checkSendComplete()
}
callNTQQApi({
methodName: NTQQApiMethod.SEND_MSG,
args: [{
msgId: "0",
peer, msgElements,
msgAttributeInfos: new Map(),
}, null]
}).then()
return await checkSendComplete()
}
static multiForwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
let msgInfos = msgIds.map(id => {
return {msgId: id, senderShowName: "LLOneBot"}
static async forwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.FORWARD_MSG,
args: [
{
msgIds: msgIds,
srcContact: srcPeer,
dstContacts: [
destPeer
],
commentElements: [],
msgAttributeInfos: new Map()
},
null,
]
})
}
static async multiForwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
const msgInfos = msgIds.map(id => {
return {msgId: id, senderShowName: selfInfo.nick}
})
const apiArgs = [
{
@@ -420,30 +549,30 @@ export class NTQQApi {
},
null,
]
return new Promise<RawMessage>((resolve, reject) => {
return await new Promise<RawMessage>((resolve, reject) => {
let complete = false
setTimeout(() => {
if (!complete) {
reject("转发消息超时");
}
}, 5000)
registerReceiveHook(ReceiveCmd.SELF_SEND_MSG, (payload: { msgRecord: RawMessage }) => {
const msg = payload.msgRecord;
registerReceiveHook(ReceiveCmd.SELF_SEND_MSG, async (payload: { msgRecord: RawMessage }) => {
const msg = payload.msgRecord
// 需要判断它是转发的消息,并且识别到是当前转发的这一条
const arkElement = msg.elements.find(ele => ele.arkElement)
if (!arkElement) {
// log("收到的不是转发消息")
return
}
const forwardData: any = JSON.parse(arkElement.arkElement.bytesData);
if (forwardData.app != "com.tencent.multimsg") {
const forwardData: any = JSON.parse(arkElement.arkElement.bytesData)
if (forwardData.app != 'com.tencent.multimsg') {
return
}
if (msg.peerUid == destPeer.peerUid && msg.senderUid == selfInfo.uid) {
complete = true;
addHistoryMsg(msg)
resolve(msg);
log("转发消息成功:", payload)
complete = true
await dbUtil.addMsg(msg)
resolve(msg)
log('转发消息成功:', payload)
}
})
callNTQQApi<GeneralCallResult>({
@@ -458,4 +587,323 @@ export class NTQQApi {
})
})
}
static async getGroupNotifies() {
// 获取管理员变更
// 加群通知,退出通知,需要管理员权限
callNTQQApi<GeneralCallResult>({
methodName: ReceiveCmd.GROUP_NOTIFY,
classNameIsRegister: true,
}).then()
return await callNTQQApi<GroupNotifies>({
methodName: NTQQApiMethod.GET_GROUP_NOTICE,
cbCmd: ReceiveCmd.GROUP_NOTIFY,
afterFirstCmd: false,
args: [
{"doubt": false, "startSeq": "", "number": 14},
null
]
});
}
static async getGroupIgnoreNotifies() {
await NTQQApi.getGroupNotifies();
const result = callNTQQApi<GroupNotifies>({
className: NTQQApiClass.WINDOW_API,
methodName: NTQQApiMethod.OPEN_EXTRA_WINDOW,
cbCmd: ReceiveCmd.GROUP_NOTIFY,
afterFirstCmd: false,
args: [
"GroupNotifyFilterWindow"
]
})
// 关闭窗口
setTimeout(() => {
for (const w of BrowserWindow.getAllWindows()) {
// log("close window", w.webContents.getURL())
if (w.webContents.getURL().indexOf("#/notify-filter/") != -1) {
w.close();
}
}
}, 2000);
return result;
}
static async handleGroupRequest(seq: string, operateType: GroupRequestOperateTypes, reason?: string) {
const notify: GroupNotify = await dbUtil.getGroupNotify(seq)
if (!notify) {
throw `${seq}对应的加群通知不存在`
}
// delete groupNotifies[seq];
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.HANDLE_GROUP_REQUEST,
args: [
{
"doubt": false,
"operateMsg": {
"operateType": operateType, // 2 拒绝
"targetMsg": {
"seq": seq, // 通知序列号
"type": notify.type,
"groupCode": notify.group.groupCode,
"postscript": reason
}
}
},
null
]
});
}
static async quitGroup(groupQQ: string) {
await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.QUIT_GROUP,
args: [
{"groupCode": groupQQ},
null
]
})
}
static async handleFriendRequest(sourceId: number, accept: boolean,) {
const request: FriendRequest = friendRequests[sourceId]
if (!request) {
throw `sourceId ${sourceId}, 对应的好友请求不存在`
}
const result = await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.HANDLE_FRIEND_REQUEST,
args: [
{
"approvalInfo": {
"friendUid": request.friendUid,
"reqTime": request.reqTime,
accept
}
}
]
})
delete friendRequests[sourceId];
return result;
}
static async kickMember(groupQQ: string, kickUids: string[], refuseForever: boolean = false, kickReason: string = '') {
return await callNTQQApi<GeneralCallResult>(
{
methodName: NTQQApiMethod.KICK_MEMBER,
args: [
{
groupCode: groupQQ,
kickUids,
refuseForever,
kickReason,
}
]
}
)
}
static async banMember(groupQQ: string, memList: Array<{ uid: string, timeStamp: number }>) {
// timeStamp为秒数, 0为解除禁言
return await callNTQQApi<GeneralCallResult>(
{
methodName: NTQQApiMethod.MUTE_MEMBER,
args: [
{
groupCode: groupQQ,
memList,
}
]
}
)
}
static async banGroup(groupQQ: string, shutUp: boolean) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.MUTE_GROUP,
args: [
{
groupCode: groupQQ,
shutUp
}, null
]
})
}
static async setMemberCard(groupQQ: string, memberUid: string, cardName: string) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.SET_MEMBER_CARD,
args: [
{
groupCode: groupQQ,
uid: memberUid,
cardName
}, null
]
})
}
static async setMemberRole(groupQQ: string, memberUid: string, role: GroupMemberRole) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.SET_MEMBER_ROLE,
args: [
{
groupCode: groupQQ,
uid: memberUid,
role
}, null
]
})
}
static async setGroupName(groupQQ: string, groupName: string) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.SET_GROUP_NAME,
args: [
{
groupCode: groupQQ,
groupName
}, null
]
})
}
static async call(className: NTQQApiClass, cmdName: string, args: any[],) {
return await callNTQQApi<GeneralCallResult>({
className,
methodName: cmdName,
args: [
...args,
]
})
}
static async setGroupTitle(groupQQ: string, uid: string, title: string) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.SET_GROUP_TITLE,
args: [
{
groupCode: groupQQ,
uid,
title
}, null
]
})
}
static publishGroupBulletin(groupQQ: string, title: string, content: string) {
}
static async setCacheSilentScan(isSilent: boolean = true) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.CACHE_SET_SILENCE,
args: [{
isSilent
}, null]
});
}
static addCacheScannedPaths(pathMap: object = {}) {
return callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.CACHE_ADD_SCANNED_PATH,
args: [{
pathMap: {...pathMap},
}, null]
});
}
static scanCache() {
callNTQQApi<GeneralCallResult>({
methodName: ReceiveCmd.CACHE_SCAN_FINISH,
classNameIsRegister: true,
}).then();
return callNTQQApi<CacheScanResult>({
methodName: NTQQApiMethod.CACHE_SCAN,
args: [null, null],
timeoutSecond: 300,
});
}
static getHotUpdateCachePath() {
return callNTQQApi<string>({
className: NTQQApiClass.HOTUPDATE_API,
methodName: NTQQApiMethod.CACHE_PATH_HOT_UPDATE
});
}
static getDesktopTmpPath() {
return callNTQQApi<string>({
className: NTQQApiClass.BUSINESS_API,
methodName: NTQQApiMethod.CACHE_PATH_DESKTOP_TEMP
});
}
static getCacheSessionPathList() {
return callNTQQApi<{
key: string,
value: string
}[]>({
className: NTQQApiClass.OS_API,
methodName: NTQQApiMethod.CACHE_PATH_SESSION,
});
}
static clearCache(cacheKeys: Array<string> = ['tmp', 'hotUpdate']) {
return callNTQQApi<any>({ // TODO: 目前还不知道真正的返回值是什么
methodName: NTQQApiMethod.CACHE_CLEAR,
args: [{
keys: cacheKeys
}, null]
});
}
static getChatCacheList(type: ChatType, pageSize: number = 1000, pageIndex: number = 0) {
return new Promise<ChatCacheList>((res, rej) => {
callNTQQApi<ChatCacheList>({
methodName: NTQQApiMethod.CACHE_CHAT_GET,
args: [{
chatType: type,
pageSize,
order: 1,
pageIndex
}, null]
}).then(list => res(list))
.catch(e => rej(e));
});
}
static getFileCacheInfo(fileType: CacheFileType, pageSize: number = 1000, lastRecord?: CacheFileListItem) {
const _lastRecord = lastRecord ? lastRecord : {fileType: fileType};
return callNTQQApi<CacheFileList>({
methodName: NTQQApiMethod.CACHE_FILE_GET,
args: [{
fileType: fileType,
restart: true,
pageSize: pageSize,
order: 1,
lastRecord: _lastRecord,
}, null]
})
}
static async clearChatCache(chats: ChatCacheListItemBasic[] = [], fileKeys: string[] = []) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.CACHE_CHAT_CLEAR,
args: [{
chats,
fileKeys
}, null]
});
}
static async setQQAvatar(filePath: string) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.SET_QQ_AVATAR,
args: [{
path:filePath
}, null],
timeoutSecond: 10 // 10秒不一定够
});
}
}

View File

@@ -8,7 +8,7 @@ export interface User {
}
export interface SelfInfo extends User {
online?: boolean;
}
export interface Friend extends User {
@@ -45,6 +45,12 @@ export interface Group {
members: GroupMember[] // 原始数据是没有这个的,为了方便自己加了这个字段
}
export enum GroupMemberRole {
normal = 2,
admin = 3,
owner = 4
}
export interface GroupMember {
avatarPath: string;
cardName: string;
@@ -53,18 +59,22 @@ export interface GroupMember {
nick: string;
qid: string;
remark: string;
role: number; // 群主:4, 管理员:3群员:2
role: GroupMemberRole; // 群主:4, 管理员:3群员:2
shutUpTime: number; // 禁言时间,单位是什么暂时不清楚
uid: string; // 加密的字符串
uin: string; // QQ号
isRobot: boolean;
}
export enum ElementType {
TEXT = 1,
PIC = 2,
FILE = 3,
PTT = 4,
VIDEO = 5,
FACE = 6,
REPLY = 7,
ARK = 10,
}
export interface SendTextElement {
@@ -87,7 +97,7 @@ export interface SendPttElement {
filePath: string,
md5HexStr: string,
fileSize: number,
duration: number,
duration: number, // 单位是秒
formatType: number,
voiceType: number,
voiceChangeType: number,
@@ -99,6 +109,11 @@ export interface SendPttElement {
}
}
export enum PicType {
gif = 2000,
jpg = 1000
}
export interface SendPicElement {
elementType: ElementType.PIC,
elementId: "",
@@ -110,7 +125,7 @@ export interface SendPicElement {
fileName: string,
sourcePath: string,
original: boolean,
picType: number,
picType: PicType,
picSubType: number,
fileUuid: string,
fileSubId: string,
@@ -136,7 +151,42 @@ export interface SendFaceElement {
faceElement: FaceElement
}
export type SendMessageElement = SendTextElement | SendPttElement | SendPicElement | SendReplyElement | SendFaceElement
export interface FileElement {
"fileMd5"?: "",
"fileName": string,
"filePath": string,
"fileSize": string,
"picHeight"?: number,
"picWidth"?: number,
"picThumbPath"?: {},
"file10MMd5"?: "",
"fileSha"?: "",
"fileSha3"?: "",
"fileUuid"?: "",
"fileSubId"?: "",
"thumbFileSize"?: number
}
export interface SendFileElement {
elementType: ElementType.FILE
elementId: "",
fileElement: FileElement
}
export interface SendVideoElement {
elementType: ElementType.VIDEO
elementId: "",
videoElement: VideoElement
}
export interface SendArkElement {
elementType: ElementType.ARK,
elementId: "",
arkElement: ArkElement
}
export type SendMessageElement = SendTextElement | SendPttElement |
SendPicElement | SendReplyElement | SendFaceElement | SendFileElement | SendVideoElement | SendArkElement
export enum AtType {
notAt = 0,
@@ -175,6 +225,8 @@ export interface PttElement {
export interface ArkElement {
bytesData: string;
linkInfo: null,
subElementType: null
}
export const IMAGE_HTTP_HOST = "https://gchat.qpic.cn"
@@ -188,6 +240,7 @@ export interface PicElement {
fileSize: number;
fileName: string;
fileUuid: string;
md5HexStr?: string;
}
export interface GrayTipElement {
@@ -199,6 +252,8 @@ export interface GrayTipElement {
operatorMemRemark?: string;
wording: string; // 自定义的撤回提示语
}
aioOpGrayTipElement: TipAioOpGrayTipElement,
groupElement: TipGroupElement
}
export interface FaceElement {
@@ -206,10 +261,86 @@ export interface FaceElement {
faceType: 1
}
export interface VideoElement {
"filePath": string,
"fileName": string,
"videoMd5"?: string,
"thumbMd5"?: string
"fileTime"?: number, // second
"thumbSize"?: number, // byte
"fileFormat"?: number, // 2表示mp4
"fileSize"?: string, // byte
"thumbWidth"?: number,
"thumbHeight"?: number,
"busiType"?: 0, // 未知
"subBusiType"?: 0, // 未知
"thumbPath"?: Map<number, any>,
"transferStatus"?: 0, // 未知
"progress"?: 0, // 下载进度?
"invalidState"?: 0, // 未知
"fileUuid"?: string, // 可以用于下载链接?
"fileSubId"?: "",
"fileBizId"?: null,
"originVideoMd5"?: "",
"import_rich_media_context"?: null,
"sourceVideoCodecFormat"?: number
}
export interface TipAioOpGrayTipElement { // 这是什么提示来着?
operateType: number,
peerUid: string,
fromGrpCodeOfTmpChat: string,
}
export enum TipGroupElementType {
memberIncrease = 1,
ban = 8
}
export interface TipGroupElement {
"type": TipGroupElementType, // 1是表示有人加入群, 自己加入群也会收到这个
"role": 0, // 暂时不知
"groupName": string, // 暂时获取不到
"memberUid": string,
"memberNick": string,
"memberRemark": string,
"adminUid": string, // 同意加群的管理员uid
"adminNick": string,
"adminRemark": string,
"createGroup": null,
"memberAdd"?: {
"showType": 1,
"otherAdd": null,
"otherAddByOtherQRCode": null,
"otherAddByYourQRCode": null,
"youAddByOtherQRCode": null,
"otherInviteOther": null,
"otherInviteYou": null,
"youInviteOther": null
},
"shutUp"?: {
"curTime": string,
"duration": string, // 禁言时间,秒
"admin": {
"uid": string,
"card": string,
"name": string,
"role": GroupMemberRole
},
"member": {
"uid": string
"card": string,
"name": string,
"role": GroupMemberRole
}
}
}
export interface RawMessage {
msgId: string;
msgShortId?: number; // 自己维护的消息id
msgTime: string;
msgTime: string; // 时间戳,秒
msgSeq: string;
senderUid: string;
senderUin?: string; // 发送者QQ号
@@ -222,6 +353,7 @@ export interface RawMessage {
recallTime: string; // 撤回时间, "0"是没有撤回
elements: {
elementId: string,
elementType: ElementType;
replyElement: {
senderUid: string; // 原消息发送者QQ号
sourceMsgIsIncPic: boolean; // 原消息是否有图片
@@ -239,5 +371,134 @@ export interface RawMessage {
arkElement: ArkElement;
grayTipElement: GrayTipElement;
faceElement: FaceElement;
videoElement: VideoElement;
fileElement: FileElement;
}[];
}
export enum GroupNotifyTypes {
INVITE_ME = 1,
INVITED_JOIN = 4, // 有人接受了邀请入群
JOIN_REQUEST = 7,
ADMIN_SET = 8,
ADMIN_UNSET = 12,
MEMBER_EXIT = 11, // 主动退出?
}
export interface GroupNotifies {
doubt: boolean,
nextStartSeq: string,
notifies: GroupNotify[],
}
export enum GroupNotifyStatus {
IGNORE = 0,
WAIT_HANDLE = 1,
APPROVE = 2,
REJECT = 3
}
export interface GroupNotify {
time: number; // 自己添加的字段,时间戳,毫秒, 用于判断收到短时间内收到重复的notify
seq: string, // 唯一标识符转成数字再除以1000应该就是时间戳
type: GroupNotifyTypes,
status: GroupNotifyStatus, // 0是已忽略1是未处理2是已同意
group: { groupCode: string, groupName: string },
user1: { uid: string, nickName: string }, // 被设置管理员的人
user2: { uid: string, nickName: string }, // 操作者
actionUser: { uid: string, nickName: string }, //未知
actionTime: string,
invitationExt: {
srcType: number, // 0?未知
groupCode: string, waitStatus: number
},
postscript: string, // 加群用户填写的验证信息
repeatSeqs: [],
warningTips: string
}
export enum GroupRequestOperateTypes {
approve = 1,
reject = 2
}
export interface FriendRequest {
friendUid: string,
reqTime: string, // 时间戳,秒
extWords: string, // 申请人填写的验证消息
isUnread: boolean,
friendNick: string,
sourceId: number,
groupCode: string
}
export interface FriendRequestNotify {
data: {
unreadNums: number,
buddyReqs: FriendRequest[]
}
}
export interface CacheScanResult {
result: number,
size: [ // 单位为字节
string, // 系统总存储空间
string, // 系统可用存储空间
string, // 系统已用存储空间
string, // QQ总大小
string, // 「聊天与文件」大小
string, // 未知
string, // 「缓存数据」大小
string, // 「其他数据」大小
string, // 未知
]
}
export interface ChatCacheList {
pageCount: number,
infos: ChatCacheListItem[]
}
export interface ChatCacheListItem {
chatType: ChatType,
basicChatCacheInfo: ChatCacheListItemBasic,
guildChatCacheInfo: unknown[] // TODO: 没用过频道所以不知道这里边的详细内容
}
export interface ChatCacheListItemBasic {
chatSize: string,
chatTime: string,
uid: string,
uin: string,
remarkName: string,
nickName: string,
chatType?: ChatType,
isChecked?: boolean
}
export enum CacheFileType {
IMAGE = 0,
VIDEO = 1,
AUDIO = 2,
DOCUMENT = 3,
OTHER = 4,
}
export interface CacheFileList {
infos: CacheFileListItem[],
}
export interface CacheFileListItem {
fileSize: string,
fileTime: string,
fileKey: string,
elementId: string,
elementIdStr: string,
fileType: CacheFileType,
path: string,
fileName: string,
senderId: string,
previewPath: string,
senderName: string,
isChecked?: boolean,
}

View File

@@ -1,9 +1,11 @@
import {ActionName, BaseCheckResult} from "./types"
import {OB11Response, OB11WebsocketResponse} from "./utils"
import {OB11Return, OB11WebsocketReturn} from "../types";
import {OB11Response} from "./utils"
import {OB11Return} from "../types";
import {log} from "../../common/utils";
class BaseAction<PayloadType, ReturnDataType> {
actionName: ActionName
protected async check(payload: PayloadType): Promise<BaseCheckResult> {
return {
valid: true,
@@ -19,20 +21,22 @@ class BaseAction<PayloadType, ReturnDataType> {
const resData = await this._handle(payload);
return OB11Response.ok(resData);
} catch (e) {
return OB11Response.error(e.toString(), 200);
log("发生错误", e)
return OB11Response.error(e?.toString() || e?.stack?.toString() || "未知错误,可能操作超时", 200);
}
}
public async websocketHandle(payload: PayloadType, echo: string): Promise<OB11WebsocketReturn<ReturnDataType | null>> {
public async websocketHandle(payload: PayloadType, echo: any): Promise<OB11Return<ReturnDataType | null>> {
const result = await this.check(payload)
if (!result.valid) {
return OB11WebsocketResponse.error(result.message, 1400)
return OB11Response.error(result.message, 1400)
}
try {
const resData = await this._handle(payload)
return OB11WebsocketResponse.ok(resData, echo);
return OB11Response.ok(resData, echo);
} catch (e) {
return OB11WebsocketResponse.error(e.toString(), 1200)
log("发生错误", e)
return OB11Response.error(e.stack?.toString() || e.toString(), 1200, echo)
}
}

View File

@@ -1,10 +1,10 @@
import {ActionName} from "./types";
import CanSendRecord from "./CanSendRecord";
interface ReturnType{
interface ReturnType {
yes: boolean
}
export default class CanSendImage extends CanSendRecord{
export default class CanSendImage extends CanSendRecord {
actionName = ActionName.CanSendImage
}

View File

@@ -1,14 +1,14 @@
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
interface ReturnType{
interface ReturnType {
yes: boolean
}
export default class CanSendRecord extends BaseAction<any, ReturnType>{
export default class CanSendRecord extends BaseAction<any, ReturnType> {
actionName = ActionName.CanSendRecord
protected async _handle(payload): Promise<ReturnType>{
protected async _handle(payload): Promise<ReturnType> {
return {
yes: true
}

View File

@@ -0,0 +1,105 @@
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
import {NTQQApi} from "../../ntqqapi/ntcall";
import fs from "fs";
import Path from "path";
import {
ChatType,
ChatCacheListItemBasic,
CacheFileType
} from '../../ntqqapi/types';
import {dbUtil} from "../../common/db";
export default class CleanCache extends BaseAction<void, void> {
actionName = ActionName.CleanCache
protected _handle(): Promise<void> {
return new Promise<void>(async (res, rej) => {
try {
// dbUtil.clearCache();
const cacheFilePaths: string[] = [];
await NTQQApi.setCacheSilentScan(false);
cacheFilePaths.push((await NTQQApi.getHotUpdateCachePath()));
cacheFilePaths.push((await NTQQApi.getDesktopTmpPath()));
(await NTQQApi.getCacheSessionPathList()).forEach(e => cacheFilePaths.push(e.value));
// await NTQQApi.addCacheScannedPaths(); // XXX: 调用就崩溃,原因目前还未知
const cacheScanResult = await NTQQApi.scanCache();
const cacheSize = parseInt(cacheScanResult.size[6]);
if (cacheScanResult.result !== 0) {
throw('Something went wrong while scanning cache. Code: ' + cacheScanResult.result);
}
await NTQQApi.setCacheSilentScan(true);
if (cacheSize > 0 && cacheFilePaths.length > 2) { // 存在缓存文件且大小不为 0 时执行清理动作
// await NTQQApi.clearCache([ 'tmp', 'hotUpdate', ...cacheScanResult ]) // XXX: 也是调用就崩溃,调用 fs 删除得了
deleteCachePath(cacheFilePaths);
}
// 获取聊天记录列表
// NOTE: 以防有人不需要删除聊天记录,暂时先注释掉,日后加个开关
// const privateChatCache = await getCacheList(ChatType.friend); // 私聊消息
// const groupChatCache = await getCacheList(ChatType.group); // 群聊消息
// const chatCacheList = [ ...privateChatCache, ...groupChatCache ];
const chatCacheList: ChatCacheListItemBasic[] = [];
// 获取聊天缓存文件列表
const cacheFileList: string[] = [];
for (const name in CacheFileType) {
if (!isNaN(parseInt(name))) continue;
const fileTypeAny: any = CacheFileType[name];
const fileType: CacheFileType = fileTypeAny;
cacheFileList.push(...(await NTQQApi.getFileCacheInfo(fileType)).infos.map(file => file.fileKey));
}
// 一并清除
await NTQQApi.clearChatCache(chatCacheList, cacheFileList);
res();
} catch(e) {
console.error('清理缓存时发生了错误');
rej(e);
}
});
}
}
function deleteCachePath(pathList: string[]) {
const emptyPath = (path: string) => {
if (!fs.existsSync(path)) return;
const files = fs.readdirSync(path);
files.forEach(file => {
const filePath = Path.resolve(path, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) emptyPath(filePath);
else fs.unlinkSync(filePath);
});
fs.rmdirSync(path);
}
for (const path of pathList) {
emptyPath(path);
}
}
function getCacheList(type: ChatType) { // NOTE: 做这个方法主要是因为目前还不支持针对频道消息的清理
return new Promise<Array<ChatCacheListItemBasic>>((res, rej) => {
NTQQApi.getChatCacheList(type, 1000, 0)
.then(data => {
const list = data.infos.filter(e => e.chatType === type && parseInt(e.basicChatCacheInfo.chatSize) > 0);
const result = list.map(e => {
const result = { ...e.basicChatCacheInfo };
result.chatType = type;
result.isChecked = true;
return result;
});
res(result);
})
.catch(e => rej(e));
});
}

View File

@@ -0,0 +1,28 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
import {log} from "../../common/utils";
interface Payload {
method: string,
args: any[],
}
export default class Debug extends BaseAction<Payload, any> {
actionName = ActionName.Debug
protected async _handle(payload: Payload): Promise<any> {
log("debug call ntqq api", payload);
const method = NTQQApi[payload.method]
if (!method) {
throw `${method} 不存在`
}
const result = method(...payload.args);
if (method.constructor.name === "AsyncFunction") {
return await result
}
return result
// const info = await NTQQApi.getUserDetailInfo(friends[0].uid);
// return info
}
}

View File

@@ -1,7 +1,7 @@
import {ActionName} from "./types";
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {getHistoryMsgByShortId} from "../../common/data";
import {dbUtil} from "../../common/db";
interface Payload {
message_id: number
@@ -10,8 +10,8 @@ interface Payload {
class DeleteMsg extends BaseAction<Payload, void> {
actionName = ActionName.DeleteMsg
protected async _handle(payload:Payload){
let msg = getHistoryMsgByShortId(payload.message_id)
protected async _handle(payload: Payload) {
let msg = await dbUtil.getMsgByShortId(payload.message_id)
await NTQQApi.recallMsg({
chatType: msg.chatType,
peerUid: msg.peerUid

View File

@@ -0,0 +1,47 @@
import BaseAction from "./BaseAction";
import {getConfigUtil} from "../../common/utils";
import fs from "fs/promises";
import {dbUtil} from "../../common/db";
export interface GetFilePayload {
file: string // 文件名
}
export interface GetFileResponse {
file?: string // path
url?: string
file_size?: string
file_name?: string
base64?: string
}
export class GetFileBase extends BaseAction<GetFilePayload, GetFileResponse> {
protected async _handle(payload: GetFilePayload): Promise<GetFileResponse> {
const cache = await dbUtil.getFileCache(payload.file)
const {autoDeleteFile, enableLocalFile2Url, autoDeleteFileSecond} = getConfigUtil().getConfig()
if (!cache) {
throw new Error('file not found')
}
if (cache.downloadFunc) {
await cache.downloadFunc()
}
let res: GetFileResponse = {
file: cache.filePath,
url: cache.url,
file_size: cache.fileSize,
file_name: cache.fileName
}
if (enableLocalFile2Url) {
if (!cache.url) {
res.base64 = await fs.readFile(cache.filePath, 'base64')
}
}
if (autoDeleteFile) {
setTimeout(() => {
fs.unlink(cache.filePath)
}, autoDeleteFileSecond * 1000)
}
return res
}
}

View File

@@ -8,7 +8,7 @@ import {ActionName} from "./types";
class GetFriendList extends BaseAction<null, OB11User[]> {
actionName = ActionName.GetFriendList
protected async _handle(payload: null){
protected async _handle(payload: null) {
return OB11Constructor.friends(friends);
}
}

View File

@@ -8,7 +8,7 @@ import {ActionName} from "./types";
class GetGroupList extends BaseAction<null, OB11Group[]> {
actionName = ActionName.GetGroupList
protected async _handle(payload: null){
protected async _handle(payload: null) {
return OB11Constructor.groups(groups);
}
}

View File

@@ -13,13 +13,12 @@ export interface PayloadType {
class GetGroupMemberInfo extends BaseAction<PayloadType, OB11GroupMember> {
actionName = ActionName.GetGroupMemberInfo
protected async _handle(payload: PayloadType){
protected async _handle(payload: PayloadType) {
const member = await getGroupMember(payload.group_id.toString(), payload.user_id.toString())
if (member) {
return OB11Constructor.groupMember(payload.group_id.toString(), member)
}
else {
throw(`群成员${payload.user_id}不存在`)
} else {
throw (`群成员${payload.user_id}不存在`)
}
}
}

View File

@@ -13,15 +13,14 @@ export interface PayloadType {
class GetGroupMemberList extends BaseAction<PayloadType, OB11GroupMember[]> {
actionName = ActionName.GetGroupMemberList
protected async _handle(payload: PayloadType){
protected async _handle(payload: PayloadType) {
const group = await getGroup(payload.group_id.toString());
if (group) {
if (!group.members?.length) {
group.members = await NTQQApi.getGroupMembers(payload.group_id.toString())
}
return OB11Constructor.groupMembers(group);
}
else {
} else {
throw (`${payload.group_id}不存在`)
}
}

View File

@@ -0,0 +1,10 @@
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
export default class GetGuildList extends BaseAction<null, null> {
actionName = ActionName.GetGuildList
protected async _handle(payload: null): Promise<null> {
return null;
}
}

View File

@@ -0,0 +1,7 @@
import {GetFileBase} from "./GetFile";
import {ActionName} from "./types";
export default class GetImage extends GetFileBase {
actionName = ActionName.GetImage
}

View File

@@ -8,7 +8,7 @@ import {ActionName} from "./types";
class GetLoginInfo extends BaseAction<null, OB11User> {
actionName = ActionName.GetLoginInfo
protected async _handle(payload: null){
protected async _handle(payload: null) {
return OB11Constructor.selfInfo(selfInfo);
}
}

View File

@@ -1,8 +1,8 @@
import {getHistoryMsgByShortId} from "../../common/data";
import {OB11Message} from '../types';
import {OB11Constructor} from "../constructor";
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
import {dbUtil} from "../../common/db";
export interface PayloadType {
@@ -14,18 +14,19 @@ export type ReturnDataType = OB11Message
class GetMsg extends BaseAction<PayloadType, OB11Message> {
actionName = ActionName.GetMsg
protected async _handle(payload: PayloadType){
protected async _handle(payload: PayloadType) {
// log("history msg ids", Object.keys(msgHistory));
if (!payload.message_id){
throw("参数message_id不能为空")
if (!payload.message_id) {
throw ("参数message_id不能为空")
}
const msg = getHistoryMsgByShortId(payload.message_id)
if (msg) {
const msgData = await OB11Constructor.message(msg);
return msgData
} else {
throw("消息不存在")
let msg = await dbUtil.getMsgByShortId(payload.message_id)
if(!msg) {
msg = await dbUtil.getMsgByLongId(payload.message_id.toString())
}
if (!msg){
throw ("消息不存在")
}
return await OB11Constructor.message(msg)
}
}

View File

@@ -0,0 +1,15 @@
import {GetFileBase, GetFilePayload, GetFileResponse} from "./GetFile";
import {ActionName} from "./types";
interface Payload extends GetFilePayload {
out_format: 'mp3' | 'amr' | 'wma' | 'm4a' | 'spx' | 'ogg' | 'wav' | 'flac'
}
export default class GetRecord extends GetFileBase {
actionName = ActionName.GetRecord
protected async _handle(payload: Payload): Promise<GetFileResponse> {
let res = super._handle(payload);
return res;
}
}

View File

@@ -1,13 +1,15 @@
import BaseAction from "./BaseAction";
import {OB11Status} from "../types";
import {ActionName} from "./types";
import {selfInfo} from "../../common/data";
export default class GetStatus extends BaseAction<any, OB11Status> {
actionName = ActionName.GetStatus
protected async _handle(payload: any): Promise<OB11Status> {
return {
online: null,
online: selfInfo.online,
good: true
}
}

View File

@@ -1,10 +1,11 @@
import BaseAction from "./BaseAction";
import {OB11Version} from "../types";
import {version} from "../../common/data";
import {ActionName} from "./types";
import {version} from "../../version";
export default class GetVersionInfo extends BaseAction<any, OB11Version>{
export default class GetVersionInfo extends BaseAction<any, OB11Version> {
actionName = ActionName.GetVersionInfo
protected async _handle(payload: any): Promise<OB11Version> {
return {
app_name: "LLOneBot",

View File

@@ -0,0 +1,14 @@
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
interface Payload{
message_id: number
}
export default class GoCQHTTPMarkMsgAsRead extends BaseAction<Payload, null>{
actionName = ActionName.GoCQHTTP_MarkMsgAsRead
protected async _handle(payload: Payload): Promise<null> {
return null
}
}

View File

@@ -1,9 +1,17 @@
import SendMsg from "./SendMsg";
import {ActionName} from "./types";
import {ActionName, BaseCheckResult} from "./types";
import {OB11PostSendMsg} from "../types";
import {log} from "../../common/utils";
class SendGroupMsg extends SendMsg{
class SendGroupMsg extends SendMsg {
actionName = ActionName.SendGroupMsg
protected async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> {
delete payload.user_id;
payload.message_type = "group"
return super.check(payload);
}
}
export default SendGroupMsg

View File

@@ -0,0 +1,35 @@
import BaseAction from "./BaseAction";
import {getFriend, getUidByUin, uidMaps} from "../../common/data";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
import {log} from "../../common/utils";
interface Payload {
user_id: number,
times: number
}
export default class SendLike extends BaseAction<Payload, null> {
actionName = ActionName.SendLike
protected async _handle(payload: Payload): Promise<null> {
log("点赞参数", payload)
try {
const qq = payload.user_id.toString();
const friend = await getFriend(qq)
let uid: string;
if (!friend) {
uid = getUidByUin(qq)
} else {
uid = friend.uid
}
let result = await NTQQApi.likeFriend(uid, parseInt(payload.times?.toString()) || 1);
if (result.result !== 0) {
throw result.errMsg
}
} catch (e) {
throw `点赞失败 ${e}`
}
return null
}
}

View File

@@ -1,14 +1,31 @@
import {AtType, ChatType, Group, SendMessageElement} from "../../ntqqapi/types";
import {addHistoryMsg, friends, getGroup, getHistoryMsgByShortId, getUidByUin, selfInfo,} from "../../common/data";
import {OB11MessageData, OB11MessageDataType, OB11MessageMixType, OB11MessageNode, OB11PostSendMsg} from '../types';
import {
AtType,
ChatType,
ElementType,
Group,
RawMessage,
SendArkElement,
SendMessageElement
} from "../../ntqqapi/types";
import {friends, getFriend, getGroup, getGroupMember, getUidByUin, selfInfo,} from "../../common/data";
import {
OB11MessageCustomMusic,
OB11MessageData,
OB11MessageDataType,
OB11MessageMixType,
OB11MessageNode,
OB11PostSendMsg
} from '../types';
import {NTQQApi, Peer} from "../../ntqqapi/ntcall";
import {SendMsgElementConstructor} from "../../ntqqapi/constructor";
import {uri2local} from "../utils";
import BaseAction from "./BaseAction";
import {ActionName, BaseCheckResult} from "./types";
import * as fs from "fs";
import {log} from "../../common/utils";
import {v4 as uuidv4} from "uuid"
import * as fs from "node:fs";
import {log, sleep} from "../../common/utils";
import {decodeCQCode} from "../cqcode";
import {dbUtil} from "../../common/db";
import {ALLOW_SEND_TEMP_MSG} from "../../common/config";
function checkSendMessage(sendMsgList: OB11MessageData[]) {
function checkUri(uri: string): boolean {
@@ -49,39 +66,55 @@ export interface ReturnDataType {
message_id: number
}
class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
actionName = ActionName.SendMsg
protected async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> {
const messages = this.convertMessage2List(payload.message);
const fmNum = this.forwardMsgNum(payload)
const fmNum = this.getSpecialMsgNum(payload, OB11MessageDataType.node)
if (fmNum && fmNum != messages.length) {
return {
valid: false,
message: "转发消息不能和普通消息混在一起发送,转发需要保证message只有type为node的元素"
}
}
if (payload.group_id && !(await getGroup(payload.group_id))) {
return {
valid: false,
message: `${payload.group_id}不存在`
}
}
if (payload.user_id && payload.message_type !== "group") {
if (!(await getFriend(payload.user_id))) {
if (!ALLOW_SEND_TEMP_MSG) {
return {
valid: false,
message: `不能发送临时消息`
}
}
}
}
return {
valid: true,
}
}
protected async _handle(payload: OB11PostSendMsg) {
const peer: Peer = {
chatType: ChatType.friend,
peerUid: ""
}
let isTempMsg = false;
let group: Group | undefined = undefined;
if (payload?.group_id) {
const genGroupPeer = async () => {
group = await getGroup(payload.group_id.toString())
if (!group) {
throw (`${payload.group_id}不存在`)
}
peer.chatType = ChatType.group
// peer.name = group.name
peer.peerUid = group.groupCode
} else if (payload?.user_id) {
}
const genFriendPeer = () => {
const friend = friends.find(f => f.uin == payload.user_id.toString())
if (friend) {
// peer.name = friend.nickName
@@ -93,83 +126,211 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
throw (`找不到私聊对象${payload.user_id}`)
}
// peer.name = tempUser.nickName
isTempMsg = true;
peer.peerUid = tempUserUid;
}
}
if (payload?.group_id && payload.message_type === "group") {
await genGroupPeer()
} else if (payload?.user_id) {
genFriendPeer()
} else if (payload.group_id) {
await genGroupPeer()
} else {
throw ("发送消息参数错误, 请指定group_id或user_id")
}
const messages = this.convertMessage2List(payload.message);
if (this.forwardMsgNum(payload)) {
if (this.getSpecialMsgNum(payload, OB11MessageDataType.node)) {
try {
const returnMsg = await this.handleForwardNode(peer, messages as OB11MessageNode[], group)
return {message_id: returnMsg.msgShortId}
} catch (e) {
throw ("发送转发消息失败 " + e.toString())
}
} else {
if (this.getSpecialMsgNum(payload, OB11MessageDataType.music)) {
const music: OB11MessageCustomMusic = messages[0] as OB11MessageCustomMusic
if (music) {
const {url, audio, title, content, image} = music.data;
const selfPeer: Peer = {peerUid: selfInfo.uid, chatType: ChatType.friend}
// 搞不定!
// const musicMsg = await this.send(selfPeer, [this.genMusicElement(url, audio, title, content, image)], [], false)
// 转发
// const res = await NTQQApi.forwardMsg(selfPeer, peer, [musicMsg.msgId])
// log("转发音乐消息成功", res);
// return {message_id: musicMsg.msgShortId}
}
}
}
// log("send msg:", peer, sendElements)
const {sendElements, deleteAfterSentFiles} = await this.createSendElements(messages, group)
try {
const returnMsg = await this.send(peer, sendElements, deleteAfterSentFiles)
return {message_id: returnMsg.msgShortId}
} catch (e) {
throw (e.toString())
}
const returnMsg = await this.send(peer, sendElements, deleteAfterSentFiles)
deleteAfterSentFiles.map(f => fs.unlink(f, () => {
}));
return {message_id: returnMsg.msgShortId}
}
private convertMessage2List(message: OB11MessageMixType) {
protected convertMessage2List(message: OB11MessageMixType) {
if (typeof message === "string") {
message = [{
type: OB11MessageDataType.text,
data: {
text: message
}
}] as OB11MessageData[]
// message = [{
// type: OB11MessageDataType.text,
// data: {
// text: message
// }
// }] as OB11MessageData[]
message = decodeCQCode(message.toString())
} else if (!Array.isArray(message)) {
message = [message]
}
return message;
}
private forwardMsgNum(payload: OB11PostSendMsg): number {
private getSpecialMsgNum(payload: OB11PostSendMsg, msgType: OB11MessageDataType): number {
if (Array.isArray(payload.message)) {
return payload.message.filter(msg => msg.type == OB11MessageDataType.node).length
return payload.message.filter(msg => msg.type == msgType).length
}
return 0
}
private async cloneMsg(msg: RawMessage): Promise<RawMessage> {
log("克隆的目标消息", msg)
let sendElements: SendMessageElement[] = [];
for (const ele of msg.elements) {
sendElements.push(ele as SendMessageElement)
// Object.keys(ele).forEach((eleKey) => {
// if (eleKey.endsWith("Element")) {
// }
}
if (sendElements.length === 0) {
log("需要clone的消息无法解析将会忽略掉", msg)
}
log("克隆消息", sendElements)
try {
const nodeMsg = await NTQQApi.sendMsg({
chatType: ChatType.friend,
peerUid: selfInfo.uid
}, sendElements, true);
await sleep(500);
return nodeMsg
} catch (e) {
log(e, "克隆转发消息失败,将忽略本条消息", msg);
}
}
// 返回一个合并转发的消息id
private async handleForwardNode(destPeer: Peer, messageNodes: OB11MessageNode[], group: Group | undefined) {
const selfPeer: Peer = {
const selfPeer = {
chatType: ChatType.friend,
peerUid: selfInfo.uid
}
let nodeIds: string[] = []
let nodeMsgIds: string[] = []
// 先判断一遍是不是id和自定义混用
let needClone = messageNodes.filter(node => node.data.id).length && messageNodes.filter(node => !node.data.id).length
for (const messageNode of messageNodes) {
// 一个node表示一个人的消息
let nodeId = messageNode.data.id;
// 有nodeId表示一个子转发消息卡片
if (nodeId) {
nodeIds.push(nodeId)
let nodeMsg = await dbUtil.getMsgByShortId(parseInt(nodeId));
if (!needClone) {
nodeMsgIds.push(nodeMsg.msgId)
} else {
if (nodeMsg.peerUid !== selfInfo.uid) {
const cloneMsg = await this.cloneMsg(nodeMsg)
if (cloneMsg) {
nodeMsgIds.push(cloneMsg.msgId)
}
}
}
} else {
// 自定义的消息
// 提取消息段发给自己生成消息id
const {
sendElements,
deleteAfterSentFiles
} = await this.createSendElements(this.convertMessage2List(messageNode.data.content), group)
try {
const {
sendElements,
deleteAfterSentFiles
} = await this.createSendElements(this.convertMessage2List(messageNode.data.content), group);
log("开始生成转发节点", sendElements);
const nodeMsg = await this.send(selfPeer, sendElements, deleteAfterSentFiles, true);
nodeIds.push(nodeMsg.msgId)
let sendElementsSplit: SendMessageElement[][] = []
let splitIndex = 0;
for (const ele of sendElements) {
if (!sendElementsSplit[splitIndex]) {
sendElementsSplit[splitIndex] = []
}
if (ele.elementType === ElementType.FILE || ele.elementType === ElementType.VIDEO) {
if (sendElementsSplit[splitIndex].length > 0) {
splitIndex++;
}
sendElementsSplit[splitIndex] = [ele]
splitIndex++;
} else {
sendElementsSplit[splitIndex].push(ele)
}
log(sendElementsSplit)
}
// log("分割后的转发节点", sendElementsSplit)
for (const eles of sendElementsSplit) {
const nodeMsg = await this.send(selfPeer, eles, [], true);
nodeMsgIds.push(nodeMsg.msgId)
await sleep(500);
log("转发节点生成成功", nodeMsg.msgId);
}
deleteAfterSentFiles.map(f => fs.unlink(f, () => {
}));
} catch (e) {
log("生转发消息节点失败", e)
log("生转发消息节点失败", e)
}
}
}
// 检查srcPeer是否一致不一致则需要克隆成自己的消息, 让所有srcPeer都变成自己的使其保持一致才能够转发
let nodeMsgArray: Array<RawMessage> = []
let srcPeer: Peer = null;
let needSendSelf = false;
for (const [index, msgId] of nodeMsgIds.entries()) {
const nodeMsg = await dbUtil.getMsgByLongId(msgId)
if (nodeMsg) {
nodeMsgArray.push(nodeMsg)
if (!srcPeer) {
srcPeer = {chatType: nodeMsg.chatType, peerUid: nodeMsg.peerUid}
} else if (srcPeer.peerUid !== nodeMsg.peerUid) {
needSendSelf = true
srcPeer = selfPeer
}
}
}
log("nodeMsgArray", nodeMsgArray);
nodeMsgIds = nodeMsgArray.map(msg => msg.msgId);
if (needSendSelf) {
log("需要克隆转发消息");
for (const [index, msg] of nodeMsgArray.entries()) {
if (msg.peerUid !== selfInfo.uid) {
const cloneMsg = await this.cloneMsg(msg)
if (cloneMsg) {
nodeMsgIds[index] = cloneMsg.msgId
}
}
}
}
// elements之间用换行符分隔
// let _sendForwardElements: SendMessageElement[] = []
// for(let i = 0; i < sendForwardElements.length; i++){
// _sendForwardElements.push(sendForwardElements[i])
// _sendForwardElements.push(SendMsgElementConstructor.text("\n\n"))
// }
// const nodeMsg = await NTQQApi.sendMsg(selfPeer, _sendForwardElements, true);
// nodeIds.push(nodeMsg.msgId)
// await sleep(500);
// 开发转发
try {
return await NTQQApi.multiForwardMsg(selfPeer, destPeer, nodeIds)
log("开发转发", nodeMsgIds)
return await NTQQApi.multiForwardMsg(srcPeer, destPeer, nodeMsgIds)
} catch (e) {
log("forward failed", e)
return null;
@@ -192,13 +353,17 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
}
break;
case OB11MessageDataType.at: {
if (!group) {
continue
}
let atQQ = sendMsg.data?.qq;
if (atQQ) {
atQQ = atQQ.toString()
if (atQQ === "all") {
sendElements.push(SendMsgElementConstructor.at(atQQ, atQQ, AtType.atAll, "全体成员"))
} else {
const atMember = group?.members.find(m => m.uin == atQQ)
// const atMember = group?.members.find(m => m.uin == atQQ)
const atMember = await getGroupMember(group?.groupCode, atQQ);
if (atMember) {
sendElements.push(SendMsgElementConstructor.at(atQQ, atMember.uid, AtType.atUser, atMember.cardName || atMember.nick))
}
@@ -209,8 +374,7 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
case OB11MessageDataType.reply: {
let replyMsgId = sendMsg.data.id;
if (replyMsgId) {
replyMsgId = replyMsgId.toString()
const replyMsg = getHistoryMsgByShortId(replyMsgId)
const replyMsg = await dbUtil.getMsgByShortId(parseInt(replyMsgId))
if (replyMsg) {
sendElements.push(SendMsgElementConstructor.reply(replyMsg.msgSeq, replyMsg.msgId, replyMsg.senderUin, replyMsg.senderUin))
}
@@ -224,23 +388,53 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
}
}
break;
case OB11MessageDataType.image:
case OB11MessageDataType.file:
case OB11MessageDataType.video:
case OB11MessageDataType.voice: {
const file = sendMsg.data?.file
let file = sendMsg.data?.file
const payloadFileName = sendMsg.data?.name
if (file) {
const {path, isLocal} = (await uri2local(uuidv4(), file))
const cache = await dbUtil.getFileCache(file)
if (cache) {
if (fs.existsSync(cache.filePath)) {
file = "file://" + cache.filePath
} else if (cache.downloadFunc) {
await cache.downloadFunc()
file = cache.filePath;
} else if (cache.url) {
file = cache.url
}
log("找到文件缓存", file);
}
const {path, isLocal, fileName, errMsg} = (await uri2local(file))
if (errMsg) {
throw errMsg
}
if (path) {
if (!isLocal) { // 只删除http和base64转过来的文件
deleteAfterSentFiles.push(path)
}
if (sendMsg.type === OB11MessageDataType.image) {
sendElements.push(await SendMsgElementConstructor.pic(path))
const constructorMap = {
[OB11MessageDataType.image]: SendMsgElementConstructor.pic,
[OB11MessageDataType.voice]: SendMsgElementConstructor.ptt,
[OB11MessageDataType.video]: SendMsgElementConstructor.video,
[OB11MessageDataType.file]: SendMsgElementConstructor.file,
}
if (sendMsg.type === OB11MessageDataType.file) {
log("发送文件", path, payloadFileName || fileName)
sendElements.push(await SendMsgElementConstructor.file(path, payloadFileName || fileName));
} else if (sendMsg.type === OB11MessageDataType.video) {
log("发送视频", path, payloadFileName || fileName)
sendElements.push(await SendMsgElementConstructor.video(path, payloadFileName || fileName));
} else {
sendElements.push(await SendMsgElementConstructor.ptt(path))
sendElements.push(await constructorMap[sendMsg.type](path));
}
}
}
} break;
}
break;
}
}
@@ -251,16 +445,53 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
}
}
private async send(peer: Peer, sendElements: SendMessageElement[], deleteAfterSentFiles: string[], waitComplete = false) {
private async send(peer: Peer, sendElements: SendMessageElement[], deleteAfterSentFiles: string[], waitComplete = true) {
if (!sendElements.length) {
throw ("消息体无法解析")
}
const returnMsg = await NTQQApi.sendMsg(peer, sendElements, waitComplete)
addHistoryMsg(returnMsg)
const returnMsg = await NTQQApi.sendMsg(peer, sendElements, waitComplete, 20000);
log("消息发送结果", returnMsg)
returnMsg.msgShortId = await dbUtil.addMsg(returnMsg)
deleteAfterSentFiles.map(f => fs.unlink(f, () => {
}))
return returnMsg
}
private genMusicElement(url: string, audio: string, title: string, content: string, image: string): SendArkElement {
const musicJson = {
app: 'com.tencent.structmsg',
config: {
ctime: 1709689928,
forward: 1,
token: '5c1e4905f926dd3a64a4bd3841460351',
type: 'normal'
},
extra: {app_type: 1, appid: 100497308, uin: selfInfo.uin},
meta: {
news: {
action: '',
android_pkg_name: '',
app_type: 1,
appid: 100497308,
ctime: 1709689928,
desc: content || title,
jumpUrl: url,
musicUrl: audio,
preview: image,
source_icon: 'https://p.qpic.cn/qqconnect/0/app_100497308_1626060999/100?max-age=2592000&t=0',
source_url: '',
tag: 'QQ音乐',
title: title,
uin: selfInfo.uin,
}
},
prompt: content || title,
ver: '0.0.0.1',
view: 'news'
}
return SendMsgElementConstructor.ark(musicJson)
}
}
export default SendMsg

View File

@@ -1,8 +1,14 @@
import SendMsg from "./SendMsg";
import {ActionName} from "./types";
import {ActionName, BaseCheckResult} from "./types";
import {OB11PostSendMsg} from "../types";
class SendPrivateMsg extends SendMsg {
actionName = ActionName.SendPrivateMsg
protected async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> {
payload.message_type = "private"
return super.check(payload);
}
}
export default SendPrivateMsg

View File

@@ -0,0 +1,18 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
interface Payload {
flag: string,
approve: boolean,
remark?: string,
}
export default class SetFriendAddRequest extends BaseAction<Payload, null> {
actionName = ActionName.SetFriendAddRequest;
protected async _handle(payload: Payload): Promise<null> {
await NTQQApi.handleFriendRequest(parseInt(payload.flag), payload.approve)
return null;
}
}

View File

@@ -0,0 +1,25 @@
import BaseAction from "./BaseAction";
import {GroupRequestOperateTypes} from "../../ntqqapi/types";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
interface Payload {
flag: string,
// sub_type: "add" | "invite",
// type: "add" | "invite"
approve: boolean,
reason: string
}
export default class SetGroupAddRequest extends BaseAction<Payload, null> {
actionName = ActionName.SetGroupAddRequest
protected async _handle(payload: Payload): Promise<null> {
const seq = payload.flag.toString();
await NTQQApi.handleGroupRequest(seq,
payload.approve ? GroupRequestOperateTypes.approve : GroupRequestOperateTypes.reject,
payload.reason
)
return null
}
}

View File

@@ -0,0 +1,24 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {getGroupMember} from "../../common/data";
import {GroupMemberRole} from "../../ntqqapi/types";
import {ActionName} from "./types";
interface Payload {
group_id: number,
user_id: number,
enable: boolean
}
export default class SetGroupAdmin extends BaseAction<Payload, null> {
actionName = ActionName.SetGroupAdmin
protected async _handle(payload: Payload): Promise<null> {
const member = await getGroupMember(payload.group_id, payload.user_id)
if (!member) {
throw `群成员${payload.user_id}不存在`
}
await NTQQApi.setMemberRole(payload.group_id.toString(), member.uid, payload.enable ? GroupMemberRole.admin : GroupMemberRole.normal)
return null
}
}

View File

@@ -0,0 +1,24 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {getGroupMember} from "../../common/data";
import {ActionName} from "./types";
interface Payload {
group_id: number,
user_id: number,
duration: number
}
export default class SetGroupBan extends BaseAction<Payload, null> {
actionName = ActionName.SetGroupBan
protected async _handle(payload: Payload): Promise<null> {
const member = await getGroupMember(payload.group_id, payload.user_id)
if (!member) {
throw `群成员${payload.user_id}不存在`
}
await NTQQApi.banMember(payload.group_id.toString(),
[{uid: member.uid, timeStamp: parseInt(payload.duration.toString())}])
return null
}
}

View File

@@ -0,0 +1,23 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {getGroupMember} from "../../common/data";
import {ActionName} from "./types";
interface Payload {
group_id: number,
user_id: number,
card: string
}
export default class SetGroupCard extends BaseAction<Payload, null> {
actionName = ActionName.SetGroupCard
protected async _handle(payload: Payload): Promise<null> {
const member = await getGroupMember(payload.group_id, payload.user_id)
if (!member) {
throw `群成员${payload.user_id}不存在`
}
await NTQQApi.setMemberCard(payload.group_id.toString(), member.uid, payload.card || "")
return null
}
}

View File

@@ -0,0 +1,23 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {getGroupMember} from "../../common/data";
import {ActionName} from "./types";
interface Payload {
group_id: number,
user_id: number,
reject_add_request: boolean
}
export default class SetGroupKick extends BaseAction<Payload, null> {
actionName = ActionName.SetGroupKick
protected async _handle(payload: Payload): Promise<null> {
const member = await getGroupMember(payload.group_id, payload.user_id)
if (!member) {
throw `群成员${payload.user_id}不存在`
}
await NTQQApi.kickMember(payload.group_id.toString(), [member.uid], !!payload.reject_add_request);
return null
}
}

View File

@@ -0,0 +1,22 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {log} from "../../common/utils";
import {ActionName} from "./types";
interface Payload {
group_id: number,
is_dismiss: boolean
}
export default class SetGroupLeave extends BaseAction<Payload, any> {
actionName = ActionName.SetGroupLeave
protected async _handle(payload: Payload): Promise<any> {
try {
await NTQQApi.quitGroup(payload.group_id.toString())
} catch (e) {
log("退群失败", e)
throw e
}
}
}

View File

@@ -0,0 +1,18 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
interface Payload {
group_id: number,
group_name: string
}
export default class SetGroupName extends BaseAction<Payload, null> {
actionName = ActionName.SetGroupName
protected async _handle(payload: Payload): Promise<null> {
await NTQQApi.setGroupName(payload.group_id.toString(), payload.group_name)
return null
}
}

View File

@@ -0,0 +1,18 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
interface Payload {
group_id: number,
enable: boolean
}
export default class SetGroupWholeBan extends BaseAction<Payload, null> {
actionName = ActionName.SetGroupWholeBan
protected async _handle(payload: Payload): Promise<null> {
await NTQQApi.banGroup(payload.group_id.toString(), !!payload.enable)
return null
}
}

View File

@@ -0,0 +1,25 @@
import BaseAction from "../BaseAction";
import {OB11User} from "../../types";
import {getFriend, getGroupMember, groups} from "../../../common/data";
import {OB11Constructor} from "../../constructor";
import {ActionName} from "../types";
export default class GoCQHTTPGetStrangerInfo extends BaseAction<{ user_id: number }, OB11User> {
actionName = ActionName.GoCQHTTP_GetStrangerInfo
protected async _handle(payload: { user_id: number }): Promise<OB11User> {
const user_id = payload.user_id.toString()
const friend = await getFriend(user_id)
if (friend) {
return OB11Constructor.friend(friend);
}
for (const group of groups) {
const member = await getGroupMember(group.groupCode, user_id)
if (member) {
return OB11Constructor.groupMember(group.groupCode, member) as OB11User
}
}
throw ("查无此人")
}
}

View File

@@ -0,0 +1,16 @@
import SendMsg from "../SendMsg";
import {OB11PostSendMsg} from "../../types";
import {ActionName} from "../types";
export class GoCQHTTPSendGroupForwardMsg extends SendMsg {
actionName = ActionName.GoCQHTTP_SendGroupForwardMsg;
protected async check(payload: OB11PostSendMsg) {
payload.message = this.convertMessage2List(payload.messages);
return super.check(payload);
}
}
export class GoCQHTTPSendPrivateForwardMsg extends GoCQHTTPSendGroupForwardMsg {
actionName = ActionName.GoCQHTTP_SendPrivateForwardMsg;
}

View File

@@ -0,0 +1,37 @@
import BaseAction from "../BaseAction";
import {getGroup} from "../../../common/data";
import {ActionName} from "../types";
import {SendMsgElementConstructor} from "../../../ntqqapi/constructor";
import {ChatType, SendFileElement} from "../../../ntqqapi/types";
import {NTQQApi} from "../../../ntqqapi/ntcall";
import {uri2local} from "../../utils";
import fs from "fs";
interface Payload{
group_id: number
file: string
name: string
folder: string
}
export default class GoCQHTTPUploadGroupFile extends BaseAction<Payload, null> {
actionName = ActionName.GoCQHTTP_UploadGroupFile
protected async _handle(payload: Payload): Promise<null> {
const group = await getGroup(payload.group_id.toString());
if (!group){
throw new Error(`群组${payload.group_id}不存在`)
}
let file = payload.file;
if (fs.existsSync(file)){
file = `file://${file}`
}
const downloadResult = await uri2local(file);
if (downloadResult.errMsg){
throw new Error(downloadResult.errMsg)
}
let sendFileEle: SendFileElement = await SendMsgElementConstructor.file(downloadResult.path, payload.name);
await NTQQApi.sendMsg({chatType: ChatType.group, peerUid: group.groupCode}, [sendFileEle]);
return null
}
}

View File

@@ -14,18 +14,68 @@ import GetVersionInfo from "./GetVersionInfo";
import CanSendRecord from "./CanSendRecord";
import CanSendImage from "./CanSendImage";
import GetStatus from "./GetStatus";
import {GoCQHTTPSendGroupForwardMsg, GoCQHTTPSendPrivateForwardMsg} from "./go-cqhttp/SendForwardMsg";
import GoCQHTTPGetStrangerInfo from "./go-cqhttp/GetStrangerInfo";
import SendLike from "./SendLike";
import SetGroupAddRequest from "./SetGroupAddRequest";
import SetGroupLeave from "./SetGroupLeave";
import GetGuildList from "./GetGuildList";
import Debug from "./Debug";
import SetFriendAddRequest from "./SetFriendAddRequest";
import SetGroupWholeBan from "./SetGroupWholeBan";
import SetGroupName from "./SetGroupName";
import SetGroupBan from "./SetGroupBan";
import SetGroupKick from "./SetGroupKick";
import SetGroupAdmin from "./SetGroupAdmin";
import SetGroupCard from "./SetGroupCard";
import GetImage from "./GetImage";
import GetRecord from "./GetRecord";
import GoCQHTTPMarkMsgAsRead from "./MarkMsgAsRead";
import CleanCache from "./CleanCache";
import GoCQHTTPUploadGroupFile from "./go-cqhttp/UploadGroupFile";
import {GetConfigAction, SetConfigAction} from "./llonebot/Config";
import GetGroupAddRequest from "./llonebot/GetGroupAddRequest";
import SetQQAvatar from './llonebot/SetQQAvatar'
export const actionHandlers = [
new Debug(),
new GetConfigAction(),
new SetConfigAction(),
new GetGroupAddRequest(),
new SetQQAvatar(),
// onebot11
new SendLike(),
new GetMsg(),
new GetLoginInfo(),
new GetFriendList(),
new GetGroupList(), new GetGroupInfo(), new GetGroupMemberList(), new GetGroupMemberInfo(),
new SendGroupMsg(), new SendPrivateMsg(), new SendMsg(),
new DeleteMsg(),
new SetGroupAddRequest(),
new SetFriendAddRequest(),
new SetGroupLeave(),
new GetVersionInfo(),
new CanSendRecord(),
new CanSendImage(),
new GetStatus()
new GetStatus(),
new SetGroupWholeBan(),
new SetGroupBan(),
new SetGroupKick(),
new SetGroupAdmin(),
new SetGroupName(),
new SetGroupCard(),
new GetImage(),
new GetRecord(),
new CleanCache(),
//以下为go-cqhttp api
new GoCQHTTPSendGroupForwardMsg(),
new GoCQHTTPSendPrivateForwardMsg(),
new GoCQHTTPGetStrangerInfo(),
new GetGuildList(),
new GoCQHTTPMarkMsgAsRead(),
new GoCQHTTPUploadGroupFile(),
]
function initActionMap() {

View File

@@ -0,0 +1,20 @@
import BaseAction from "../BaseAction";
import {Config} from "../../../common/types";
import {getConfigUtil} from "../../../common/utils";
import {ActionName} from "../types";
import {setConfig} from "../../../main/setConfig";
export class GetConfigAction extends BaseAction<null, Config> {
actionName = ActionName.GetConfig
protected async _handle(payload: null): Promise<Config> {
return getConfigUtil().getConfig()
}
}
export class SetConfigAction extends BaseAction<Config, void> {
actionName = ActionName.SetConfig
protected async _handle(payload: Config): Promise<void> {
setConfig(payload).then();
}
}

View File

@@ -0,0 +1,32 @@
import {GroupNotify, GroupNotifyStatus} from "../../../ntqqapi/types";
import BaseAction from "../BaseAction";
import {ActionName} from "../types";
import {NTQQApi} from "../../../ntqqapi/ntcall";
import {uidMaps} from "../../../common/data";
import {log} from "../../../common/utils";
interface OB11GroupRequestNotify {
group_id: number,
user_id: number,
flag: string
}
export default class GetGroupAddRequest extends BaseAction<null, OB11GroupRequestNotify[]> {
actionName = ActionName.GetGroupIgnoreAddRequest
protected async _handle(payload: null): Promise<OB11GroupRequestNotify[]> {
const data = await NTQQApi.getGroupIgnoreNotifies()
log(data);
let notifies: GroupNotify[] = data.notifies.filter(notify => notify.status === GroupNotifyStatus.WAIT_HANDLE);
let returnData: OB11GroupRequestNotify[] = []
for (const notify of notifies) {
const uin = uidMaps[notify.user1.uid] || (await NTQQApi.getUserDetailInfo(notify.user1.uid))?.uin
returnData.push({
group_id: parseInt(notify.group.groupCode),
user_id: parseInt(uin),
flag: notify.seq
})
}
return returnData;
}
}

View File

@@ -0,0 +1,44 @@
import BaseAction from "../BaseAction";
import {NTQQApi} from "../../../ntqqapi/ntcall";
import {ActionName} from "../types";
import { uri2local } from "../../utils";
import * as fs from "node:fs";
import { checkFileReceived } from "../../../common/utils";
// import { log } from "../../../common/utils";
interface Payload {
file: string
}
export default class SetAvatar extends BaseAction<Payload, null> {
actionName = ActionName.SetQQAvatar
protected async _handle(payload: Payload): Promise<null> {
const {path, isLocal, errMsg} = (await uri2local(payload.file))
if (errMsg){
throw `头像${payload.file}设置失败,file字段可能格式不正确`
}
if (path) {
await checkFileReceived(path, 5000); // 文件不存在QQ会崩溃需要提前判断
const ret = await NTQQApi.setQQAvatar(path)
if (!isLocal){
fs.unlink(path, () => {})
}
if (!ret) {
throw `头像${payload.file}设置失败,api无返回`
}
// log(`头像设置返回:${JSON.stringify(ret)}`)
if (ret['result'] == 1004022) {
throw `头像${payload.file}设置失败,文件可能不是图片格式`
} else if(ret['result'] != 0) {
throw `头像${payload.file}设置失败,未知的错误,${ret['result']}:${ret['errMsg']}`
}
} else {
if (!isLocal){
fs.unlink(path, () => {})
}
throw `头像${payload.file}设置失败,无法获取头像,文件可能不存在`
}
return null
}
}

View File

@@ -2,17 +2,24 @@ export type BaseCheckResult = ValidCheckResult | InvalidCheckResult
export interface ValidCheckResult {
valid: true
[k: string | number]: any
}
export interface InvalidCheckResult {
valid: false
message: string
[k: string | number]: any
}
export enum ActionName {
TestForwardMsg = "test_forward_msg",
GetGroupIgnoreAddRequest = "get_group_ignore_add_request",
SetQQAvatar = "set_qq_avatar",
GetConfig = "get_config",
SetConfig = "set_config",
Debug = "llonebot_debug",
SendLike = "send_like",
GetLoginInfo = "get_login_info",
GetFriendList = "get_friend_list",
GetGroupInfo = "get_group_info",
@@ -24,8 +31,27 @@ export enum ActionName {
SendGroupMsg = "send_group_msg",
SendPrivateMsg = "send_private_msg",
DeleteMsg = "delete_msg",
SetGroupAddRequest = "set_group_add_request",
SetFriendAddRequest = "set_friend_add_request",
SetGroupLeave = "set_group_leave",
GetVersionInfo = "get_version_info",
GetStatus = "get_status",
CanSendRecord = "can_send_record",
CanSendImage = "can_send_image",
SetGroupKick = "set_group_kick",
SetGroupBan = "set_group_ban",
SetGroupWholeBan = "set_group_whole_ban",
SetGroupAdmin = "set_group_admin",
SetGroupCard = "set_group_card",
SetGroupName = "set_group_name",
GetImage = "get_image",
GetRecord = "get_record",
CleanCache = "clean_cache",
// 以下为go-cqhttp api
GoCQHTTP_SendGroupForwardMsg = "send_group_forward_msg",
GoCQHTTP_SendPrivateForwardMsg = "send_private_forward_msg",
GoCQHTTP_GetStrangerInfo = "get_stranger_info",
GetGuildList = "get_guild_list",
GoCQHTTP_MarkMsgAsRead = "mark_msg_as_read",
GoCQHTTP_UploadGroupFile = "upload_group_file",
}

View File

@@ -1,4 +1,5 @@
import {OB11Return, OB11WebsocketReturn} from '../types';
import {OB11Return} from '../types';
import {isNull} from '../../common/utils';
export class OB11Response {
static res<T>(data: T, status: string, retcode: number, message: string = ""): OB11Return<T> {
@@ -6,31 +7,25 @@ export class OB11Response {
status: status,
retcode: retcode,
data: data,
message: message
message: message,
wording: message,
echo: null
}
}
static ok<T>(data: T) {
return OB11Response.res<T>(data, "ok", 0)
}
static error(err: string, retcode: number) {
return OB11Response.res(null, "failed", retcode, err)
}
}
export class OB11WebsocketResponse {
static res<T>(data: T, status: string, retcode: number, echo: string, message: string = ""): OB11WebsocketReturn<T> {
return {
status: status,
retcode: retcode,
data: data,
echo: echo,
message: message
static ok<T>(data: T, echo: any = null) {
let res = OB11Response.res<T>(data, "ok", 0)
if (!isNull(echo)) {
res.echo = echo;
}
return res;
}
static ok<T>(data: T, echo: string = "") {
return OB11WebsocketResponse.res<T>(data, "ok", 0, echo)
}
static error(err: string, retcode: number, echo: string = "") {
return OB11WebsocketResponse.res(null, "failed", retcode, echo, err)
static error(err: string, retcode: number, echo: any = null) {
let res = OB11Response.res(null, "failed", retcode, err)
if (!isNull(echo)) {
res.echo = echo;
}
return res;
}
}

View File

@@ -5,25 +5,43 @@ import {
OB11Message,
OB11MessageData,
OB11MessageDataType,
OB11User
OB11User,
OB11UserSex
} from "./types";
import {AtType, ChatType, Group, GroupMember, IMAGE_HTTP_HOST, RawMessage, SelfInfo, User} from '../ntqqapi/types';
import {getFriend, getGroupMember, getHistoryMsgBySeq, selfInfo} from '../common/data';
import {file2base64, getConfigUtil, log} from "../common/utils";
import {
AtType,
ChatType,
Group,
GroupMember,
IMAGE_HTTP_HOST,
RawMessage,
SelfInfo,
TipGroupElementType,
User
} from '../ntqqapi/types';
import {getFriend, getGroup, getGroupMember, selfInfo, tempGroupCodeMap} from '../common/data';
import {getConfigUtil, log, sleep} from "../common/utils";
import {NTQQApi} from "../ntqqapi/ntcall";
import {EventType} from "./event/OB11BaseEvent";
import {encodeCQCode} from "./cqcode";
import {dbUtil} from "../common/db";
import {OB11GroupIncreaseEvent} from "./event/notice/OB11GroupIncreaseEvent";
import {OB11GroupBanEvent} from "./event/notice/OB11GroupBanEvent";
import {OB11GroupUploadNoticeEvent} from "./event/notice/OB11GroupUploadNoticeEvent";
import {OB11GroupNoticeEvent} from "./event/notice/OB11GroupNoticeEvent";
export class OB11Constructor {
static async message(msg: RawMessage): Promise<OB11Message> {
const {enableLocalFile2Url} = getConfigUtil().getConfig()
const {enableLocalFile2Url, ob11: {messagePostFormat}} = getConfigUtil().getConfig()
const message_type = msg.chatType == ChatType.group ? "group" : "private";
const resMsg: OB11Message = {
self_id: parseInt(selfInfo.uin),
user_id: parseInt(msg.senderUin),
time: parseInt(msg.msgTime) || 0,
time: parseInt(msg.msgTime) || Date.now(),
message_id: msg.msgShortId,
real_id: msg.msgId,
real_id: msg.msgShortId,
message_type: msg.chatType == ChatType.group ? "group" : "private",
sender: {
user_id: parseInt(msg.senderUin),
@@ -33,11 +51,12 @@ export class OB11Constructor {
raw_message: "",
font: 14,
sub_type: "friend",
message: [],
post_type: "message",
message: messagePostFormat === 'string' ? '' : [],
message_format: messagePostFormat === 'string' ? 'string' : 'array',
post_type: selfInfo.uin == msg.senderUin ? EventType.MESSAGE_SENT : EventType.MESSAGE,
}
if (msg.chatType == ChatType.group) {
resMsg.sub_type = "normal"
resMsg.sub_type = "normal" // 这里go-cqhttp是group而onebot11标准是normal, 蛋疼
resMsg.group_id = parseInt(msg.peerUin)
const member = await getGroupMember(msg.peerUin, msg.senderUin);
if (member) {
@@ -52,6 +71,10 @@ export class OB11Constructor {
}
} else if (msg.chatType == ChatType.temp) {
resMsg.sub_type = "group"
const tempGroupCode = tempGroupCodeMap[msg.peerUin]
if (tempGroupCode) {
resMsg.group_id = parseInt(tempGroupCode)
}
}
for (let element of msg.elements) {
@@ -62,48 +85,113 @@ export class OB11Constructor {
if (element.textElement && element.textElement?.atType !== AtType.notAt) {
message_data["type"] = OB11MessageDataType.at
if (element.textElement.atType == AtType.atAll) {
message_data["data"]["mention"] = "all"
// message_data["data"]["mention"] = "all"
message_data["data"]["qq"] = "all"
} else {
let atUid = element.textElement.atNtUid
let atQQ = element.textElement.atUid
if (!atQQ || atQQ === "0") {
const atMember = await getGroupMember(msg.peerUin, null, atUid)
const atMember = await getGroupMember(msg.peerUin, atUid)
if (atMember) {
atQQ = atMember.uin
}
}
if (atQQ) {
message_data["data"]["mention"] = atQQ
// message_data["data"]["mention"] = atQQ
message_data["data"]["qq"] = atQQ
}
}
} else if (element.textElement) {
message_data["type"] = "text"
resMsg.raw_message += message_data["data"]["text"] = element.textElement.content
} else if (element.picElement) {
message_data["type"] = "image"
message_data["data"]["file_id"] = element.picElement.fileUuid
message_data["data"]["path"] = element.picElement.sourcePath
message_data["data"]["file"] = element.picElement.sourcePath
message_data["data"]["http_file"] = IMAGE_HTTP_HOST + element.picElement.originImageUrl
try {
await NTQQApi.downloadMedia(msg.msgId, msg.chatType, msg.peerUid,
element.elementId, element.picElement.thumbPath.get(0), element.picElement.sourcePath)
} catch (e) {
let text = element.textElement.content
if (!text.trim()) {
continue;
}
message_data["data"]["text"] = text
} else if (element.replyElement) {
message_data["type"] = "reply"
const replyMsg = getHistoryMsgBySeq(element.replyElement.replayMsgSeq)
if (replyMsg) {
message_data["data"]["id"] = replyMsg.msgShortId
} else {
continue
// log("收到回复消息", element.replyElement.replayMsgSeq)
try {
const replyMsg = await dbUtil.getMsgBySeqId(element.replyElement.replayMsgSeq)
// log("找到回复消息", replyMsg.msgShortId, replyMsg.msgId)
if (replyMsg) {
message_data["data"]["id"] = replyMsg.msgShortId.toString()
} else {
continue
}
} catch (e) {
log("获取不到引用的消息", e.stack, element.replyElement.replayMsgSeq)
}
} else if (element.picElement) {
message_data["type"] = "image"
// message_data["data"]["file"] = element.picElement.sourcePath
message_data["data"]["file"] = element.picElement.fileName
// message_data["data"]["path"] = element.picElement.sourcePath
const url = element.picElement.originImageUrl
const fileMd5 = element.picElement.md5HexStr
if (url) {
message_data["data"]["url"] = IMAGE_HTTP_HOST + url
} else if (fileMd5 && element.picElement.fileUuid.indexOf("_") === -1) { // fileuuid有下划线的是Linux发送的这个url是另外的格式目前尚未得知如何组装
message_data["data"]["url"] = `${IMAGE_HTTP_HOST}/gchatpic_new/0/0-0-${fileMd5.toUpperCase()}/0`
}
// message_data["data"]["file_id"] = element.picElement.fileUuid
message_data["data"]["file_size"] = element.picElement.fileSize
dbUtil.addFileCache(element.picElement.fileName, {
fileName: element.picElement.fileName,
filePath: element.picElement.sourcePath,
fileSize: element.picElement.fileSize.toString(),
url: message_data["data"]["url"],
downloadFunc: async () => {
await NTQQApi.downloadMedia(msg.msgId, msg.chatType, msg.peerUid,
element.elementId, element.picElement.thumbPath?.get(0) || "", element.picElement.sourcePath)
}
}).then()
// 不在自动下载图片
} else if (element.videoElement) {
message_data["type"] = OB11MessageDataType.video;
message_data["data"]["file"] = element.videoElement.fileName
message_data["data"]["path"] = element.videoElement.filePath
// message_data["data"]["file_id"] = element.videoElement.fileUuid
message_data["data"]["file_size"] = element.videoElement.fileSize
dbUtil.addFileCache(element.videoElement.fileName, {
fileName: element.videoElement.fileName,
filePath: element.videoElement.filePath,
fileSize: element.videoElement.fileSize,
downloadFunc: async () => {
await NTQQApi.downloadMedia(msg.msgId, msg.chatType, msg.peerUid,
element.elementId, element.videoElement.thumbPath.get(0), element.videoElement.filePath)
}
}).then()
// 怎么拿到url呢
} else if (element.fileElement) {
message_data["type"] = OB11MessageDataType.file;
message_data["data"]["file"] = element.fileElement.fileName
// message_data["data"]["path"] = element.fileElement.filePath
// message_data["data"]["file_id"] = element.fileElement.fileUuid
message_data["data"]["file_size"] = element.fileElement.fileSize
dbUtil.addFileCache(element.fileElement.fileName, {
fileName: element.fileElement.fileName,
filePath: element.fileElement.filePath,
fileSize: element.fileElement.fileSize,
downloadFunc: async () => {
await NTQQApi.downloadMedia(msg.msgId, msg.chatType, msg.peerUid,
element.elementId, null, element.fileElement.filePath)
}
}).then()
// 怎么拿到url呢
} else if (element.pttElement) {
message_data["type"] = OB11MessageDataType.voice;
message_data["data"]["file"] = element.pttElement.filePath
message_data["data"]["file_id"] = element.pttElement.fileUuid
message_data["data"]["file"] = element.pttElement.fileName
message_data["data"]["path"] = element.pttElement.filePath
// message_data["data"]["file_id"] = element.pttElement.fileUuid
message_data["data"]["file_size"] = element.pttElement.fileSize
dbUtil.addFileCache(element.pttElement.fileName, {
fileName: element.pttElement.fileName,
filePath: element.pttElement.filePath,
fileSize: element.pttElement.fileSize,
}).then()
// log("收到语音消息", msg)
// window.LLAPI.Ptt2Text(message.raw.msgId, message.peer, messages).then(text => {
@@ -118,28 +206,98 @@ export class OB11Constructor {
message_data["type"] = OB11MessageDataType.face;
message_data["data"]["id"] = element.faceElement.faceIndex.toString();
}
if (message_data.data.file) {
let filePath: string = message_data.data.file;
if (!enableLocalFile2Url) {
message_data.data.file = "file://" + filePath
} else { // 不使用本地路径
if (message_data.data.http_file) {
message_data.data.file = message_data.data.http_file
} else {
let {err, data} = await file2base64(filePath);
if (err) {
log("文件转base64失败", filePath, err)
} else {
message_data.data.file = "base64://" + data
// todo: 解析入群grayTipElement
else if (element.grayTipElement?.aioOpGrayTipElement) {
log("收到 group gray tip 消息", element.grayTipElement.aioOpGrayTipElement)
}
// if (message_data.data.file) {
// let filePath: string = message_data.data.file;
// if (!enableLocalFile2Url) {
// message_data.data.file = "file://" + filePath
// } else { // 不使用本地路径
// const ignoreTypes = [OB11MessageDataType.file, OB11MessageDataType.video]
// if (!ignoreTypes.includes(message_data.type)) {
// if (message_data.data.url && !message_data.data.url.startsWith(IMAGE_HTTP_HOST + "/download")) {
// message_data.data.file = message_data.data.url
// } else {
// let { err, data } = await file2base64(filePath);
// if (err) {
// log("文件转base64失败", filePath, err)
// } else {
// message_data.data.file = "base64://" + data
// }
// }
// } else {
// message_data.data.file = "file://" + filePath
// }
// }
// }
if (message_data.type !== "unknown" && message_data.data) {
const cqCode = encodeCQCode(message_data);
if (messagePostFormat === 'string') {
(resMsg.message as string) += cqCode;
} else (resMsg.message as OB11MessageData[]).push(message_data);
resMsg.raw_message += cqCode;
}
}
resMsg.raw_message = resMsg.raw_message.trim();
return resMsg;
}
static async GroupEvent(msg: RawMessage): Promise<OB11GroupNoticeEvent> {
if (msg.chatType !== ChatType.group) {
return;
}
for (let element of msg.elements) {
const groupElement = element.grayTipElement?.groupElement
if (groupElement) {
// log("收到群提示消息", groupElement)
if (groupElement.type == TipGroupElementType.memberIncrease) {
log("收到群成员增加消息", groupElement)
await sleep(1000);
const member = await getGroupMember(msg.peerUid, groupElement.memberUid);
let memberUin = member?.uin;
if (!memberUin) {
memberUin = (await NTQQApi.getUserDetailInfo(groupElement.memberUid)).uin
}
// log("获取新群成员QQ", memberUin)
const adminMember = await getGroupMember(msg.peerUid, groupElement.adminUid);
// log("获取同意新成员入群的管理员", adminMember)
if (memberUin) {
const operatorUin = adminMember?.uin || memberUin
let event = new OB11GroupIncreaseEvent(parseInt(msg.peerUid), parseInt(memberUin), parseInt(operatorUin));
// log("构造群增加事件", event)
return event;
}
}
else if (groupElement.type === TipGroupElementType.ban) {
log("收到群群员禁言提示", groupElement)
const memberUid = groupElement.shutUp.member.uid
const adminUid = groupElement.shutUp.admin.uid
let memberUin: string = ""
let duration = parseInt(groupElement.shutUp.duration)
let sub_type: "ban" | "lift_ban" = duration > 0 ? "ban" : "lift_ban"
if (memberUid){
memberUin = (await getGroupMember(msg.peerUid, memberUid))?.uin || (await NTQQApi.getUserDetailInfo(memberUid))?.uin
}
else {
memberUin = "0"; // 0表示全员禁言
if (duration > 0) {
duration = -1
}
}
const adminUin = (await getGroupMember(msg.peerUid, adminUid))?.uin || (await NTQQApi.getUserDetailInfo(adminUid))?.uin
if (memberUin && adminUin) {
return new OB11GroupBanEvent(parseInt(msg.peerUid), parseInt(memberUin), parseInt(adminUin), duration, sub_type);
}
}
}
if (message_data.type !== "unknown" && message_data.data) {
resMsg.message.push(message_data);
else if (element.fileElement){
return new OB11GroupUploadNoticeEvent(parseInt(msg.peerUid), parseInt(msg.senderUin), {id: element.fileElement.fileName, name: element.fileElement.fileName, size: parseInt(element.fileElement.fileSize)})
}
}
return resMsg;
}
static friend(friend: User): OB11User {
@@ -175,7 +333,19 @@ export class OB11Constructor {
group_id: parseInt(group_id),
user_id: parseInt(member.uin),
nickname: member.nick,
card: member.cardName
card: member.cardName,
sex: OB11UserSex.unknown,
age: 0,
area: "",
level: 0,
join_time: 0, // 暂时没法获取
last_sent_time: 0, // 暂时没法获取
title_expire_time: 0,
unfriendly: false,
card_changeable: true,
is_robot: member.isRobot,
shut_up_timestamp: member.shutUpTime,
role: OB11Constructor.groupMemberRole(member.role),
}
}
@@ -196,4 +366,4 @@ export class OB11Constructor {
static groups(groups: Group[]): OB11Group[] {
return groups.map(OB11Constructor.group)
}
}
}

78
src/onebot11/cqcode.ts Normal file
View File

@@ -0,0 +1,78 @@
import {OB11MessageData} from "./types";
const pattern = /\[CQ:(\w+)((,\w+=[^,\]]*)*)\]/
function unescape(source: string) {
return String(source)
.replace(/&#91;/g, '[')
.replace(/&#93;/g, ']')
.replace(/&#44;/g, ',')
.replace(/&amp;/g, '&')
}
function from(source: string) {
const capture = pattern.exec(source)
if (!capture) return null
const [, type, attrs] = capture
const data: Record<string, any> = {}
attrs && attrs.slice(1).split(',').forEach((str) => {
const index = str.indexOf('=')
data[str.slice(0, index)] = unescape(str.slice(index + 1))
})
return {type, data, capture}
}
function h(type: string, data: any) {
return {
type,
data,
}
}
export function decodeCQCode(source: string): OB11MessageData[] {
const elements: any[] = []
let result: ReturnType<typeof from>
while ((result = from(source))) {
const {type, data, capture} = result
if (capture.index) {
elements.push(h('text', {text: unescape(source.slice(0, capture.index))}))
}
elements.push(h(type, data))
source = source.slice(capture.index + capture[0].length)
}
if (source) elements.push(h('text', {text: unescape(source)}))
return elements
}
export function encodeCQCode(data: OB11MessageData) {
const CQCodeEscapeText = (text: string) => {
return text.replace(/\&/g, '&amp;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;')
};
const CQCodeEscape = (text: string) => {
return text.replace(/\&/g, '&amp;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;')
.replace(/,/g, '&#44;');
};
if (data.type === 'text') {
return CQCodeEscapeText(data.data.text);
}
let result = '[CQ:' + data.type;
for (const name in data.data) {
const value = data.data[name];
result += `,${name}=${CQCodeEscape(value)}`;
}
result += ']';
return result;
}
// const result = parseCQCode("[CQ:at,qq=114514]早上好啊[CQ:image,file=http://baidu.com/1.jpg,type=show,id=40004]")
// const result = parseCQCode("好好好")
// console.log(JSON.stringify(result))

View File

@@ -4,12 +4,13 @@ export enum EventType {
META = "meta_event",
REQUEST = "request",
NOTICE = "notice",
MESSAGE = "message"
MESSAGE = "message",
MESSAGE_SENT = "message_sent",
}
export abstract class OB11BaseEvent {
time = new Date().getTime();
time = Math.floor(Date.now() / 1000);
self_id = parseInt(selfInfo.uin);
post_type: EventType;
}

View File

@@ -1,6 +1,6 @@
import {OB11BaseNoticeEvent} from "./OB11BaseNoticeEvent";
import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export class OB11GroupAdminNoticeEvent extends OB11BaseNoticeEvent {
export class OB11GroupAdminNoticeEvent extends OB11GroupNoticeEvent {
notice_type = "group_admin"
sub_type: string // "set" | "unset"
sub_type: "set" | "unset" // "set" | "unset"
}

View File

@@ -0,0 +1,17 @@
import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export class OB11GroupBanEvent extends OB11GroupNoticeEvent {
notice_type = "group_ban";
operator_id: number;
duration: number;
sub_type: "ban" | "lift_ban";
constructor(groupId: number, userId: number, operatorId: number, duration: number, sub_type: "ban" | "lift_ban") {
super();
this.group_id = groupId;
this.operator_id = operatorId;
this.user_id = userId;
this.duration = duration;
this.sub_type = sub_type;
}
}

View File

@@ -2,7 +2,7 @@ import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
notice_type = "group_decrease";
sub_type = "leave"; // TODO: 实现其他几种子类型的识别 ("leave" | "kick" | "kick_me")
sub_type: "leave" | "kick" | "kick_me" = "leave"; // TODO: 实现其他几种子类型的识别 ("leave" | "kick" | "kick_me")
operate_id: number;
constructor(groupId: number, userId: number) {

View File

@@ -3,12 +3,12 @@ import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export class OB11GroupIncreaseEvent extends OB11GroupNoticeEvent {
notice_type = "group_increase";
sub_type = "approve"; // TODO: 实现其他几种子类型的识别 ("approve" | "invite")
operate_id: number;
operator_id: number;
constructor(groupId: number, userId: number) {
constructor(groupId: number, userId: number, operatorId: number) {
super();
this.group_id = groupId;
this.operate_id = userId; // 实际上不应该这么实现,但是现在还没有办法识别用户是被邀请的,还是主动加入的
this.operator_id = operatorId;
this.user_id = userId;
}
}
}

View File

@@ -0,0 +1,19 @@
import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export interface GroupUploadFile{
id: string,
name: string,
size: number
}
export class OB11GroupUploadNoticeEvent extends OB11GroupNoticeEvent {
notice_type = "group_upload"
file: GroupUploadFile
constructor(groupId: number, userId: number, file: GroupUploadFile) {
super();
this.group_id = groupId;
this.user_id = userId;
this.file = file
}
}

View File

@@ -0,0 +1,11 @@
import {OB11BaseNoticeEvent} from "../notice/OB11BaseNoticeEvent";
import {EventType} from "../OB11BaseEvent";
export class OB11FriendRequestEvent extends OB11BaseNoticeEvent {
post_type = EventType.REQUEST
user_id: number;
request_type: "friend" = "friend";
comment: string;
flag: string;
}

View File

@@ -0,0 +1,11 @@
import {OB11GroupNoticeEvent} from "../notice/OB11GroupNoticeEvent";
import {EventType} from "../OB11BaseEvent";
export class OB11GroupRequestEvent extends OB11GroupNoticeEvent {
post_type = EventType.REQUEST;
request_type: "group" = "group";
sub_type: "add" | "invite" = "add";
comment: string;
flag: string;
}

View File

@@ -6,6 +6,7 @@ import {actionHandlers} from "../action";
class OB11HTTPServer extends HttpServerBase {
name = "OneBot V11 server"
handleFailed(res: Response, payload: any, e: any) {
res.send(OB11Response.error(e.stack.toString(), 200))
}
@@ -19,8 +20,10 @@ class OB11HTTPServer extends HttpServerBase {
export const ob11HTTPServer = new OB11HTTPServer();
for (const action of actionHandlers) {
for(const method of ["post", "get"]){
ob11HTTPServer.registerRouter(method, action.actionName, (res, payload) => action.handle(payload))
setTimeout(() => {
for (const action of actionHandlers) {
for (const method of ["post", "get"]) {
ob11HTTPServer.registerRouter(method, action.actionName, (res, payload) => action.handle(payload))
}
}
}
}, 0)

View File

@@ -3,18 +3,18 @@ import {OB11Message} from "../types";
import {selfInfo} from "../../common/data";
import {OB11BaseMetaEvent} from "../event/meta/OB11BaseMetaEvent";
import {OB11BaseNoticeEvent} from "../event/notice/OB11BaseNoticeEvent";
import * as websocket from "ws";
import {WebSocket as WebSocketClass} from "ws";
import {wsReply} from "./ws/reply";
export type PostEventType = OB11Message | OB11BaseMetaEvent | OB11BaseNoticeEvent
const eventWSList: websocket.WebSocket[] = [];
const eventWSList: WebSocketClass[] = [];
export function registerWsEventSender(ws: websocket.WebSocket) {
export function registerWsEventSender(ws: WebSocketClass) {
eventWSList.push(ws);
}
export function unregisterWsEventSender(ws: websocket.WebSocket) {
export function unregisterWsEventSender(ws: WebSocketClass) {
let index = eventWSList.indexOf(ws);
if (index !== -1) {
eventWSList.splice(index, 1);
@@ -29,10 +29,10 @@ export function postWsEvent(event: PostEventType) {
}
}
export function postEvent(msg: PostEventType) {
export function postOB11Event(msg: PostEventType, reportSelf = false) {
const config = getConfigUtil().getConfig();
// 判断msg是否是event
if (!config.reportSelfMessage) {
if (!config.reportSelfMessage && !reportSelf) {
if ((msg as OB11Message).user_id.toString() == selfInfo.uin) {
return
}

View File

@@ -1,19 +1,21 @@
import {getConfigUtil, log} from "../../../common/utils";
import * as WebSocket from "ws";
import {selfInfo} from "../../../common/data";
import {LifeCycleSubType, OB11LifeCycleEvent} from "../../event/meta/OB11LifeCycleEvent";
import {ActionName} from "../../action/types";
import {OB11WebsocketResponse} from "../../action/utils";
import {OB11Response} from "../../action/utils";
import BaseAction from "../../action/BaseAction";
import {actionMap} from "../../action";
import {registerWsEventSender, unregisterWsEventSender} from "../postevent";
import {postWsEvent, registerWsEventSender, unregisterWsEventSender} from "../postOB11Event";
import {wsReply} from "./reply";
import {WebSocket as WebSocketClass} from "ws";
import {OB11HeartbeatEvent} from "../../event/meta/OB11HeartbeatEvent";
export let rwsList: ReverseWebsocket[] = [];
export class ReverseWebsocket {
public websocket: WebSocket.WebSocket;
public websocket: WebSocketClass;
public url: string;
private running: boolean = false;
@@ -33,24 +35,24 @@ export class ReverseWebsocket {
}
public async onmessage(msg: string) {
let receiveData: { action: ActionName, params: any, echo?: string } = {action: null, params: {}}
let echo = ""
log("收到反向Websocket消息", msg.toString())
let receiveData: { action: ActionName, params: any, echo?: any } = {action: null, params: {}}
let echo = null
try {
receiveData = JSON.parse(msg.toString())
echo = receiveData.echo
log("收到反向Websocket消息", receiveData)
} catch (e) {
return wsReply(this.websocket, OB11WebsocketResponse.error("json解析失败请检查数据格式", 1400, echo))
return wsReply(this.websocket, OB11Response.error("json解析失败请检查数据格式", 1400, echo))
}
const action: BaseAction<any, any> = actionMap.get(receiveData.action);
if (!action) {
return wsReply(this.websocket, OB11WebsocketResponse.error("不支持的api " + receiveData.action, 1404, echo))
return wsReply(this.websocket, OB11Response.error("不支持的api " + receiveData.action, 1404, echo))
}
try {
let handleResult = await action.websocketHandle(receiveData.params, echo);
wsReply(this.websocket, handleResult)
} catch (e) {
wsReply(this.websocket, OB11WebsocketResponse.error(`api处理出错:${e}`, 1200, echo))
wsReply(this.websocket, OB11Response.error(`api处理出错:${e}`, 1200, echo))
}
}
@@ -75,8 +77,8 @@ export class ReverseWebsocket {
}
private connect() {
const {token} = getConfigUtil().getConfig()
this.websocket = new WebSocket.WebSocket(this.url, {
const {token, heartInterval} = getConfigUtil().getConfig()
this.websocket = new WebSocketClass(this.url, {
handshakeTimeout: 2000,
perMessageDeflate: false,
headers: {
@@ -89,18 +91,22 @@ export class ReverseWebsocket {
log("Trying to connect to the websocket server: " + this.url);
this.websocket.on("open", ()=> {
this.websocket.on("open", () => {
log("Connected to the websocket server: " + this.url);
this.onopen();
});
this.websocket.on("message", async (data)=>{
this.websocket.on("message", async (data) => {
await this.onmessage(data.toString());
});
this.websocket.on("error", log);
this.websocket.on("close", ()=> {
const wsClientInterval = setInterval(() => {
postWsEvent(new OB11HeartbeatEvent(selfInfo.online, true, heartInterval));
}, heartInterval); // 心跳包
this.websocket.on("close", () => {
clearInterval(wsClientInterval);
log("The websocket connection: " + this.url + " closed, trying reconnecting...");
this.onclose();
});

View File

@@ -1,8 +1,8 @@
import {WebSocket} from "ws";
import {getConfigUtil, log} from "../../../common/utils";
import {actionMap} from "../../action";
import {OB11WebsocketResponse} from "../../action/utils";
import {postWsEvent, registerWsEventSender, unregisterWsEventSender} from "../postevent";
import {OB11Response} from "../../action/utils";
import {postWsEvent, registerWsEventSender, unregisterWsEventSender} from "../postOB11Event";
import {ActionName} from "../../action/types";
import BaseAction from "../../action/BaseAction";
import {LifeCycleSubType, OB11LifeCycleEvent} from "../../event/meta/OB11LifeCycleEvent";
@@ -10,38 +10,39 @@ import {OB11HeartbeatEvent} from "../../event/meta/OB11HeartbeatEvent";
import {WebsocketServerBase} from "../../../common/server/websocket";
import {IncomingMessage} from "node:http";
import {wsReply} from "./reply";
import {selfInfo} from "../../../common/data";
let heartbeatRunning = false;
class OB11WebsocketServer extends WebsocketServerBase {
authorizeFailed(wsClient: WebSocket) {
wsClient.send(JSON.stringify(OB11WebsocketResponse.res(null, "failed", 1403, "token验证失败")))
wsClient.send(JSON.stringify(OB11Response.res(null, "failed", 1403, "token验证失败")))
}
async handleAction(wsClient: WebSocket, actionName: string, params: any, echo?: string) {
async handleAction(wsClient: WebSocket, actionName: string, params: any, echo?: any) {
const action: BaseAction<any, any> = actionMap.get(actionName);
if (!action) {
return wsReply(wsClient, OB11WebsocketResponse.error("不支持的api " + actionName, 1404, echo))
return wsReply(wsClient, OB11Response.error("不支持的api " + actionName, 1404, echo))
}
try {
let handleResult = await action.websocketHandle(params, echo);
wsReply(wsClient, handleResult)
} catch (e) {
wsReply(wsClient, OB11WebsocketResponse.error(`api处理出错:${e}`, 1200, echo))
wsReply(wsClient, OB11Response.error(`api处理出错:${e.stack}`, 1200, echo))
}
}
onConnect(wsClient: WebSocket, url: string, req: IncomingMessage) {
if (url == "/api" || url == "/api/" || url == "/") {
wsClient.on("message", async (msg) => {
let receiveData: { action: ActionName, params: any, echo?: string } = {action: null, params: {}}
let echo = ""
log("收到正向Websocket消息", msg.toString())
let receiveData: { action: ActionName, params: any, echo?: any } = {action: null, params: {}}
let echo = null
try {
receiveData = JSON.parse(msg.toString())
echo = receiveData.echo
log("收到正向Websocket消息", receiveData);
} catch (e) {
return wsReply(wsClient, OB11WebsocketResponse.error("json解析失败请检查数据格式", 1400, echo))
return wsReply(wsClient, OB11Response.error("json解析失败请检查数据格式", 1400, echo))
}
this.handleAction(wsClient, receiveData.action, receiveData.params, receiveData.echo).then()
})
@@ -58,7 +59,7 @@ class OB11WebsocketServer extends WebsocketServerBase {
}
const {heartInterval} = getConfigUtil().getConfig();
const wsClientInterval = setInterval(() => {
postWsEvent(new OB11HeartbeatEvent(true, true, heartInterval));
postWsEvent(new OB11HeartbeatEvent(selfInfo.online, true, heartInterval));
}, heartInterval); // 心跳包
wsClient.on("close", () => {
log("event上报ws客户端已断开")

View File

@@ -1,18 +1,17 @@
import * as websocket from "ws";
import {OB11WebsocketResponse} from "../../action/utils";
import {PostEventType} from "../postevent";
import {log} from "../../../common/utils";
import {WebSocket as WebSocketClass} from "ws";
import {OB11Response} from "../../action/utils";
import {PostEventType} from "../postOB11Event";
import {isNull, log} from "../../../common/utils";
export function wsReply(wsClient: websocket.WebSocket, data: OB11WebsocketResponse | PostEventType) {
export function wsReply(wsClient: WebSocketClass, data: OB11Response | PostEventType) {
try {
let packet = Object.assign({
}, data);
if (!packet["echo"]){
let packet = Object.assign({}, data);
if (isNull(packet["echo"])) {
delete packet["echo"];
}
wsClient.send(JSON.stringify(packet))
log("ws 消息上报", wsClient.url, data)
log("ws 消息上报", wsClient.url || "", data);
} catch (e) {
log("websocket 回复失败", e)
log("websocket 回复失败", e.stack, data);
}
}

View File

@@ -1,4 +1,5 @@
import {AtType, RawMessage} from "../ntqqapi/types";
import {RawMessage} from "../ntqqapi/types";
import {EventType} from "./event/OB11BaseEvent";
export interface OB11User {
user_id: number;
@@ -30,6 +31,14 @@ export interface OB11GroupMember {
level?: number
role?: OB11GroupMemberRole
title?: string
area?: string
unfriendly?: boolean
title_expire_time?: number
card_changeable?: boolean
// 以下为gocq字段
shut_up_timestamp?: number
// 以下为扩展字段
is_robot?: boolean
}
export interface OB11Group {
@@ -58,16 +67,17 @@ export interface OB11Message {
self_id?: number,
time: number,
message_id: number,
real_id: string,
real_id: number,
user_id: number,
group_id?: number,
message_type: "private" | "group",
sub_type?: "friend" | "group" | "normal",
sender: OB11Sender,
message: OB11MessageData[],
message: OB11MessageData[] | string,
message_format: 'array' | 'string',
raw_message: string,
font: number,
post_type?: "message",
post_type?: EventType,
raw?: RawMessage
}
@@ -76,21 +86,22 @@ export interface OB11Return<DataType> {
retcode: number
data: DataType
message: string,
}
export interface OB11WebsocketReturn<DataType> extends OB11Return<DataType>{
echo: string
echo?: any, // ws调用api才有此字段
wording?: string, // go-cqhttp字段错误信息
}
export enum OB11MessageDataType {
text = "text",
image = "image",
music = "music",
video = "video",
voice = "record",
file = "file",
at = "at",
reply = "reply",
json = "json",
face = "face",
node = "node" // 合并转发消息
node = "node", // 合并转发消息
}
export interface OB11MessageText {
@@ -102,8 +113,9 @@ export interface OB11MessageText {
interface OB11MessageFileBase {
data: {
name?: string;
file: string,
http_file?: string;
url?: string;
}
}
@@ -115,6 +127,14 @@ export interface OB11MessageRecord extends OB11MessageFileBase {
type: OB11MessageDataType.voice
}
export interface OB11MessageFile extends OB11MessageFileBase {
type: OB11MessageDataType.file
}
export interface OB11MessageVideo extends OB11MessageFileBase {
type: OB11MessageDataType.video
}
export interface OB11MessageAt {
type: OB11MessageDataType.at
data: {
@@ -148,18 +168,31 @@ export interface OB11MessageNode {
}
}
export interface OB11MessageCustomMusic{
type: OB11MessageDataType.music
data: {
type: "custom"
url: string,
audio: string,
title: string,
content?: string,
image?: string
}
}
export type OB11MessageData =
OB11MessageText |
OB11MessageFace |
OB11MessageAt | OB11MessageReply |
OB11MessageImage | OB11MessageRecord |
OB11MessageNode
OB11MessageImage | OB11MessageRecord | OB11MessageFile | OB11MessageVideo |
OB11MessageNode | OB11MessageCustomMusic
export interface OB11PostSendMsg {
message_type?: "private" | "group"
user_id: string,
group_id?: string,
message: OB11MessageMixType;
messages?: OB11MessageMixType; // 兼容 go-cqhttp
}
export interface OB11Version {

View File

@@ -1,31 +1,62 @@
import {CONFIG_DIR, isGIF} from "../common/utils";
import * as path from 'path';
import {OB11MessageData} from "./types";
import {DATA_DIR, isGIF, log} from "../common/utils";
import {v4 as uuidv4} from "uuid";
import * as path from 'node:path';
import * as fileType from 'file-type';
import {dbUtil} from "../common/db";
const fs = require("fs").promises;
export async function uri2local(fileName: string, uri: string){
let filePath = path.join(CONFIG_DIR, fileName)
let url = new URL(uri);
type Uri2LocalRes = {
success: boolean,
errMsg: string,
fileName: string,
ext: string,
path: string,
isLocal: boolean
}
export async function uri2local(uri: string, fileName: string = null) : Promise<Uri2LocalRes>{
let res = {
success: false,
errMsg: "",
fileName: "",
ext: "",
path: "",
isLocal: false
}
if (!fileName) {
fileName = uuidv4();
}
let filePath = path.join(DATA_DIR, fileName)
let url = null;
try{
url = new URL(uri);
}catch (e) {
res.errMsg = `uri ${uri} 解析失败,` + e.toString() + ` 可能${uri}不存在`
return res
}
// log("uri protocol", url.protocol, uri);
if (url.protocol == "base64:") {
// base64转成文件
let base64Data = uri.split("base64://")[1]
try {
const buffer = Buffer.from(base64Data, 'base64');
await fs.writeFile(filePath, buffer);
} catch (e: any) {
res.errMsg = `base64文件下载失败,` + e.toString()
return res
}
} else if (url.protocol == "http:" || url.protocol == "https:") {
// 下载文件
let fetchRes = await fetch(url)
let fetchRes: Response;
try{
fetchRes = await fetch(url)
}catch (e) {
res.errMsg = `${url}下载失败`
return res
}
if (!fetchRes.ok) {
res.errMsg = `${url}下载失败,` + fetchRes.statusText
return res
@@ -33,67 +64,65 @@ export async function uri2local(fileName: string, uri: string){
let blob = await fetchRes.blob();
let buffer = await blob.arrayBuffer();
try {
const pathInfo = path.parse(decodeURIComponent(url.pathname))
if (pathInfo.name){
fileName = pathInfo.name
if (pathInfo.ext){
fileName += pathInfo.ext
// res.ext = pathInfo.ext
}
}
res.fileName = fileName
filePath = path.join(DATA_DIR, uuidv4() + fileName)
await fs.writeFile(filePath, Buffer.from(buffer));
} catch (e: any) {
res.errMsg = `${url}下载失败,` + e.toString()
return res
}
} else if (url.protocol === "file:"){
// await fs.copyFile(url.pathname, filePath);
let pathname = decodeURIComponent(url.pathname)
if (process.platform === "win32"){
filePath = pathname.slice(1)
}
else{
filePath = pathname
} else {
let pathname: string;
if (url.protocol === "file:") {
// await fs.copyFile(url.pathname, filePath);
pathname = decodeURIComponent(url.pathname)
if (process.platform === "win32") {
filePath = pathname.slice(1)
} else {
filePath = pathname
}
} else {
const cache = await dbUtil.getFileCache(uri);
if (cache) {
filePath = cache.filePath
} else {
filePath = uri;
}
}
res.isLocal = true
}
else{
res.errMsg = `不支持的file协议,` + url.protocol
return res
}
if (isGIF(filePath) && !res.isLocal) {
await fs.rename(filePath, filePath + ".gif");
filePath += ".gif";
// else{
// res.errMsg = `不支持的file协议,` + url.protocol
// return res
// }
// if (isGIF(filePath) && !res.isLocal) {
// await fs.rename(filePath, filePath + ".gif");
// filePath += ".gif";
// }
if (!res.isLocal && !res.ext) {
try {
let ext: string = (await fileType.fileTypeFromFile(filePath)).ext
if (ext) {
log("获取文件类型", ext, filePath)
await fs.rename(filePath, filePath + `.${ext}`)
filePath += `.${ext}`
res.fileName += `.${ext}`
res.ext = ext
}
} catch (e) {
// log("获取文件类型失败", filePath,e.stack)
}
}
res.success = true
res.path = filePath
return res
}
function checkSendMessage(sendMsgList: OB11MessageData[]) {
function checkUri(uri: string): boolean {
const pattern = /^(file:\/\/|http:\/\/|https:\/\/|base64:\/\/)/;
return pattern.test(uri);
}
for (let msg of sendMsgList) {
if (msg["type"] && msg["data"]) {
let type = msg["type"];
let data = msg["data"];
if (type === "text" && !data["text"]) {
return 400;
} else if (["image", "voice", "record"].includes(type)) {
if (!data["file"]) {
return 400;
} else {
if (checkUri(data["file"])) {
return 200;
} else {
return 400;
}
}
} else if (type === "at" && !data["qq"]) {
return 400;
} else if (type === "reply" && !data["id"]) {
return 400;
}
} else {
return 400
}
}
return 200;
}
}

View File

@@ -1,7 +1,14 @@
// Electron 主进程 与 渲染进程 交互的桥梁
import {Config} from "./common/types";
import {CHANNEL_GET_CONFIG, CHANNEL_LOG, CHANNEL_SET_CONFIG,} from "./common/channels";
import {Config, LLOneBotError} from "./common/types";
import {
CHANNEL_ERROR,
CHANNEL_GET_CONFIG,
CHANNEL_LOG,
CHANNEL_SELECT_FILE,
CHANNEL_SET_CONFIG,
} from "./common/channels";
const {contextBridge} = require("electron");
const {ipcRenderer} = require('electron');
@@ -12,9 +19,15 @@ const llonebot = {
setConfig: (config: Config) => {
ipcRenderer.send(CHANNEL_SET_CONFIG, config);
},
getConfig: async () => {
getConfig: async (): Promise<Config> => {
return ipcRenderer.invoke(CHANNEL_GET_CONFIG);
},
getError: async (): Promise<LLOneBotError> => {
return ipcRenderer.invoke(CHANNEL_ERROR);
},
selectFile: (): Promise<string> => {
return ipcRenderer.invoke(CHANNEL_SELECT_FILE);
}
}
export type LLOneBot = typeof llonebot;

View File

@@ -1,275 +0,0 @@
/// <reference path="./global.d.ts" />
// 打开设置界面时触发
async function onSettingWindowCreated(view: Element) {
window.llonebot.log("setting window created");
let config = await window.llonebot.getConfig()
const httpClass = "http";
const httpPostClass = "http-post";
const wsClass = "ws";
const reverseWSClass = "reverse-ws";
function createHttpHostEleStr(host: string) {
let eleStr = `
<setting-item data-direction="row" class="hostItem vertical-list-item ${httpPostClass}">
<h2>HTTP事件上报地址(http)</h2>
<input class="httpHost input-text" type="text" value="${host}"
style="width:60%;padding: 5px"
placeholder="如:http://127.0.0.1:8080/onebot/v11/http"/>
</setting-item>
`
return eleStr
}
function createWsHostEleStr(host: string) {
let eleStr = `
<setting-item data-direction="row" class="hostItem vertical-list-item ${reverseWSClass}">
<h2>反向websocket地址:</h2>
<input class="wsHost input-text" type="text" value="${host}"
style="width:60%;padding: 5px"
placeholder="如: ws://127.0.0.1:5410/onebot"/>
</setting-item>
`
return eleStr
}
let httpHostsEleStr = ""
for (const host of config.ob11.httpHosts) {
httpHostsEleStr += createHttpHostEleStr(host);
}
let wsHostsEleStr = ""
for (const host of config.ob11.wsHosts) {
wsHostsEleStr += createWsHostEleStr(host);
}
let html = `
<div class="config_view llonebot">
<setting-section>
<setting-panel>
<setting-list class="wrap">
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>启用HTTP服务</div>
</div>
<setting-switch id="http" ${config.ob11.enableHttp ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item class="vertical-list-item ${httpClass}" data-direction="row" style="display: ${config.ob11.enableHttp ? '' : 'none'}">
<setting-text>HTTP监听端口</setting-text>
<input id="httpPort" type="number" value="${config.ob11.httpPort}"/>
</setting-item>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>启用HTTP事件上报</div>
</div>
<setting-switch id="httpPost" ${config.ob11.enableHttpPost ? "is-active" : ""}></setting-switch>
</setting-item>
<div class="${httpPostClass}" style="display: ${config.ob11.enableHttpPost ? '' : 'none'}">
<div >
<button id="addHttpHost" class="q-button">添加HTTP POST上报地址</button>
</div>
<div id="httpHostItems">
${httpHostsEleStr}
</div>
</div>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>启用正向Websocket协议</div>
</div>
<setting-switch id="websocket" ${config.ob11.enableWs ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item class="vertical-list-item ${wsClass}" data-direction="row" style="display: ${config.ob11.enableWs ? '' : 'none'}">
<setting-text>正向Websocket监听端口</setting-text>
<input id="wsPort" type="number" value="${config.ob11.wsPort}"/>
</setting-item>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>启用反向Websocket协议</div>
</div>
<setting-switch id="websocketReverse" ${config.ob11.enableWsReverse ? "is-active" : ""}></setting-switch>
</setting-item>
<div class="${reverseWSClass}" style="display: ${config.ob11.enableWsReverse ? '' : 'none'}">
<div>
<button id="addWsHost" class="q-button">添加反向Websocket地址</button>
</div>
<div id="wsHostItems">
${wsHostsEleStr}
</div>
</div>
<setting-item class="vertical-list-item" data-direction="row">
<setting-text>Access Token</setting-text>
<input id="token" type="text" placeholder="可为空" value="${config.token}"/>
</setting-item>
<button id="save" class="q-button">保存</button>
</setting-list>
</setting-panel>
<setting-panel>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>上报文件不采用本地路径</div>
<div class="tips">开启后上报图片为http连接语音为base64编码</div>
</div>
<setting-switch id="switchFileUrl" ${config.enableLocalFile2Url ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>debug模式</div>
<div class="tips">开启后上报消息添加raw字段附带原始消息</div>
</div>
<setting-switch id="debug" ${config.debug ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>上报自身消息</div>
<div class="tips">慎用,不然会自己和自己聊个不停</div>
</div>
<setting-switch id="reportSelfMessage" ${config.reportSelfMessage ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>日志</div>
<div class="tips">目录:${window.LiteLoader.plugins["LLOneBot"].path.data}</div>
</div>
<setting-switch id="log" ${config.log ? "is-active" : ""}></setting-switch>
</setting-item>
</setting-panel>
</setting-section>
</div>
<style>
setting-panel {
padding: 10px;
}
.tips {
font-size: 0.75rem;
}
@media (prefers-color-scheme: dark){
.llonebot input {
color: white;
}
}
</style>
`
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
function addHostEle(type: string, initValue: string = "") {
let addressEle, hostItemsEle;
if (type === "ws") {
let addressDoc = parser.parseFromString(createWsHostEleStr(initValue), "text/html");
addressEle = addressDoc.querySelector("setting-item")
hostItemsEle = document.getElementById("wsHostItems");
} else {
let addressDoc = parser.parseFromString(createHttpHostEleStr(initValue), "text/html");
addressEle = addressDoc.querySelector("setting-item")
hostItemsEle = document.getElementById("httpHostItems");
}
hostItemsEle.appendChild(addressEle);
}
doc.getElementById("addHttpHost").addEventListener("click", () => addHostEle("http"))
doc.getElementById("addWsHost").addEventListener("click", () => addHostEle("ws"))
function switchClick(eleId: string, configKey: string, _config=null) {
if (!_config){
_config = config
}
doc.getElementById(eleId)?.addEventListener("click", (e) => {
const switchEle = e.target as HTMLInputElement
if (_config[configKey]) {
_config[configKey] = false
switchEle.removeAttribute("is-active")
} else {
_config[configKey] = true
switchEle.setAttribute("is-active", "")
}
// 妈蛋手动操作DOM越写越麻烦要不用vue算了
const keyClassMap = {
"enableHttp": httpClass,
"enableHttpPost": httpPostClass,
"enableWs": wsClass,
"enableWsReverse": reverseWSClass,
}
for (let e of document.getElementsByClassName(keyClassMap[configKey])) {
e["style"].display = _config[configKey] ? "" : "none"
}
window.llonebot.setConfig(config)
})
}
switchClick("http", "enableHttp", config.ob11);
switchClick("httpPost", "enableHttpPost", config.ob11);
switchClick("websocket", "enableWs", config.ob11);
switchClick("websocketReverse", "enableWsReverse", config.ob11);
switchClick("debug", "debug");
switchClick("switchFileUrl", "enableLocalFile2Url");
switchClick("reportSelfMessage", "reportSelfMessage");
switchClick("log", "log");
doc.getElementById("save")?.addEventListener("click",
() => {
const httpPortEle: HTMLInputElement = document.getElementById("httpPort") as HTMLInputElement;
const httpHostEles: HTMLCollectionOf<HTMLInputElement> = document.getElementsByClassName("httpHost") as HTMLCollectionOf<HTMLInputElement>;
const wsPortEle: HTMLInputElement = document.getElementById("wsPort") as HTMLInputElement;
const wsHostEles: HTMLCollectionOf<HTMLInputElement> = document.getElementsByClassName("wsHost") as HTMLCollectionOf<HTMLInputElement>;
const tokenEle = document.getElementById("token") as HTMLInputElement;
// 获取端口和host
const httpPort = httpPortEle.value
let httpHosts: string[] = [];
for (const hostEle of httpHostEles) {
const value = hostEle.value.trim();
value && httpHosts.push(value);
}
const wsPort = wsPortEle.value;
const token = tokenEle.value.trim();
let wsHosts: string[] = [];
for (const hostEle of wsHostEles) {
const value = hostEle.value.trim();
value && wsHosts.push(value);
}
config.ob11.httpPort = parseInt(httpPort);
config.ob11.httpHosts = httpHosts;
config.ob11.wsPort = parseInt(wsPort);
config.ob11.wsHosts = wsHosts;
config.token = token;
window.llonebot.setConfig(config);
alert("保存成功");
})
doc.body.childNodes.forEach(node => {
view.appendChild(node);
});
}
function init() {
let hash = location.hash;
if (hash === "#/blank") {
return;
}
}
if (location.hash === "#/blank") {
(window as any).navigation.addEventListener("navigatesuccess", init, {once: true});
} else {
init();
}
export {
onSettingWindowCreated
}

View File

@@ -0,0 +1,4 @@
export const SettingButton = (text: string, id?: string, type: string = 'secondary') => {
return `<setting-button ${type ? `data-type="${type}"` : ''} ${id ? `id="${id}"` : ''}>${text}</setting-button>`;
}

View File

@@ -0,0 +1,5 @@
export * from './list';
export * from './item';
export * from './button';
export * from './switch';
export * from './select';

View File

@@ -0,0 +1,10 @@
export const SettingItem = (title: string, subtitle?: string, action?: string, id?: string, visible: boolean = true) => {
return `<setting-item ${id ? `id="${id}"` : ''} ${!visible ? 'is-hidden' : ''}>
<div>
<setting-text>${title}</setting-text>
${subtitle ? `<setting-text data-type="secondary">${subtitle}</setting-text>` : ''}
</div>
${action ? `<div>${action}</div>` : ''}
</setting-item>`;
}

View File

@@ -0,0 +1,10 @@
export const SettingList = (items: string[], title?: string, isCollapsible: boolean = false, direction: string = 'column') => {
return `<setting-section ${title && !isCollapsible ? `data-title="${title}"` : ''}>
<setting-panel>
<setting-list ${direction ? `data-direction="${direction}"` : ''} ${isCollapsible ? 'is-collapsible' : ''} ${title && isCollapsible ? `data-title="${title}"` : ''}>
${items.join('')}
</setting-list>
</setting-panel>
</setting-section>`;
}

View File

@@ -0,0 +1,4 @@
export const SettingOption = (text: string, value?: string, isSelected: boolean = false) => {
return `<setting-option ${value ? `data-value="${value}"` : ''} ${isSelected ? 'is-selected' : ''}>${text}</setting-option>`;
}

View File

@@ -0,0 +1,11 @@
import { SettingOption } from "./option";
export const SettingSelect = (items: Array<{ text: string, value: string }>, configKey?: string, configValue?: any) => {
return `<setting-select ${configKey ? `data-config-key="${configKey}"` : ''}>
<div>
${items.map((e, i) => {
return SettingOption(e.text, e.value, (configKey && configValue ? configValue === e.value : i === 0));
}).join('')}
</div>
</setting-select>`;
}

View File

@@ -0,0 +1,9 @@
export const SettingSwitch = (configKey?: string, isActive: boolean = false, extraData?: Record<string, string>) => {
return `<setting-switch
${configKey ? `data-config-key="${configKey}"` : ''}
${isActive ? 'is-active' : ''}
${extraData ? Object.keys(extraData).map(key => `data-${key}="${extraData[key]}"`) : ''}
>
</setting-switch>`;
}

310
src/renderer/index.ts Normal file
View File

@@ -0,0 +1,310 @@
/// <reference path="../global.d.ts" />
import {
SettingButton,
SettingItem,
SettingList,
SettingSelect,
SettingSwitch
} from './components';
import StyleRaw from './style.css?raw';
// 打开设置界面时触发
async function onSettingWindowCreated(view: Element) {
window.llonebot.log("setting window created");
const isEmpty = (value: any) => value === undefined || value === null || value === '';
let config = await window.llonebot.getConfig();
let ob11Config = { ...config.ob11 };
const setConfig = (key: string, value: any) => {
const configKey = key.split('.');
if (key.indexOf('ob11') === 0) {
if (configKey.length === 2) ob11Config[configKey[1]] = value;
else ob11Config[key] = value;
} else {
if (configKey.length === 2) config[configKey[0]][configKey[1]] = value;
else config[key] = value;
if (!['heartInterval', 'token', 'ffmpeg'].includes(key)){
window.llonebot.setConfig(config);
}
}
};
const parser = new DOMParser();
const doc = parser.parseFromString([
'<div>',
`<style>${StyleRaw}</style>`,
SettingList([
SettingItem('启用 HTTP 服务', null,
SettingSwitch('ob11.enableHttp', config.ob11.enableHttp, { 'control-display-id': 'config-ob11-httpPort' }),
),
SettingItem('HTTP 服务监听端口', null,
`<div class="q-input"><input class="q-input__inner" data-config-key="ob11.httpPort" type="number" min="1" max="65534" value="${config.ob11.httpPort}" placeholder="${config.ob11.httpPort}" /></div>`,
'config-ob11-httpPort', config.ob11.enableHttp
),
SettingItem('启用 HTTP 事件上报', null,
SettingSwitch('ob11.enableHttpPost', config.ob11.enableHttpPost, { 'control-display-id': 'config-ob11-httpHosts' }),
),
`<div class="config-host-list" id="config-ob11-httpHosts" ${config.ob11.enableHttpPost ? '' : 'is-hidden'}>
<setting-item data-direction="row">
<div>
<setting-text>HTTP 事件上报地址</setting-text>
</div>
<setting-button id="config-ob11-httpHosts-add" data-type="primary">添加</setting-button>
</setting-item>
<div id="config-ob11-httpHosts-list"></div>
</div>`,
SettingItem('启用正向 WebSocket 服务', null,
SettingSwitch('ob11.enableWs', config.ob11.enableWs, { 'control-display-id': 'config-ob11-wsPort' }),
),
SettingItem('正向 WebSocket 服务监听端口', null,
`<div class="q-input"><input class="q-input__inner" data-config-key="ob11.wsPort" type="number" min="1" max="65534" value="${config.ob11.wsPort}" placeholder="${config.ob11.wsPort}" /></div>`,
'config-ob11-wsPort', config.ob11.enableWs
),
SettingItem('启用反向 WebSocket 服务', null,
SettingSwitch('ob11.enableWsReverse', config.ob11.enableWsReverse, { 'control-display-id': 'config-ob11-wsHosts' }),
),
`<div class="config-host-list" id="config-ob11-wsHosts" ${config.ob11.enableWsReverse ? '' : 'is-hidden'}>
<setting-item data-direction="row">
<div>
<setting-text>反向 WebSocket 监听地址</setting-text>
</div>
<setting-button id="config-ob11-wsHosts-add" data-type="primary">添加</setting-button>
</setting-item>
<div id="config-ob11-wsHosts-list"></div>
</div>`,
SettingItem(' WebSocket 服务心跳间隔',
'控制每隔多久发送一个心跳包,单位为毫秒',
`<div class="q-input"><input class="q-input__inner" data-config-key="heartInterval" type="number" min="1000" value="${config.heartInterval}" placeholder="${config.heartInterval}" /></div>`,
),
SettingItem('Access token', null,
`<div class="q-input" style="width:210px;"><input class="q-input__inner" data-config-key="token" type="text" value="${config.token}" placeholder="未设置" /></div>`,
),
SettingItem(
'消息上报格式类型',
'如客户端无特殊需求推荐保持默认设置,两者的详细差异可参考 <a href="javascript:LiteLoader.api.openExternal(\'https://github.com/botuniverse/onebot-11/tree/master/message#readme\');">OneBot v11 文档</a>',
SettingSelect([
{ text: '消息段', value: 'array' },
{ text: 'CQ码', value: 'string' },
], 'ob11.messagePostFormat', config.ob11.messagePostFormat),
),
SettingItem(
'ffmpeg 路径, 发送语音、视频需要同时保证ffprobe和ffmpeg在一起', `<span id="config-ffmpeg-path-text">${!isEmpty(config.ffmpeg) ? config.ffmpeg : '未指定'}</span>`,
SettingButton('选择', 'config-ffmpeg-select'),
),
SettingItem(
'', null,
SettingButton('保存', 'config-ob11-save', 'primary'),
)
]),
SettingList([
SettingItem(
'使用 Base64 编码获取文件',
'开启后,调用 /get_image、/get_record 时,获取不到 url 时添加一个 Base64 字段',
SettingSwitch('enableLocalFile2Url', config.enableLocalFile2Url),
),
SettingItem(
'调试模式',
'开启后上报信息会添加 raw 字段以附带原始信息',
SettingSwitch('debug', config.debug),
),
SettingItem(
'上报 Bot 自身发送的消息',
'上报 event 为 message_sent',
SettingSwitch('reportSelfMessage', config.reportSelfMessage),
),
SettingItem(
'自动删除收到的文件',
'在收到文件后的指定时间内删除该文件',
SettingSwitch('autoDeleteFile', config.autoDeleteFile, { 'control-display-id': 'config-auto-delete-file-second' }),
),
SettingItem(
'自动删除文件时间',
'单位为秒',
`<div class="q-input"><input class="q-input__inner" data-config-key="autoDeleteFileSecond" type="number" min="1" value="${config.autoDeleteFileSecond}" placeholder="${config.autoDeleteFileSecond}" /></div>`,
'config-auto-delete-file-second', config.autoDeleteFile
),
SettingItem(
'写入日志',
`将日志文件写入插件的数据文件夹`,
SettingSwitch('log', config.log),
),
SettingItem(
'日志文件目录',
`${window.LiteLoader.plugins['LLOneBot'].path.data}`,
SettingButton('打开', 'config-open-log-path'),
),
]),
SettingList([
SettingItem(
'GitHub和文档',
`https://github.com/LLOneBot/LLOneBot`,
SettingButton('点个Star', 'open-github'),
),
SettingItem(
'Telegram 群',
`https://t.me/+nLZEnpne-pQ1OWFl`,
SettingButton('进去逛逛', 'open-telegram'),
),
SettingItem(
'QQ 群',
`545402644`,
SettingButton('我要进去', 'open-qq-group'),
),
]),
'</div>',
].join(''), "text/html");
// 外链按钮
doc.querySelector('#open-github').addEventListener('click', () => {
window.LiteLoader.api.openExternal('https://github.com/LLOneBot/LLOneBot')
})
doc.querySelector('#open-telegram').addEventListener('click', () => {
window.LiteLoader.api.openExternal('https://t.me/+nLZEnpne-pQ1OWFl')
})
doc.querySelector('#open-qq-group').addEventListener('click', () => {
window.LiteLoader.api.openExternal('https://qm.qq.com/q/bDnHRG38aI')
})
// 生成反向地址列表
const buildHostListItem = (type: string, host: string, index: number, inputAttrs: any={}) => {
const dom = {
container: document.createElement('setting-item'),
input: document.createElement('input'),
inputContainer: document.createElement('div'),
deleteBtn: document.createElement('setting-button'),
};
dom.container.classList.add('setting-host-list-item');
dom.container.dataset.direction = 'row';
Object.assign(dom.input, inputAttrs);
dom.input.classList.add('q-input__inner');
dom.input.type = 'url';
dom.input.value = host;
dom.input.addEventListener('input', () => {
ob11Config[type][index] = dom.input.value;
});
dom.inputContainer.classList.add('q-input');
dom.inputContainer.appendChild(dom.input);
dom.deleteBtn.innerHTML = '删除';
dom.deleteBtn.dataset.type = 'secondary';
dom.deleteBtn.addEventListener('click', () => {
ob11Config[type].splice(index, 1);
initReverseHost(type);
});
dom.container.appendChild(dom.inputContainer);
dom.container.appendChild(dom.deleteBtn);
return dom.container;
};
const buildHostList = (hosts: string[], type: string, inputAttr: any={}) => {
const result: HTMLElement[] = [];
hosts.forEach((host, index) => {
result.push(buildHostListItem(type, host, index, inputAttr));
});
return result;
};
const addReverseHost = (type: string, doc: Document = document, inputAttr: any={}) => {
const hostContainerDom = doc.body.querySelector(`#config-ob11-${type}-list`);
hostContainerDom.appendChild(buildHostListItem(type, '', ob11Config[type].length, inputAttr));
ob11Config[type].push('');
};
const initReverseHost = (type: string, doc: Document = document) => {
const hostContainerDom = doc.body.querySelector(`#config-ob11-${type}-list`);
[ ...hostContainerDom.childNodes ].forEach(dom => dom.remove());
buildHostList(ob11Config[type], type).forEach(dom => {
hostContainerDom.appendChild(dom);
});
};
initReverseHost('httpHosts', doc);
initReverseHost('wsHosts', doc);
doc.querySelector('#config-ob11-httpHosts-add').addEventListener('click', () => addReverseHost('httpHosts', document, {'placeholder': '如http://127.0.0.1:5140/onebot' }));
doc.querySelector('#config-ob11-wsHosts-add').addEventListener('click', () => addReverseHost('wsHosts', document, {'placeholder': '如ws://127.0.0.1:5140/onebot' }));
doc.querySelector('#config-ffmpeg-select').addEventListener('click', () => {
window.llonebot.selectFile()
.then(path => {
if (!isEmpty(path)) {
setConfig('ffmpeg', path);
document.querySelector('#config-ffmpeg-path-text').innerHTML = path;
}
})
});
doc.querySelector('#config-open-log-path').addEventListener('click', () => {
window.LiteLoader.api.openPath(window.LiteLoader.plugins['LLOneBot'].path.data);
})
// 开关
doc.querySelectorAll('setting-switch[data-config-key]').forEach((dom: HTMLElement) => {
dom.addEventListener('click', () => {
const active = dom.getAttribute('is-active') === null;
setConfig(dom.dataset.configKey, active);
if (active) dom.setAttribute('is-active', '');
else dom.removeAttribute('is-active');
if (!isEmpty(dom.dataset.controlDisplayId)) {
const displayDom = document.querySelector(`#${dom.dataset.controlDisplayId}`);
if (active) displayDom.removeAttribute('is-hidden');
else displayDom.setAttribute('is-hidden', '');
}
});
});
// 输入框
doc.querySelectorAll('setting-item .q-input input.q-input__inner[data-config-key]').forEach((dom: HTMLInputElement) => {
dom.addEventListener('input', () => {
const Type = dom.getAttribute('type');
const configKey = dom.dataset.configKey;
const configValue = Type === 'number' ? (parseInt(dom.value) >= 1 ? parseInt(dom.value) : 1) : dom.value;
setConfig(configKey, configValue);
});
});
// 下拉框
doc.querySelectorAll('setting-select').forEach((dom: HTMLElement) => {
dom.addEventListener('selected', (e: CustomEvent) => {
const configKey = dom.dataset.configKey;
const configValue = e.detail.value;
setConfig(configKey, configValue);
});
});
// 保存按钮
doc.querySelector('#config-ob11-save').addEventListener('click', () => {
config.ob11 = ob11Config;
window.llonebot.setConfig(config);
alert('保存成功');
});
doc.body.childNodes.forEach(node => {
view.appendChild(node);
});
}
function init () {
const hash = location.hash
if (hash === '#/blank') {
}
}
if (location.hash === '#/blank') {
(window as any).navigation.addEventListener('navigatesuccess', init, { once: true })
} else {
init()
}
export {
onSettingWindowCreated
}

64
src/renderer/style.css Normal file
View File

@@ -0,0 +1,64 @@
setting-item[is-hidden],
setting-item[is-hidden] + setting-divider {
display: none !important;
}
.config-host-list {
width: 100%;
padding-left: 16px;
box-sizing: border-box;
}
.config-host-list[is-hidden],
.config-host-list[is-hidden] + setting-divider {
display: none !important;
}
setting-item .q-input {
height: 24px;
width: 100px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
box-sizing: border-box;
position: relative;
background: var(--bg_bottom_light);
border: 1px solid var(--border_dark);
}
setting-item .q-input .q-input__inner {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
box-sizing: border-box;
color: var(--text_primary);
font-family: inherit;
font-size: 12px;
height: 24px;
line-height: 24px;
width: 100%;
border: 1px solid transparent;
padding: 0px 8px;
}
setting-item .q-input input[type=number].q-input__inner::-webkit-outer-spin-button,
setting-item .q-input input[type=number].q-input__inner::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.config-host-list setting-item.setting-host-list-item .q-input {
width: 260px;
}
setting-item a {
color: var(--text-link);
}
setting-item a:hover {
color: var(--hover-link);
}
setting-item a:active,
setting-item a:visited {
color: var(--text-link);
}

1
src/version.ts Normal file
View File

@@ -0,0 +1 @@
export const version = "3.15.1"

View File

@@ -8,11 +8,14 @@
"allowJs": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
// "sourceMap": true
// "sourceMap": true
},
"include": ["src/*"],
"include": [
"src/*",
"src/**/*",
"scripts/*"
],
"exclude": [
"node_modules",
"src/common/types.ts"
]
}

View File

@@ -1,76 +0,0 @@
// import path from "path";
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const ignoreModules = [
"silk-wasm", "electron"
];
const copyModules = ["silk-wasm"]
let config = {
// target: 'node',
entry: {
// main: './src/main.ts',
// preload: './src/preload.ts'
}, // 入口文件路径
target: "node",
output: { // 输出文件配置
path: path.resolve(__dirname, 'dist'), // 输出目录路径
filename: '[name].js', // 输出文件名
// libraryTarget: "commonjs2",
// chunkFormat: "commonjs",
},
externals: ignoreModules,
experiments: {
// outputModule: true
// asyncWebAssembly: true
},
resolve: {
extensions: ['.js', '.ts']
},
module: { // 模块配置
rules: [ // 模块规则
{
test: /\.js$/, // 匹配.js文件
exclude: /node_modules/, // 排除node_modules目录
use: { // 使用的loader
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
}
}
},
{
test: /\.ts$/, // 匹配.ts文件
// exclude: /node_modules/, // 排除node_modules目录
use: { // 使用的loader
loader: 'ts-loader',
options: {
// configFile: 'src/tsconfig.json'
}
}
}]
},
optimization: {
minimize: false,
minimizer: [
new TerserPlugin({
extractComments: false,
}),
],
},
plugins: [
new CopyPlugin({
patterns: copyModules.map(m=>{
m = `node_modules/${m}`
return {
from: m,
to: m
}
})
}),
], // devtool: 'source-map',
}
module.exports = config

View File

@@ -1,11 +0,0 @@
const baseConfig = require('./webpack.base.config.js')
baseConfig.target = 'electron-main'
baseConfig.entry = {
main: './src/main/main.ts',
// preload: './src/preload.ts',
}
baseConfig.output.libraryTarget = 'commonjs2'
baseConfig.output.chunkFormat = 'commonjs'
module.exports = baseConfig

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