Compare commits

...

232 Commits

Author SHA1 Message Date
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
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
5ef221608c chore: GitHub zip action 2024-02-21 06:06:08 +08:00
linyuchen
6b2a45e087 chore: GitHub zip action 2024-02-21 05:55:49 +08:00
linyuchen
03d4a68c33 feat: post image http url 2024-02-21 05:48:37 +08:00
linyuchen
0f84e82d74 fix: reverse ws support koishi 2024-02-21 05:22:07 +08:00
linyuchen
0f4d8f3fe2 fix: send temp msg
fix: multi forward msg
2024-02-21 05:11:13 +08:00
linyuchen
be7b68ec4e fix: old http port config 2024-02-21 03:47:14 +08:00
linyuchen
103e0b43f8 fix: reverse ws restart 2024-02-21 03:30:04 +08:00
linyuchen
f092fad2f4 fix: get method params parse 2024-02-20 23:08:42 +08:00
linyuchen
c4e54fa259 feat: auto encode silk 2024-02-20 22:39:24 +08:00
linyuchen
0e4de038ca merge v3.4.0 2024-02-20 16:12:46 +08:00
linyuchen
ed48a76c33 Merge branch 'v3.4.0' into dev
# Conflicts:
#	src/common/utils.ts
#	src/global.d.ts
#	src/main/ipcsend.ts
#	src/main/main.ts
#	src/ntqqapi/hook.ts
#	src/onebot11/action/SendMsg.ts
#	src/onebot11/action/TestForwdMsg.ts
#	src/onebot11/action/types.ts
#	src/onebot11/server.ts
#	src/preload.ts
2024-02-20 16:09:15 +08:00
linyuchen
0545bcfdab refactor: function getConfig add cache param 2024-02-20 15:51:55 +08:00
linyuchen
a4301f0b55 Merge remote-tracking branch 'origin/v3.4.0' into v3.4.0
# Conflicts:
#	src/common/config.ts
2024-02-20 15:46:41 +08:00
linyuchen
e34e8c2768 Merge pull request #56 from disymayufei/main
临时修复配置文件的问题
2024-02-20 15:41:13 +08:00
linyuchen
dce65a295f Merge remote-tracking branch 'origin/v3.4.0' into v3.4.0
# Conflicts:
#	src/common/config.ts
2024-02-20 15:38:48 +08:00
linyuchen
f9b97543d9 refactor: default config 2024-02-20 03:29:28 +08:00
linyuchen
c1dd309b21 refactor: base server & setting ui 2024-02-20 03:25:16 +08:00
Disy
20399dc369 fix: get config return null ref 2024-02-20 00:10:46 +08:00
linyuchen
4e4ccf4935 Merge pull request #54 from disymayufei/main
增加配置文件的内存缓存机制
2024-02-19 23:17:11 +08:00
Disy
6e97044437 feat: cache config 2024-02-19 23:05:44 +08:00
Disy
5cf9a6e942 Merge pull request #1 from disymayufei/dev-1
合并开发分支
2024-02-19 22:56:26 +08:00
linyuchen
5094ba724a Merge pull request #53 from disymayufei/dev-1
补充支持基本的正向和反向Websocket
2024-02-19 21:54:27 +08:00
linyuchen
1938eef746 fix: send multi forward msg 2024-02-19 21:48:50 +08:00
Disy
82e3ca113d chore: change app version 2024-02-19 18:31:10 +08:00
Disy
acb1ec3871 feat: Asynchronous connect reverse websocket 2024-02-19 13:54:09 +08:00
Disy
9b0f2d0983 chore: Conflict resolution 2024-02-19 13:31:57 +08:00
Disy
d1eef6759c Merge branch 'linyuchen:main' into dev-1 2024-02-18 10:05:27 +08:00
Disy
6219f4ec95 Merge branch 'dev' into dev-1 2024-02-17 23:50:05 +08:00
linyuchen
9b8b9a203c fix: group member_count & member_max_count 2024-02-17 23:38:18 +08:00
linyuchen
e5edfd78eb docs: update readme 2024-02-17 20:19:09 +08:00
linyuchen
ee4206c33d docs: update readme 2024-02-17 20:10:08 +08:00
linyuchen
42d6f1528a Merge branch 'dev'
# Conflicts:
#	manifest.json
#	src/common/data.ts
2024-02-17 20:07:13 +08:00
linyuchen
1a1d673c8c feat: face msg
feat: recall notice
2024-02-17 20:06:17 +08:00
linyuchen
06ad92b846 ver: 3.2.2 2024-02-17 01:44:21 +08:00
linyuchen
df5968ccc1 Merge pull request #47 from YuChuXi/patch-1
fix get_group_info
2024-02-17 01:43:12 +08:00
linyuchen
ba387b40ca 暂存 2024-02-17 01:42:14 +08:00
YuChuXi
e554d805b5 修东西
fix: get_group_info和get_group_list都返回群列表
2024-02-17 01:39:06 +08:00
linyuchen
d54111ce94 fix: ws url token parse 2024-02-16 22:48:43 +08:00
Disy
018ec07082 feat: support reverse websocket 2024-02-16 22:34:12 +08:00
linyuchen
4f9682289c feat: api /get_version_info
feat: api /can_send_image
feat: api /can_send_record
feat: ws heart & lifecycle
2024-02-16 21:32:37 +08:00
linyuchen
963aad1510 fix: some id(int and string) compatibility 2024-02-16 15:54:07 +08:00
linyuchen
0eeba1d29e fix: remove ws welcome 2024-02-16 10:34:56 +08:00
linyuchen
97200f427d docs: update readme 2024-02-16 00:52:19 +08:00
linyuchen
ef4443d080 feat: Websocket Server
feat: change port not need restart
2024-02-16 00:47:04 +08:00
Disy
f02b0bdcad Merge branch 'main' of https://github.com/disymayufei/LiteLoaderQQNT-OneBotApi 2024-02-15 22:27:01 +08:00
Disy
72b1c906f7 fix: Notification event not effective 2024-02-15 22:26:53 +08:00
Disy
53d30ed7ea Merge branch 'linyuchen:main' into main 2024-02-15 21:48:05 +08:00
Disy
8f48d1d4ca feat: 预添加群成员变动事件 2024-02-15 21:47:16 +08:00
linyuchen
a7d75f84cb fix: send voice msg 2024-02-15 18:43:29 +08:00
Disy
c875cfda15 feat: add websocket support 2024-02-14 22:11:07 +08:00
linyuchen
9bb69058c2 fix: group msg subtype: normal 2024-02-14 12:58:29 +08:00
linyuchen
89971dd2e4 docs: update README 2024-02-14 01:38:46 +08:00
linyuchen
aea67db27c fix: report self sent message_id 2024-02-14 01:35:48 +08:00
linyuchen
c4b45f8298 ver: 3.0.5 2024-02-14 01:02:48 +08:00
linyuchen
1a77abfc62 fix: 发送回复消息多了个@符号 2024-02-14 01:01:54 +08:00
linyuchen
eb32ecb79b perf: 去掉多余日志 2024-02-14 00:37:53 +08:00
linyuchen
ccf91f4a94 fix: 消息重复上报 2024-02-14 00:35:36 +08:00
linyuchen
282b2a0da0 fix: message_id过长导致koishi对接失败
perf: 初始化卡顿优化
2024-02-13 21:17:16 +08:00
linyuchen
b28b812396 fix: file://中有中文无法正确解析 2024-02-13 19:56:02 +08:00
linyuchen
1936671cb3 fix: self nickname
fix: @member msg report
fix: send file:// on Windows
ver: 3.0.2
2024-02-13 18:37:01 +08:00
linyuchen
6a8d67a8ae fix: self nickname
fix: auto download receive image
fix: @member msg report
ver: 3.0.1
2024-02-13 13:12:16 +08:00
linyuchen
64c4798117 Merge branch 'v3'
# Conflicts:
#	.github/ISSUE_TEMPLATE/bug_report.md
2024-02-12 23:40:44 +08:00
linyuchen
daca59d27d chore: update issue template 2024-02-12 22:44:42 +08:00
linyuchen
b29134f40b chore: update issue template 2024-02-12 22:15:48 +08:00
linyuchen
2727795b20 docs: update readme 2024-02-12 22:14:38 +08:00
linyuchen
edcf3f2592 feat: delete msg 2024-02-12 22:12:25 +08:00
linyuchen
0a8e25c121 refactor: Action 2024-02-12 21:54:58 +08:00
linyuchen
2d5d1c69c1 refactor: remove unused code 2024-02-12 14:47:58 +08:00
linyuchen
1c92f37188 Merge pull request #37 from PurpleNoon/action-layering 2024-02-12 09:16:55 +08:00
zhangzemeng
c510f4acdc feat: 抽离 action 公共逻辑 2024-02-12 09:13:06 +08:00
zhangzemeng
43fbcb819a feat: 从 http server 中分离 action 处理逻辑 2024-02-12 00:03:04 +08:00
linyuchen
8d2353a524 refactor: pre-release 2024-02-11 19:57:20 +08:00
linyuchen
d08601505b refactor 2024-02-11 02:58:34 +08:00
linyuchen
f9c376f6c5 chore: GitHub issue template 2024-02-09 22:19:20 +08:00
linyuchen
5e64df0eaa save 2024-02-08 11:32:17 +08:00
linyuchen
2c55d84b9f refactor: hook some api 2024-02-08 01:45:56 +08:00
linyuchen
a98ce843ef chore:Optimized GitHub issue template 2024-02-07 18:13:36 +08:00
linyuchen
3c4db1d9d9 Merge pull request #29 from PurpleNoon/preload-safe-fix
fix: preload.ts sendSendMsgResult 安全问题
2024-02-07 18:07:48 +08:00
linyuchen
9698c6d81c chore: GitHub issue template 2024-02-07 15:41:57 +08:00
zhangzemeng
1b04cd4843 fix: preload.ts sendSendMsgResult 安全问题 2024-02-07 10:44:42 +08:00
linyuchen
dbd72c952b Merge pull request #28 from linyuchen/main
main branch merge to dev
2024-02-06 21:53:07 +08:00
linyuchen
a4e97bfea5 docs: update 2024-02-06 18:38:37 +08:00
linyuchen
9411993d8a fix: report self 2024-02-06 18:29:03 +08:00
linyuchen
e545d8d1cd fix: report self 2024-02-06 15:17:01 +08:00
linyuchen
7fd37fe137 重构接受消息hook 2024-02-06 13:47:11 +08:00
linyuchen
dcaa07dc1c ver: 2.4.1 2024-02-04 22:19:22 +08:00
linyuchen
5194c279d8 fix: 修复上传file://格式的文件时会误删原文件 2024-02-04 22:00:23 +08:00
linyuchen
b830cfbfa0 style: syntax error 2024-02-04 10:34:57 +08:00
linyuchen
ce25c9752f ver: 2.4.0 2024-02-04 10:29:50 +08:00
linyuchen
5e00aee176 Merge branch 'dev'
# Conflicts:
#	src/renderer.ts
2024-02-04 10:29:10 +08:00
linyuchen
a25c1b24fc feat: 新增开关控制是否上报自己发送的消息 2024-02-04 10:26:20 +08:00
linyuchen
afed1b8575 Merge pull request #22 from YuChuXi/dev_report
添加上报自身消息设置项
2024-02-04 10:05:25 +08:00
linyuchen
0fe58c1965 Merge branch 'dev' into dev_report 2024-02-04 10:05:02 +08:00
linyuchen
b3cae5f1c6 feat: 新增开关控制是否上报自己发送的消息 2024-02-04 10:00:13 +08:00
YuChuXi
d09fc78747 改了文本 2024-02-04 03:04:02 +08:00
YuChuXi
19d7ecd4f0 新增设置项:上报自身消息 2024-02-04 02:54:56 +08:00
linyuchen
070eee6c1c docs: README update 2024-02-03 19:31:22 +08:00
linyuchen
fe5e0ea4e0 Merge remote-tracking branch 'origin/main' 2024-02-03 19:29:08 +08:00
linyuchen
7ba7af13a8 ver: 2.3.0 2024-02-03 19:27:31 +08:00
linyuchen
fae61fbbde fix: 夜间模式输入框颜色
feat: log开关
2024-02-03 19:25:38 +08:00
linyuchen
a249139fe0 Update README.md 2024-02-03 16:08:57 +08:00
linyuchen
ebc3968c4e fix: 不支持的消息不再上报 2024-02-03 15:46:23 +08:00
linyuchen
3d03aec976 fix: 偶尔出现不能上报 2024-02-02 21:54:31 +08:00
linyuchen
083d3ddf67 docs: update 2024-01-31 19:29:25 +08:00
108 changed files with 10312 additions and 4819 deletions

20
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,20 @@
---
name: Bug反馈
about: 报个Bug
title: ''
labels: bug
assignees: ''
---
QQ版本
LLOneBot版本
调用LLOneBot的方式或者应用端(如postman直接调用或NoneBot2、Koishi)
BUG描述
复现步骤:
LLOneBot日志:

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
@@ -26,7 +28,8 @@ jobs:
run: |
sudo apt install zip -y
cp manifest.json ./dist/manifest.json
zip LLOneBot.zip ./dist/* -j
cd ./dist/
zip -r ../LLOneBot.zip ./*
- name: publish
uses: ncipollo/release-action@v1

5
.gitignore vendored
View File

@@ -1,3 +1,6 @@
node_modules/
package-lock.json
dist/
.idea/
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.

180
README.md
View File

@@ -1,25 +1,58 @@
# LLOneBot API
LiteLoaderQQNT的OneBot11协议插件
将NTQQLiteLoaderAPI封装成OneBot11/12标准的API, V12没有完整测试
TG群<https://t.me/+nLZEnpne-pQ1OWFl>
*注意:本文档对应的是 LiteLoader 1.0.0及以上版本如果你使用的是旧版本请切换到本项目v1分支查看文档*
*V3之后不再需要LLAPI*
## 安装方法
### 通用手动安装方法
1.安装[LiteLoaderQQNT](https://liteloaderqqnt.github.io/guide/install.html)
2.安装修改后的[LiteLoaderQQNT-Plugin-LLAPI](https://github.com/linyuchen/LiteLoaderQQNT-Plugin-LLAPI/releases),原版的功能有缺陷
2.安装本项目插件[LLOneBot](https://github.com/linyuchen/LiteLoaderQQNT-OneBotApi/releases/), 注意本插件2.0以下的版本不支持LiteLoader 1.0.0及以上版本
3.安装本项目插件[OneBotApi](https://github.com/linyuchen/LiteLoaderQQNT-OneBotApi/releases/), 注意本插件2.0以下的版本不支持LiteLoader 1.0.0及以上版本
*关于插件的安装方法: 上述的两个插件都没有上架NTQQLiteLoader插件市场需要自己下载复制到插件目录*
*关于插件的安装方法: 下载后解压复制到插件目录*
*插件目录:`LiteLoaderQQNT/plugins`*
## 支持的API
安装后的目录结构如下
```
├── plugins
│ ├── LLOneBot
│ │ └── main/
│ │ └── preload/
│ │ └── renderer/
│ │ └── manifest.json
│ │ └── node_modules/...
```
目前只支持http协议POST方法不支持websocket事件上报也是http协议
### Linux 容器化快速安装
执行以下任意脚本按照提示设置NoVnc密码即可运行脚本问题与异常参考 [llonebot-docker](https://github.com/MliKiowa/llonebot-docker) 项目。
```bash
curl https://cdn.jsdelivr.net/gh/LLOneBot/llonebot-docker/fastboot.sh -o fastboot.sh & chmod +x fastboot.sh & sudo sh fastboot.sh
```
```bash
wget -O fastboot.sh https://cdn.jsdelivr.net/gh/LLOneBot/llonebot-docker/fastboot.sh & chmod +x fastboot.sh & sudo sh fastboot.sh
```
### 使用termux安装
具体安装过程与教程,参考 [llonebot-termux](https://github.com/LLOneBot/llonebot-termux) 项目。
## 支持的功能
目前支持的协议
- [x] http调用api
- [x] http事件上报不支持快捷回复等快捷操作
- [x] 正向websocket
- [x] 反向websocket
主要功能:
- [x] 发送好友消息
@@ -28,39 +61,50 @@
- [x] 获取群列表
- [x] 获取群成员列表
- [x] 撤回消息
- [x] 处理添加好友请求
- [x] 处理加群请求
- [x] 退群
- [x] 上报好友消息
- [x] 上报添加好友请求
- [x] 上报群消息
- [x] 上报好友、群消息撤回
- [x] 上报加群请求
- [x] 上报群员人数变动(尚不支持识别群员人数变动原因)
- [x] 设置群管理员
- [x] 群禁言/全体禁言
- [x] 群踢人
- [x] 群改群成员名片
- [x] 修改群名
消息格式支持:
- [x] cq码
- [x] 文字
- [x] 表情
- [x] 图片
- [x] 引用消息
- [x] @群成员
- [x] 语音
- [x] 语音(支持mp3、wav等多种音频格式直接发送)
- [x] json消息(只上报)
- [ ] 红包
- [ ] 转发消息记录
- [ ] 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_member_list
- [x] get_group_member_info
- [x] get_friend_list
**自己发送成功的消息也会上报可以用于获取需要撤回消息的id**
- [x] 转发消息记录(目前只能发不能收)
- [x] 视频(上报时暂时只有个空的file)
- [x] 文件(上报时暂时只有个空的file), type为file, data为{file: uri}, 发送时uri支持http://, file://, base64://
```
{
"type": "file",
"data": {
"file": "file:///D:/1.txt",
"name": "自定义显示的文件名" // 此字段不是必须的
}
}
```
- [ ] 发送音乐卡片
- [ ] 红包(没有计划支持)
- [ ] xml (没有计划支持)
## 示例
![](doc/image/example.jpg)
*暂时不支持`"message": "hello"`这种message为字符串的形式*
## 一些坑
<details>
@@ -73,7 +117,8 @@
<details>
<summary>调用接口报404</summary>
<br/>
目前没有支持全部的onebot规范接口请检查是否调用了不支持的接口并且所有接口都只支持POST方法调用GET方法会报404
目前没有支持全部的onebot规范接口请检查是否调用了不支持的接口
-
</details>
<br/>
@@ -84,20 +129,6 @@
</details>
<br/>
<details>
<summary>不支持cq码</summary>
<br/>
cq码已经过时了没有支持的打算(主要是我不用这玩意儿,加上我懒)
</details>
<br/>
<details>
<summary>onebot 12对接不了</summary>
<br/>
onebot 12只写了部分兼容没有完整测试不保证能用慎用
</details>
<br/>
<details>
<summary>QQ变得很卡</summary>
<br/>
@@ -105,26 +136,57 @@
</details>
<br/>
<details>
<summary>撤回消息无效</summary>
<br/>
如果接口调用的传的`message`是number类型会导致精度丢失使用string类型可解决详情见<a href="https://github.com/linyuchen/LiteLoaderQQNT-OneBotApi/issues/17">issue#17</a>
</details>
<br/>
## 支持的onebot v11 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] set_friend_add_request
- [x] get_msg
- [x] send_like
- [x] set_group_add_request
- [x] set_group_leave
- [x] set_group_kick
- [x] set_group_ban
- [x] set_group_whole_ban
- [x] set_group_kick
- [x] set_group_admin
- [x] set_group_card
- [x] set_group_name
- [x] get_version_info
- [x] get_status
- [x] can_send_image
- [x] can_send_record
- [x] get_image
- [x] get_record
<details>
<summary>如何查看日志</summary>
<br/>
LiteLoaderQQNT/data/LLOneBot/*.log
</details>
<br/>
### 支持的go-cqhtp api:
- [x] send_private_forward_msg
- [x] send_group_forward_msg
- [x] get_stranger_info
## TODO
- [x] 接口返回更详细的错误信息,目前消息发不出去也会返回发送成功(这河里吗)
- [ ] 转发消息记录
- [ ] 支持websocket等个有缘人提PR实现
- [ ] 重构摆脱LLAPI目前调用LLAPI只能在renderer进程调用需重构成在main进程调用
- [x] 重构摆脱LLAPI目前调用LLAPI只能在renderer进程调用需重构成在main进程调用
- [x] 支持正、反向websocket感谢@disymayufei的PR
- [x] 转发消息记录
- [x] 好友点赞api
- [x] 群管理功能,禁言、踢人,改群名片等
- [x] 视频消息
- [x] 文件消息
- [ ] 音乐卡片
- [ ] 无头模式
## onebot11文档
<https://11.onebot.dev/>
## 鸣谢
* [LiteLoaderQQNT](https://liteloaderqqnt.github.io/guide/install.html)
* [LLAPI](https://github.com/Night-stars-1/LiteLoaderQQNT-Plugin-LLAPI)
* chronocat
* [koishi-plugin-adapter-onebot](https://github.com/koishijs/koishi-plugin-adapter-onebot)

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

@@ -0,0 +1,69 @@
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' }] })]
},
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;

View File

@@ -1,31 +1,33 @@
{
"manifest_version": 4,
"type": "extension",
"name": "LLOneBot",
"slug": "LLOneBot",
"description": "LiteLoaderQQNT的OneBotApi",
"version": "2.2.0",
"thumbnail": "./icon.png",
"authors": [{
"name": "linyuchen",
"link": "https://github.com/linyuchen"
}],
"repository": {
"manifest_version": 4,
"type": "extension",
"name": "LLOneBot v3.13.2",
"slug": "LLOneBot",
"description": "LiteLoaderQQNT的OneBotApi",
"version": "3.13.2",
"thumbnail": "./icon.png",
"authors": [
{
"name": "linyuchen",
"link": "https://github.com/linyuchen"
}
],
"repository": {
"repo": "linyuchen/LiteLoaderQQNT-OneBotApi",
"branch": "main",
"release": {
"tag": "latest",
"name": "LLOneBot.zip"
"tag": "latest",
"name": "LLOneBot.zip"
}
},
"platform": [
"win32",
"linux",
"darwin"
],
"injects": {
"renderer": "./renderer.js",
"main": "./main.js",
"preload": "./preload.js"
}
}
},
"platform": [
"win32",
"linux",
"darwin"
],
"injects": {
"renderer": "./renderer/index.js",
"main": "./main/main.cjs",
"preload": "./preload/preload.cjs"
}
}

7384
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +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",
"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 dist/* ~/Library/Containers/com.tencent.qq/Data/Documents/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 /S /Y dist\\* %USERPROFILE%\\documents\\LiteLoaderQQNT\\plugins\\LLOneBot\\\""
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"json-bigint": "^1.0.0",
"uuid": "^9.0.1"
"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",
"babel-loader": "^9.1.3",
"electron": "^27.0.2",
"ts-loader": "^9.5.0",
"typescript": "^5.2.2",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4"
"@types/fluent-ffmpeg": "^2.1.24",
"@types/node": "^20.11.24",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"@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)
}

View File

@@ -1,14 +0,0 @@
export const CHANNEL_SEND_MSG = "llonebot_send_msg"
export const CHANNEL_RECALL_MSG = "llonebot_recall_msg"
export const CHANNEL_GET_CONFIG = "llonebot_get_config"
export const CHANNEL_SET_CONFIG = "llonebot_set_config"
export const CHANNEL_START_HTTP_SERVER = "llonebot_start_http_server"
export const CHANNEL_UPDATE_GROUPS = "llonebot_update_groups"
export const CHANNEL_UPDATE_FRIENDS = "llonebot_update_friends"
export const CHANNEL_LOG = "llonebot_log"
export const CHANNEL_POST_ONEBOT_DATA = "llonebot_post_onebot_data"
export const CHANNEL_SET_SELF_INFO= "llonebot_set_self_info"
export const CHANNEL_DOWNLOAD_FILE= "llonebot_download_file"
export const CHANNEL_DELETE_FILE= "llonebot_delete_file"
export const CHANNEL_GET_RUNNING_STATUS= "llonebot_get_running_status"
export const CHANNEL_FILE2BASE64= "llonebot_file2base64"

5
src/common/channels.ts Normal file
View File

@@ -0,0 +1,5 @@
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'

86
src/common/config.ts Normal file
View File

@@ -0,0 +1,86 @@
import fs from "fs";
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;
constructor(configPath: string) {
this.configPath = configPath;
}
getConfig(cache = true) {
if (this.config && cache) {
return this.config;
}
return this.reloadConfig();
}
reloadConfig(): Config {
let ob11Default: OB11Config = {
httpPort: 3000,
httpHosts: [],
wsPort: 3001,
wsHosts: [],
enableHttp: true,
enableHttpPost: true,
enableWs: true,
enableWsReverse: false,
messagePostFormat: "array",
}
let defaultConfig: Config = {
ob11: ob11Default,
heartInterval: 60000,
token: "",
enableLocalFile2Url: false,
debug: false,
log: false,
reportSelfMessage: false,
autoDeleteFile: false,
autoDeleteFileSecond: 60,
};
if (!fs.existsSync(this.configPath)) {
this.config = defaultConfig;
return this.config;
} else {
const data = fs.readFileSync(this.configPath, "utf-8");
let jsonData: Config = defaultConfig;
try {
jsonData = JSON.parse(data)
} catch (e) {
this.config = defaultConfig;
return this.config;
}
mergeNewProperties(defaultConfig, jsonData);
this.checkOldConfig(jsonData.ob11, jsonData, "httpPort", "http");
this.checkOldConfig(jsonData.ob11, jsonData, "httpHosts", "hosts");
this.checkOldConfig(jsonData.ob11, jsonData, "wsPort", "wsPort");
// console.log("get config", jsonData);
this.config = jsonData;
return this.config;
}
}
setConfig(config: Config) {
this.config = config;
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), "utf-8")
}
private checkOldConfig(currentConfig: Config | OB11Config,
oldConfig: Config | OB11Config,
currentKey: string, oldKey: string) {
// 迁移旧的配置到新配置,避免用户重新填写配置
const oldValue = oldConfig[oldKey];
if (oldValue) {
currentConfig[currentKey] = oldValue;
delete oldConfig[oldKey];
}
}
}

110
src/common/data.ts Normal file
View File

@@ -0,0 +1,110 @@
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 {log} from "./utils";
export const selfInfo: SelfInfo = {
uid: '',
uin: '',
nick: '',
online: true
}
export let groups: Group[] = []
export let friends: Friend[] = []
export let groupNotifies: Map<string, GroupNotify> = new Map<string, GroupNotify>()
export let friendRequests: Map<number, FriendRequest> = new Map<number, FriendRequest>()
export const llonebotError: LLOneBotError = {
ffmpegError: '',
otherError: ''
}
export const fileCache = new Map<string, FileCache>()
export async function getFriend(qq: string, uid: string = ""): Promise<Friend | undefined> {
let filterKey = uid ? "uid" : "uin"
let filterValue = uid ? uid : qq
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.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 | number, memberQQ: string | number, memberUid: string = null) {
groupQQ = groupQQ.toString()
if (memberQQ) {
memberQQ = memberQQ.toString()
}
const group = await getGroup(groupQQ)
if (group) {
const filterKey = memberQQ ? "uin" : "uid"
const filterValue = memberQQ ? memberQQ : memberUid
let filterFunc: (member: GroupMember) => boolean = member => member[filterKey] === filterValue
let member = group.members?.find(filterFunc)
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 async function refreshGroupMembers(groupQQ: string) {
const group = groups.find(group => group.groupCode === groupQQ)
if (group) {
group.members = await NTQQApi.getGroupMembers(groupQQ)
}
}
export const uidMaps: Record<string, string> = {} // 一串加密的字符串(uid) -> qq号
export function getUidByUin(uin: string) {
for (const key in uidMaps) {
if (uidMaps[key] === uin) {
return key
}
}
}
export let tempGroupCodeMap: Record<string, string> = {} // peerUid => 群号

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

@@ -0,0 +1,161 @@
// import {DATA_DIR} from "./utils";
import {Level} from "level";
import {RawMessage} from "../ntqqapi/types";
import {DATA_DIR, log} from "./utils";
import {selfInfo} from "./data";
class DBUtil {
private readonly DB_KEY_PREFIX_MSG_ID = "msg_id_";
private readonly DB_KEY_PREFIX_MSG_SHORT_ID = "msg_short_id_";
private readonly DB_KEY_PREFIX_MSG_SEQ_ID = "msg_seq_id_";
private db: Level;
private cache: Record<string, RawMessage> = {} // <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
* */
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()
setInterval(() => {
this.cache = {}
}, 1000 * 60 * 10)
}
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] = this.cache[seqIdKey] = msg
}
async getMsgByShortId(shortMsgId: number): Promise<RawMessage> {
const shortMsgIdKey = this.DB_KEY_PREFIX_MSG_SHORT_ID + shortMsgId;
if (this.cache[shortMsgIdKey]) {
return this.cache[shortMsgIdKey]
}
const longId = await this.db.get(shortMsgIdKey);
const msg = await this.getMsgByLongId(longId)
this.addCache(msg)
return msg
}
async getMsgByLongId(longId: string): Promise<RawMessage> {
const longIdKey = this.DB_KEY_PREFIX_MSG_ID + longId;
if (this.cache[longIdKey]) {
return this.cache[longIdKey]
}
const data = await this.db.get(longIdKey)
const msg = JSON.parse(data)
this.addCache(msg)
return msg
}
async getMsgBySeqId(seqId: string): Promise<RawMessage> {
const seqIdKey = this.DB_KEY_PREFIX_MSG_SEQ_ID + seqId;
if (this.cache[seqIdKey]) {
return this.cache[seqIdKey]
}
const longId = await this.db.get(seqIdKey);
const msg = await this.getMsgByLongId(longId)
this.addCache(msg)
return msg
}
async addMsg(msg: RawMessage) {
// 有则更新,无则添加
const longIdKey = this.DB_KEY_PREFIX_MSG_ID + msg.msgId
let existMsg = this.cache[longIdKey]
if (!existMsg) {
try {
existMsg = await this.getMsgByLongId(msg.msgId)
} catch (e) {
}
}
if (existMsg) {
this.updateMsg(msg).then()
return existMsg.msgShortId
}
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;
try {
this.db.put(shortIdKey, msg.msgId).then();
this.db.put(longIdKey, JSON.stringify(msg)).then();
this.db.put(seqIdKey, msg.msgId).then();
log(`消息入库 ${seqIdKey}: ${msg.msgId}, ${shortMsgId}: ${msg.msgId}`);
} catch (e) {
log("addMsg db error", e.stack.toString());
}
this.cache[seqIdKey] = this.cache[shortIdKey] = this.cache[longIdKey] = msg;
return shortMsgId
}
async updateMsg(msg: RawMessage) {
const longIdKey = this.DB_KEY_PREFIX_MSG_ID + msg.msgId
let existMsg = this.cache[longIdKey]
if (!existMsg) {
try {
existMsg = await this.getMsgByLongId(msg.msgId)
} catch (e) {
return
}
}
Object.assign(existMsg, msg)
this.db.put(longIdKey, JSON.stringify(existMsg)).then();
const shortIdKey = this.DB_KEY_PREFIX_MSG_SHORT_ID + existMsg.msgShortId;
const seqIdKey = this.DB_KEY_PREFIX_MSG_SEQ_ID + msg.msgSeq;
this.db.put(shortIdKey, msg.msgId).then();
this.db.put(seqIdKey, msg.msgId).then();
}
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++;
await this.db.put(key, this.currentShortId.toString());
return this.currentShortId;
}
}
export const dbUtil = new DBUtil();

92
src/common/server/http.ts Normal file
View File

@@ -0,0 +1,92 @@
import express, {Express, json, Request, Response} from "express";
import {getConfigUtil, log} from "../utils";
import http from "http";
type RegisterHandler = (res: Response, payload: any) => Promise<any>
export abstract class HttpServerBase {
name: string = "LLOneBot";
private readonly expressAPP: Express;
private server: http.Server = null;
constructor() {
this.expressAPP = express();
this.expressAPP.use(express.urlencoded({extended: true, limit: "500mb"}));
this.expressAPP.use(json({limit: "500mb"}));
}
authorize(req: Request, res: Response, next: () => void) {
let serverToken = getConfigUtil().getConfig().token;
let clientToken = ""
const authHeader = req.get("authorization")
if (authHeader) {
clientToken = authHeader.split("Bearer ").pop()
log("receive http header token", clientToken)
} else if (req.query.access_token) {
if (Array.isArray(req.query.access_token)) {
clientToken = req.query.access_token[0].toString();
} else {
clientToken = req.query.access_token.toString();
}
log("receive http url token", clientToken)
}
if (serverToken && clientToken != serverToken) {
return res.status(403).send(JSON.stringify({message: 'token verify failed!'}));
}
next();
};
start(port: number) {
this.expressAPP.get('/', (req: Request, res: Response) => {
res.send(`${this.name}已启动`);
})
this.listen(port);
}
stop() {
if (this.server) {
this.server.close()
this.server = null;
}
}
restart(port: number) {
this.stop()
this.start(port)
}
abstract handleFailed(res: Response, payload: any, err: any): void
registerRouter(method: "post" | "get" | string, url: string, handler: RegisterHandler) {
if (!url.startsWith("/")) {
url = "/" + url
}
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") {
payload = req.query
}
log("收到http请求", url, payload);
try {
res.send(await handler(res, payload))
} catch (e) {
this.handleFailed(res, payload, e.stack.toString())
}
});
}
protected listen(port: number) {
this.server = this.expressAPP.listen(port, "0.0.0.0", () => {
const info = `${this.name} started 0.0.0.0:${port}`
console.log(info);
log(info);
});
}
}

View File

@@ -0,0 +1,96 @@
import {WebSocket, WebSocketServer} from "ws";
import {getConfigUtil, log} from "../utils";
import urlParse from "url";
import {IncomingMessage} from "node:http";
class WebsocketClientBase {
private wsClient: WebSocket
constructor() {
}
send(msg: string) {
if (this.wsClient && this.wsClient.readyState == WebSocket.OPEN) {
this.wsClient.send(msg);
}
}
onMessage(msg: string) {
}
}
export class WebsocketServerBase {
private ws: WebSocketServer = null;
constructor() {
console.log(`llonebot websocket service started`)
}
start(port: number) {
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) => {
this.onMessage(wsClient, url, msg.toString())
})
})
}
stop() {
this.ws.close((err) => {
log("ws server close failed!", err)
});
this.ws = null;
}
restart(port: number) {
this.stop();
this.start(port);
}
authorize(wsClient: WebSocket, req) {
let token = getConfigUtil().getConfig().token;
const url = req.url.split("?").shift();
log("ws connect", url)
let clientToken: string = ""
const authHeader = req.headers['authorization'];
if (authHeader) {
clientToken = authHeader.split("Bearer ").pop()
log("receive ws header token", clientToken);
} else {
const parsedUrl = urlParse.parse(req.url, true);
const urlToken = parsedUrl.query.access_token;
if (urlToken) {
if (Array.isArray(urlToken)) {
clientToken = urlToken[0]
} else {
clientToken = urlToken
}
log("receive ws url token", clientToken);
}
}
if (token && clientToken != token) {
this.authorizeFailed(wsClient)
return wsClient.close()
}
}
authorizeFailed(wsClient: WebSocket) {
}
onConnect(wsClient: WebSocket, url: string, req: IncomingMessage) {
}
onMessage(wsClient: WebSocket, url: string, msg: string) {
}
sendHeart() {
}
}

View File

@@ -1,195 +1,37 @@
export enum AtType {
notAt = 0,
atAll = 1,
atUser = 2
}
export enum ChatType {
friend = 1,
group = 2,
temp = 100
}
export interface GroupMemberInfo {
avatarPath: string;
cardName: string;
cardType: number;
isDelete: boolean;
nick: string;
qid: string;
remark: string;
role: number; // 群主:4, 管理员:3群员:2
shutUpTime: number; // 禁言时间,单位是什么暂时不清楚
uid: string; // 加密的字符串
uin: string; // QQ号
}
export const OnebotGroupMemberRole = {
4: 'owner',
3: 'admin',
2: 'member'
}
export interface SelfInfo {
user_id: string;
nickname: string;
}
export interface User {
avatarUrl?: string;
bio?: string; // 签名
nickName: string;
uid?: string; // 加密的字符串
uin: string; // QQ号
}
export interface Group {
uid: string; // 群号
name: string;
members?: GroupMemberInfo[];
}
export interface Peer {
chatType: ChatType
name: string
uid: string // qq号
}
export interface PttElement {
canConvert2Text: boolean
duration: number // 秒数
fileBizId: null
fileId: number // 0
fileName: string // "e4d09c784d5a2abcb2f9980bdc7acfe6.amr"
filePath: string // "/Users//Library/Containers/com.tencent.qq/Data/Library/Application Support/QQ/nt_qq_a6b15c9820595d25a56c1633ce19ad40/nt_data/Ptt/2023-11/Ori/e4d09c784d5a2abcb2f9980bdc7acfe6.amr"
fileSize: string // "4261"
fileSubId: string // "0"
fileUuid: string // "90j3z7rmRphDPrdVgP9udFBaYar#oK0TWZIV"
formatType: string // 1
invalidState: number // 0
md5HexStr: string // "e4d09c784d5a2abcb2f9980bdc7acfe6"
playState: number // 0
progress: number // 0
text: string // ""
transferStatus: number // 0
translateStatus: number // 0
voiceChangeType: number // 0
voiceType: number // 0
waveAmplitudes: number[]
}
export interface ArkElement{
bytesData: string
}
export interface MessageElement {
raw: {
msgId: string,
msgTime: string,
msgSeq: string,
senderUin: string; // 发送者QQ号
chatType: ChatType,
elements: {
replyElement: {
senderUid: string, // 原消息发送者QQ号
sourceMsgIsIncPic: boolean; // 原消息是否有图片
sourceMsgText: string;
replayMsgSeq: string; // 源消息的msgSeq可以通过这个找到源消息的msgId
},
textElement: {
atType: AtType
atUid: string,
content: string,
atNtUid: string
},
picElement: {
sourcePath: string // 图片本地路径
picWidth: number
picHeight: number
fileSize: number
fileName: string
fileUuid: string
},
pttElement: PttElement,
arkElement: ArkElement
}[]
}
peer: Peer,
sender: {
uid: string // 一串加密的字符串
memberName: string
nickname: string
}
}
export enum MessageType {
text = "text",
image = "image",
voice = "record",
at = "at",
reply = "reply",
json = "json"
}
export type SendMessage = {
type: MessageType.text,
content: string,
data?: {
text: string, // 纯文本
}
} | {
type: "image" | "voice" | "record",
file: string, // 本地路径
data?: {
file: string // 本地路径
}
} | {
type: MessageType.at,
atType?: AtType,
content?: string,
atUid?: string,
atNtUid?: string,
data?: {
qq: string // at的qq号
}
} | {
type: MessageType.reply,
msgId: string,
msgSeq: string,
senderUin: string,
data: {
id: string,
}
}
export type PostDataAction = "send_private_msg" | "send_group_msg" | "get_group_list"
| "get_friend_list" | "delete_msg" | "get_login_info" | "get_group_member_list" | "get_group_member_info"
export interface PostDataSendMsg {
action: PostDataAction
message_type?: "private" | "group"
params?: {
user_id: string,
group_id: string,
message: SendMessage[];
},
user_id: string,
group_id: string,
message: SendMessage[];
ipc_uuid?: string
export interface OB11Config {
httpPort: number
httpHosts: string[]
wsPort: number
wsHosts: string[]
enableHttp?: boolean
enableHttpPost?: boolean
enableWs?: boolean
enableWsReverse?: boolean
messagePostFormat?: 'array' | 'string'
}
export interface Config {
port: number,
hosts: string[],
enableBase64?: boolean
debug?: 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 SendMsgResult {
status: number,
retcode: number,
data: any,
message: string,
}
export interface LLOneBotError {
ffmpegError?: string
otherError?: string
}
export interface FileCache {
fileName: string
filePath: string
fileSize: string
url?: string
downloadFunc?: () => Promise<void>
}

261
src/common/utils.ts Normal file
View File

@@ -0,0 +1,261 @@
import * as path from "node:path";
import {selfInfo} from "./data";
import {ConfigUtil} from "./config";
import util from "util";
import {encode, getDuration, isWav} from "silk-wasm";
import fs from 'fs';
import {v4 as uuidv4} from "uuid";
import ffmpeg from "fluent-ffmpeg"
export const DATA_DIR = global.LiteLoader.plugins["LLOneBot"].path.data;
export function getConfigUtil() {
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 log(...msg: any[]) {
if (!getConfigUtil().getConfig().log) {
return //console.log(...msg);
}
let currentDateTime = new Date().toLocaleString();
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const currentDate = `${year}-${month}-${day}`;
const userInfo = selfInfo.uin ? `${selfInfo.nick}(${selfInfo.uin})` : ""
let logMsg = "";
for (let msgItem of msg) {
// 判断是否是对象
if (typeof msgItem === "object") {
let obj = JSON.parse(JSON.stringify(msgItem));
logMsg += JSON.stringify(truncateString(obj)) + " ";
continue;
}
logMsg += msgItem + " ";
}
logMsg = `${currentDateTime} ${userInfo}: ${logMsg}\n\n`
// sendLog(...msg);
// console.log(msg)
fs.appendFile(path.join(DATA_DIR, `llonebot-${currentDate}.log`), logMsg, (err: any) => {
})
}
export function isGIF(path: string) {
const buffer = Buffer.alloc(4);
const fd = fs.openSync(path, 'r');
fs.readSync(fd, buffer, 0, 4, 0);
fs.closeSync(fd);
return buffer.toString() === 'GIF8'
}
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 定义一个异步函数来检查文件是否存在
export function checkFileReceived(path: string, timeout: number = 3000): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
function check() {
if (fs.existsSync(path)) {
resolve();
} else if (Date.now() - startTime > timeout) {
reject(new Error(`文件不存在: ${path}`));
} else {
setTimeout(check, 100);
}
}
check();
});
}
export async function file2base64(path: string) {
const readFile = util.promisify(fs.readFile);
let result = {
err: "",
data: ""
}
try {
// 读取文件内容
// if (!fs.existsSync(path)){
// path = path.replace("\\Ori\\", "\\Thumb\\");
// }
try {
await checkFileReceived(path, 5000);
} catch (e: any) {
result.err = e.toString();
return result;
}
const data = await readFile(path);
// 转换为Base64编码
result.data = data.toString('base64');
} catch (err) {
result.err = err.toString();
}
return result;
}
// 在保证老对象已有的属性不变化的情况下将新对象的属性复制到老对象
export function mergeNewProperties(newObj: any, oldObj: any) {
Object.keys(newObj).forEach(key => {
// 如果老对象不存在当前属性,则直接复制
if (!oldObj.hasOwnProperty(key)) {
oldObj[key] = newObj[key];
} else {
// 如果老对象和新对象的当前属性都是对象,则递归合并
if (typeof oldObj[key] === 'object' && typeof newObj[key] === 'object') {
mergeNewProperties(newObj[key], oldObj[key]);
} else if (typeof oldObj[key] === 'object' || typeof newObj[key] === 'object') {
// 属性冲突,有一方不是对象,直接覆盖
oldObj[key] = newObj[key];
}
}
});
}
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;
try {
const buffer = fs.readFileSync(filePath, {
encoding: null,
flag: "r",
});
const fileHeader = buffer.toString("hex", 0, bytesToRead);
return fileHeader;
} catch (err) {
console.error("读取文件错误:", err);
return;
}
}
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 pttPath = path.join(DATA_DIR, uuidv4());
if (getFileHeader(filePath) !== "02232153494c4b") {
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").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;
const pcm = fs.readFileSync(filePath);
const silk = await encode(pcm, 0);
fs.writeFileSync(pttPath, silk.data);
fs.unlink(wavPath, (err) => {
});
log(`语音文件${filePath}转换成功!`, pttPath)
return {
converted: true,
path: pttPath,
duration: silk.duration,
};
} else {
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,
duration: duration,
};
}
} catch (error) {
log("convert silk failed", error.stack);
return {};
}
}
export function isNull(value: any) {
return value === undefined || value === null;
}

64
src/global.d.ts vendored
View File

@@ -1,60 +1,8 @@
import {
Config,
Group,
GroupMemberInfo,
MessageElement,
Peer,
PostDataSendMsg, PttElement,
SelfInfo,
SendMessage, SendMsgResult,
User
} from "./common/types";
declare var LLAPI: {
on(event: "new-messages" | "new-send-messages", callback: (data: MessageElement[]) => void): void;
on(event: "context-msg-menu", callback: (event: any, target: any, msgIds:any) => void): void;
getAccountInfo(): Promise<{
uid: string // 一串加密的字符串
uin: string // qq
}>
getUserInfo(uid: string): Promise<User>; // uid是一串加密的字符串
sendMessage(peer: Peer, message: SendMessage[]): Promise<any>;
recallMessage(peer: Peer, msgIds: string[]): Promise<void>;
getGroupsList(forced: boolean): Promise<Group[]>
getFriendsList(forced: boolean): Promise<User[]>
getGroupMemberList(group_id: string, num: number): Promise<{result: { infos: Map<string, GroupMemberInfo> }}>
getPeer(): Promise<Peer>
add_qmenu(func: (qContextMenu: Node)=>void): void
Ptt2Text(msgId:string, peer: Peer, elements: MessageElement[]): Promise<any>
};
declare var llonebot: {
postData: (data: any) => void
listenSendMessage: (handle: (msg: PostDataSendMsg) => void) => void
listenRecallMessage: (handle: (msg: {message_id: string}) => void) => void
updateGroups: (groups: Group[]) => void
updateFriends: (friends: User[]) => void
updateGroupMembers: (data: { groupMembers: User[], group_id: string }) => void
startExpress: () => void
log(data: any): void,
setConfig(config: Config):void;
getConfig():Promise<Config>;
setSelfInfo(selfInfo: SelfInfo):void;
downloadFile(arg: {uri: string, fileName: string}):Promise<{errMsg: string, path: string}>;
deleteFile(path: string[]):Promise<void>;
getRunningStatus(): Promise<boolean>;
sendSendMsgResult(sessionId: string, msgResult: SendMsgResult): void;
file2base64(path: string): Promise<{err: string, data: string}>;
};
import { type LLOneBot } from './preload'
declare global {
interface Window {
LLAPI: typeof LLAPI;
llonebot: typeof llonebot;
LiteLoader: any;
}
}
interface Window {
llonebot: LLOneBot
LiteLoader: any
}
}

View File

@@ -1,224 +0,0 @@
import {log} from "./utils";
const express = require("express");
const JSONbig = require('json-bigint');
import {sendIPCRecallQQMsg, sendIPCSendQQMsg} from "./IPCSend";
import {OnebotGroupMemberRole, PostDataAction, PostDataSendMsg, SendMessage, SendMsgResult} from "../common/types";
import {friends, groups, selfInfo} from "./data";
// @SiberianHusky 2021-08-15
function checkSendMessage(sendMsgList: SendMessage[]) {
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;
}
// ==end==
function handlePost(jsonData: any, handleSendResult: (data: SendMsgResult) => void) {
log("API receive post:" + JSON.stringify(jsonData))
if (!jsonData.params) {
jsonData.params = JSON.parse(JSON.stringify(jsonData));
delete jsonData.params.params;
}
let resData = {
status: 0,
retcode: 0,
data: {},
message: ''
}
if (jsonData.action == "get_login_info") {
resData["data"] = selfInfo
} else if (jsonData.action == "send_private_msg" || jsonData.action == "send_group_msg") {
if (jsonData.action == "send_private_msg") {
jsonData.message_type = "private"
} else {
jsonData.message_type = "group"
}
// @SiberianHuskY 2021-10-20 22:00:00
resData.status = checkSendMessage(jsonData.message);
if (resData.status == 200) {
resData.message = "发送成功";
resData.data = jsonData.message;
sendIPCSendQQMsg(jsonData, handleSendResult);
return;
} else {
resData.message = "发送失败, 请检查消息格式";
resData.data = jsonData.message;
}
// == end ==
} else if (jsonData.action == "get_group_list") {
resData["data"] = groups.map(group => {
return {
group_id: group.uid,
group_name: group.name,
member_count: group.members.length,
group_members: group.members.map(member => {
return {
user_id: member.uin,
user_name: member.cardName || member.nick,
user_display_name: member.cardName || member.nick
}
})
}
})
} else if (jsonData.action == "get_group_info") {
let group = groups.find(group => group.uid == jsonData.params.group_id)
if (group) {
resData["data"] = {
group_id: group.uid,
group_name: group.name,
member_count: group.members.length,
}
}
} else if (jsonData.action == "get_group_member_info") {
let member = groups.find(group => group.uid == jsonData.params.group_id)?.members?.find(member => member.uin == jsonData.params.user_id)
resData["data"] = {
user_id: member.uin,
user_name: member.nick,
user_display_name: member.cardName || member.nick,
nickname: member.nick,
card: member.cardName,
role: OnebotGroupMemberRole[member.role],
}
} else if (jsonData.action == "get_group_member_list") {
let group = groups.find(group => group.uid == jsonData.params.group_id)
if (group) {
resData["data"] = group?.members?.map(member => {
return {
user_id: member.uin,
user_name: member.nick,
user_display_name: member.cardName || member.nick,
nickname: member.nick,
card: member.cardName,
role: OnebotGroupMemberRole[member.role],
}
}) || []
} else {
resData["data"] = []
}
} else if (jsonData.action == "get_friend_list") {
resData["data"] = friends.map(friend => {
return {
user_id: friend.uin,
user_name: friend.nickName,
}
})
} else if (jsonData.action == "delete_msg") {
sendIPCRecallQQMsg(String(jsonData.message_id))
}
return resData
}
export function startExpress(port: number) {
const app = express();
// 中间件用于解析POST请求的请求体
app.use(express.urlencoded({extended: true, limit: "500mb"}));
app.use(express.json({
limit: '500mb',
verify: (req: any, res: any, buf: any, encoding: any) => {
req.rawBody = buf;
}
}));
app.use((req: any, res: any, next: any) => {
try {
req.body = JSONbig.parse(req.rawBody.toString());
next();
} catch (error) {
// next(error);
next();
}
});
function parseToOnebot12(action: PostDataAction) {
app.post('/' + action, (req: any, res: any) => {
let jsonData: PostDataSendMsg = req.body;
jsonData.action = action
let resData = handlePost(jsonData, (data: SendMsgResult) => {
res.send(data)
})
if (resData) {
res.send(resData)
}
});
}
const actionList: PostDataAction[] = ["get_login_info", "send_private_msg", "send_group_msg",
"get_group_list", "get_friend_list", "delete_msg", "get_group_member_list", "get_group_member_info"]
for (const action of actionList) {
parseToOnebot12(action as PostDataAction)
}
app.get('/', (req: any, res: any) => {
res.send('llonebot已启动');
})
// 处理POST请求的路由
app.post('/', (req: any, res: any) => {
let jsonData: PostDataSendMsg = req.body;
let resData = handlePost(jsonData, (data: SendMsgResult) => {
res.send(data)
})
if (resData) {
res.send(resData)
}
});
app.post('/send_msg', (req: any, res: any) => {
let jsonData: PostDataSendMsg = req.body;
if (jsonData.message_type == "private") {
jsonData.action = "send_private_msg"
} else if (jsonData.message_type == "group") {
jsonData.action = "send_group_msg"
} else {
if (jsonData.params.group_id) {
jsonData.action = "send_group_msg"
} else {
jsonData.action = "send_private_msg"
}
}
let resData = handlePost(jsonData, (data: SendMsgResult) => {
res.send(data)
})
if (resData) {
res.send(resData)
}
})
app.listen(port, "0.0.0.0", () => {
console.log(`llonebot started 0.0.0.0:${port}`);
});
}

View File

@@ -1,35 +0,0 @@
import {ipcMain, webContents} from 'electron';
import {PostDataSendMsg, SendMsgResult} from "../common/types";
import {CHANNEL_RECALL_MSG, CHANNEL_SEND_MSG} from "../common/IPCChannel";
import {v4 as uuid4} from "uuid";
import {log} from "./utils";
function sendIPCMsg(channel: string, data: any) {
let contents = webContents.getAllWebContents();
for (const content of contents) {
try {
content.send(channel, data)
} catch (e) {
console.log("llonebot send ipc msg to render error:", e)
}
}
}
export function sendIPCSendQQMsg(postData: PostDataSendMsg, handleSendResult: (data: SendMsgResult) => void) {
const onceSessionId = "llonebot_send_msg_" + uuid4();
postData.ipc_uuid = onceSessionId;
ipcMain.once(onceSessionId, (event: any, sendResult: SendMsgResult) => {
// log("llonebot send msg ipcMain.once:" + JSON.stringify(sendResult));
try {
handleSendResult(sendResult)
} catch (e) {
log("llonebot send msg ipcMain.once error:" + JSON.stringify(e))
}
})
sendIPCMsg(CHANNEL_SEND_MSG, postData);
}
export function sendIPCRecallQQMsg(message_id: string) {
sendIPCMsg(CHANNEL_RECALL_MSG, {message_id: message_id});
}

View File

@@ -1,28 +0,0 @@
import {Config} from "../common/types";
const fs = require("fs")
export class ConfigUtil{
configPath: string;
constructor(configPath: string) {
this.configPath = configPath;
}
getConfig(): Config{
if (!fs.existsSync(this.configPath)) {
return {port:3000, hosts: ["http://192.168.1.2:5000/"]}
} else {
const data = fs.readFileSync(this.configPath, "utf-8");
let jsonData =JSON.parse(data);
if (!jsonData.hosts){
jsonData.hosts = []
}
return jsonData;
}
}
setConfig(config: Config){
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), "utf-8")
}
}

View File

@@ -1,9 +0,0 @@
import {Group, SelfInfo, User} from "../common/types";
export let groups: Group[] = []
export let friends: User[] = []
export let selfInfo: SelfInfo = {
user_id: "",
nickname: ""
}

13
src/main/ipcsend.ts Normal file
View File

@@ -0,0 +1,13 @@
import {webContents} from 'electron';
function sendIPCMsg(channel: string, ...data: any) {
let contents = webContents.getAllWebContents();
for (const content of contents) {
try {
content.send(channel, ...data)
} catch (e) {
console.log("llonebot send ipc msg to render error:", e)
}
}
}

View File

@@ -1,217 +1,424 @@
// 运行在 Electron 主进程 下的插件入口
import * as path from "path";
import {ipcMain} from 'electron';
import * as util from 'util';
import {Config, Group, SelfInfo, User} from "../common/types";
import {BrowserWindow, dialog, ipcMain} from 'electron';
import * as fs from 'node:fs';
import {Config} from "../common/types";
import {
CHANNEL_DOWNLOAD_FILE,
CHANNEL_ERROR,
CHANNEL_GET_CONFIG,
CHANNEL_SET_SELF_INFO,
CHANNEL_LOG,
CHANNEL_POST_ONEBOT_DATA,
CHANNEL_SELECT_FILE,
CHANNEL_SET_CONFIG,
CHANNEL_START_HTTP_SERVER,
CHANNEL_UPDATE_FRIENDS,
CHANNEL_UPDATE_GROUPS, CHANNEL_DELETE_FILE, CHANNEL_GET_RUNNING_STATUS, CHANNEL_FILE2BASE64
} from "../common/IPCChannel";
import {ConfigUtil} from "./config";
import {startExpress} from "./HttpServer";
import {checkFileReceived, CONFIG_DIR, isGIF, log} from "./utils";
import {friends, groups, selfInfo} from "./data";
import {} from "../global";
} from "../common/channels";
import {ob11WebsocketServer} from "../onebot11/server/ws/WebsocketServer";
import {checkFfmpeg, DATA_DIR, getConfigUtil, log} from "../common/utils";
import {
friendRequests, getFriend,
getGroup,
getGroupMember,
groupNotifies,
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,
FriendRequestNotify,
GroupMember,
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 {postOB11Event} from "../onebot11/server/postOB11Event";
import {ob11ReverseWebsockets} from "../onebot11/server/ws/ReverseWebsocket";
import {OB11GroupAdminNoticeEvent} from "../onebot11/event/notice/OB11GroupAdminNoticeEvent";
import {OB11GroupDecreaseEvent} from "../onebot11/event/notice/OB11GroupDecreaseEvent";
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";
const fs = require('fs');
let running = false;
// 加载插件时触发
function onLoad() {
log("main onLoaded");
log("llonebot main onLoad");
// const config_dir = browserWindow.LiteLoader.plugins["LLOneBot"].path.data;
function getConfigUtil() {
const configFilePath = path.join(CONFIG_DIR, `config_${selfInfo.user_id}.json`)
return new ConfigUtil(configFilePath)
}
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, {recursive: true});
}
ipcMain.handle(CHANNEL_GET_CONFIG, (event: any, arg: any) => {
return getConfigUtil().getConfig()
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 ""
}
})
ipcMain.handle(CHANNEL_DOWNLOAD_FILE, async (event: any, arg: { uri: string, fileName: string }): Promise<{
success: boolean,
errMsg: string,
path: string
}> => {
let filePath = path.join(CONFIG_DIR, arg.fileName)
let url = new URL(arg.uri);
if (url.protocol == "base64:") {
// base64转成文件
let base64Data = arg.uri.split("base64://")[1]
try {
const buffer = Buffer.from(base64Data, 'base64');
fs.writeFileSync(filePath, buffer);
} catch (e: any) {
return {
success: false,
errMsg: `base64文件下载失败,` + e.toString(),
path: ""
}
}
} else if (url.protocol == "http:" || url.protocol == "https:") {
// 下载文件
let res = await fetch(url)
if (!res.ok) {
return {
success: false,
errMsg: `${url}下载失败,` + res.statusText,
path: ""
}
}
let blob = await res.blob();
let buffer = await blob.arrayBuffer();
try {
fs.writeFileSync(filePath, Buffer.from(buffer));
} catch (e: any) {
return {
success: false,
errMsg: `${url}下载失败,` + e.toString(),
path: ""
}
}
}
else{
return {
success: false,
errMsg: `不支持的file协议,` + url.protocol,
path: ""
}
}
if (isGIF(filePath)) {
fs.renameSync(filePath, filePath + ".gif");
filePath += ".gif";
}
return {
success: true,
errMsg: "",
path: filePath
};
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, {recursive: true});
}
ipcMain.handle(CHANNEL_ERROR, (event, arg) => {
return llonebotError;
})
ipcMain.on(CHANNEL_SET_CONFIG, (event: any, arg: Config) => {
ipcMain.handle(CHANNEL_GET_CONFIG, async (event, arg) => {
const config = getConfigUtil().getConfig()
return config;
})
ipcMain.on(CHANNEL_SET_CONFIG, (event, arg: Config) => {
let oldConfig = getConfigUtil().getConfig();
getConfigUtil().setConfig(arg)
})
ipcMain.on(CHANNEL_START_HTTP_SERVER, (event: any, arg: any) => {
startExpress(getConfigUtil().getConfig().port)
})
ipcMain.on(CHANNEL_UPDATE_GROUPS, (event: any, arg: Group[]) => {
for (const group of arg) {
let existGroup = groups.find(g => g.uid == group.uid)
if (existGroup) {
if (!existGroup.members) {
existGroup.members = []
}
existGroup.name = group.name
for (const member of group.members || []) {
let existMember = existGroup.members?.find(m => m.uin == member.uin)
if (existMember) {
existMember.nick = member.nick
existMember.cardName = member.cardName
} else {
existGroup.members?.push(member)
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;
}
}
} else {
groups.push(group)
}
}
groups.length = 0
groups.push(...arg)
// 检查ffmpeg
if (arg.ffmpeg) {
checkFfmpeg(arg.ffmpeg).then(success => {
if (success) {
llonebotError.ffmpegError = ''
}
})
}
})
ipcMain.on(CHANNEL_UPDATE_FRIENDS, (event: any, arg: User[]) => {
friends.length = 0
friends.push(...arg)
ipcMain.on(CHANNEL_LOG, (event, arg) => {
log(arg);
})
ipcMain.on(CHANNEL_POST_ONEBOT_DATA, (event: any, arg: any) => {
for (const host of getConfigUtil().getConfig().hosts) {
async function postReceiveMsg(msgList: RawMessage[]) {
const {debug, reportSelfMessage} = getConfigUtil().getConfig();
for (let message of msgList) {
// log("收到新消息", message)
message.msgShortId = await dbUtil.addMsg(message)
OB11Constructor.message(message).then((msg) => {
if (debug) {
msg.raw = message;
}
const isSelfMsg = msg.user_id.toString() == selfInfo.uin
if (isSelfMsg && !reportSelfMessage) {
return
}
postOB11Event(msg);
// log("post msg", msg)
}).catch(e => log("constructMessage error: ", e.stack.toString()));
}
}
async function startReceiveHook() {
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, async (payload) => {
try {
fetch(host, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-self-id": selfInfo.user_id
},
body: JSON.stringify(arg)
}).then((res: any) => {
log(`新消息事件上传成功: ${host} ` + JSON.stringify(arg));
}, (err: any) => {
log(`新消息事件上传失败: ${host} ` + err + JSON.stringify(arg));
});
} catch (e: any) {
log(e.toString())
await postReceiveMsg(payload.msgList);
} catch (e) {
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") {
// 撤回消息上报
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);
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)
operatorId = operator.uin
}
const groupRecallEvent = new OB11GroupRecallNoticeEvent(
parseInt(message.peerUin),
parseInt(message.senderUin),
parseInt(operatorId),
oriMessage.msgShortId
)
postOB11Event(groupRecallEvent);
}
// 不让入库覆盖原来消息,不然就获取不到撤回的消息内容了
continue
}
dbUtil.addMsg(message).then();
}
})
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, async (payload) => {
const {reportSelfMessage} = getConfigUtil().getConfig();
if (!reportSelfMessage) {
return
}
// log("reportSelfMessage", payload)
try {
await postReceiveMsg([payload.msgRecord]);
} catch (e) {
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 = groupNotifies[notify.seq];
if (existNotify) {
if (Date.now() - existNotify.time < 3000) {
continue
}
}
log("收到群通知", notify);
groupNotifies[notify.seq] = 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, null, 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() {
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)
} catch (e) {
log("http server start failed", e);
}
}
})
ipcMain.on(CHANNEL_LOG, (event: any, arg: any) => {
log(arg)
})
ipcMain.handle(CHANNEL_SET_SELF_INFO, (event: any, arg: SelfInfo) => {
selfInfo.user_id = arg.user_id;
selfInfo.nickname = arg.nickname;
running = true;
})
ipcMain.on(CHANNEL_DELETE_FILE, (event: any, arg: string[]) => {
for (const path of arg) {
fs.unlinkSync(path);
if (config.ob11.enableWs) {
ob11WebsocketServer.start(config.ob11.wsPort);
}
})
ipcMain.handle(CHANNEL_GET_RUNNING_STATUS, (event: any, arg: any) => {
return running;
})
ipcMain.handle(CHANNEL_FILE2BASE64, async (event: any, path: string): Promise<{err: string, data: string}> => {
const readFile = util.promisify(fs.readFile);
let result = {
err: "",
data: ""
if (config.ob11.enableWsReverse) {
ob11ReverseWebsockets.start();
}
log("LLOneBot start")
}
let getSelfNickCount = 0;
const init = async () => {
try {
// 读取文件内容
// if (!fs.existsSync(path)){
// path = path.replace("\\Ori\\", "\\Thumb\\");
// }
try {
await checkFileReceived(path, 5000);
} catch (e: any) {
result.err = e.toString();
return result;
}
const data = await readFile(path);
// 转换为Base64编码
result.data = data.toString('base64');
} catch (err) {
result.err = err.toString();
log("start get self info")
const _ = await NTQQApi.getSelfInfo();
log("get self info api result:", _);
Object.assign(selfInfo, _);
selfInfo.nick = selfInfo.uin;
} catch (e) {
log("retry get self info", e);
}
return result;
})
log("self info", selfInfo);
if (selfInfo.uin) {
try {
const userInfo = (await NTQQApi.getUserDetailInfo(selfInfo.uid));
log("self info", userInfo);
if (userInfo) {
selfInfo.nick = userInfo.nick;
} else {
getSelfNickCount++;
if (getSelfNickCount < 10) {
return setTimeout(init, 1000);
}
}
} catch (e) {
log("get self nickname failed", e.toString());
return setTimeout(init, 1000);
}
start().then();
} else {
setTimeout(init, 1000)
}
}
setTimeout(init, 1000);
}
// 创建窗口时触发
function onBrowserWindowCreated(window: any) {
function onBrowserWindowCreated(window: BrowserWindow) {
try {
hookNTQQApiCall(window);
hookNTQQApiReceive(window);
} catch (e) {
log("LLOneBot hook error: ", e.toString())
}
}
try {

View File

@@ -1,47 +0,0 @@
import * as path from "path";
import {json} from "express";
import {selfInfo} from "./data";
const fs = require('fs');
export const CONFIG_DIR = global.LiteLoader.plugins["LLOneBot"].path.data;
export function log(msg: any) {
let currentDateTime = new Date().toLocaleString();
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const currentDate = `${year}-${month}-${day}`;
const userInfo = selfInfo.user_id ? `${selfInfo.nickname}(${selfInfo.user_id})` : ""
fs.appendFile(path.join(CONFIG_DIR , `llonebot-${currentDate}.log`), currentDateTime + ` ${userInfo}:` + JSON.stringify(msg) + "\n", (err: any) => {
})
}
export function isGIF(path: string) {
const buffer = Buffer.alloc(4);
const fd = fs.openSync(path, 'r');
fs.readSync(fd, buffer, 0, 4, 0);
fs.closeSync(fd);
return buffer.toString() === 'GIF8'
}
// 定义一个异步函数来检查文件是否存在
export function checkFileReceived(path: string, timeout: number=3000): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
function check() {
if (fs.existsSync(path)) {
resolve();
} else if (Date.now() - startTime > timeout) {
reject(new Error(`文件不存在: ${path}`));
} else {
setTimeout(check, 100);
}
}
check();
});
}

161
src/ntqqapi/constructor.ts Normal file
View File

@@ -0,0 +1,161 @@
import {
AtType,
ElementType, PicType, SendArkElement,
SendFaceElement,
SendFileElement,
SendPicElement,
SendPttElement,
SendReplyElement,
SendTextElement
} from "./types";
import {NTQQApi} from "./ntcall";
import {encodeSilk, isGIF} from "../common/utils";
import * as fs from "node:fs";
export class SendMsgElementConstructor {
static text(content: string): SendTextElement {
return {
elementType: ElementType.TEXT,
elementId: "",
textElement: {
content,
atType: AtType.notAt,
atUid: "",
atTinyId: "",
atNtUid: "",
},
};
}
static at(atUid: string, atNtUid: string, atType: AtType, atName: string): SendTextElement {
return {
elementType: ElementType.TEXT,
elementId: "",
textElement: {
content: `@${atName}`,
atType,
atUid,
atTinyId: "",
atNtUid,
},
};
}
static reply(msgSeq: string, msgId: string, senderUin: string, senderUinStr: string): SendReplyElement {
return {
elementType: ElementType.REPLY,
elementId: "",
replyElement: {
replayMsgSeq: msgSeq, // raw.msgSeq
replayMsgId: msgId, // raw.msgId
senderUin: senderUin,
senderUinStr: senderUinStr,
}
}
}
static async pic(picPath: string): Promise<SendPicElement> {
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(picPath, ElementType.PIC);
const imageSize = await NTQQApi.getImageSize(picPath);
const picElement = {
md5HexStr: md5,
fileSize: fileSize,
picWidth: imageSize.width,
picHeight: imageSize.height,
fileName: fileName,
sourcePath: path,
original: true,
picType: isGIF(picPath) ? PicType.gif : PicType.jpg,
picSubType: 0,
fileUuid: "",
fileSubId: "",
thumbFileSize: 0,
summary: "",
};
return {
elementType: ElementType.PIC,
elementId: "",
picElement
};
}
static async file(filePath: string, showPreview: boolean = false, fileName: string = ""): Promise<SendFileElement> {
let picHeight = 0;
let picWidth = 0;
if (showPreview) {
picHeight = 1024;
picWidth = 768;
}
const {md5, fileName: _fileName, path, fileSize} = await NTQQApi.uploadFile(filePath, ElementType.FILE);
let element: SendFileElement = {
elementType: ElementType.FILE,
elementId: "",
fileElement: {
fileName: fileName || _fileName,
"filePath": path,
"fileSize": (fileSize).toString(),
picHeight,
picWidth
}
}
return element;
}
static video(filePath: string, fileName: string=""): Promise<SendFileElement> {
return SendMsgElementConstructor.file(filePath, true, fileName);
}
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, ElementType.PTT);
if (converted) {
fs.unlink(silkPath, () => {
});
}
return {
elementType: ElementType.PTT,
elementId: "",
pttElement: {
fileName: fileName,
filePath: path,
md5HexStr: md5,
fileSize: fileSize,
// duration: Math.max(1, Math.round(fileSize / 1024 / 3)), // 一秒钟大概是3kb大小, 小于1秒的按1秒算
duration: duration / 1000,
formatType: 1,
voiceType: 1,
voiceChangeType: 0,
canConvert2Text: true,
waveAmplitudes: [
0, 18, 9, 23, 16, 17, 16, 15, 44, 17, 24, 20, 14, 15, 17,
],
fileSubId: "",
playState: 1,
autoConvertText: 0,
}
};
}
static face(faceId: number): SendFaceElement {
return {
elementType: ElementType.FACE,
elementId: "",
faceElement: {
faceIndex: faceId,
faceType: 1
}
}
}
static ark(data: any): SendArkElement {
return {
elementType: ElementType.ARK,
elementId: "",
arkElement: data
}
}
}

283
src/ntqqapi/hook.ts Normal file
View File

@@ -0,0 +1,283 @@
import {type BrowserWindow} from 'electron'
import {getConfigUtil, log, sleep} from '../common/utils'
import {NTQQApi, type NTQQApiClass, sendMessagePool} from './ntcall'
import {type Group, type RawMessage, type User} from './types'
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 {postOB11Event} from '../onebot11/server/postOB11Event'
import {HOOK_LOG} from '../common/config'
import fs from 'fs'
import {dbUtil} from "../common/db";
export const hookApiCallbacks: Record<string, (apiReturn: any) => void> = {}
export enum ReceiveCmd {
UPDATE_MSG = 'nodeIKernelMsgListener/onMsgInfoListUpdate',
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',
MEDIA_DOWNLOAD_COMPLETE = 'nodeIKernelMsgListener/onRichMediaDownloadComplete',
UNREAD_GROUP_NOTIFY = 'nodeIKernelGroupListener/onGroupNotifiesUnreadCountUpdated',
GROUP_NOTIFY = 'nodeIKernelGroupListener/onGroupSingleScreenNotifies',
FRIEND_REQUEST = 'nodeIKernelBuddyListener/onBuddyReqChange',
SELF_STATUS = 'nodeIKernelProfileListener/onSelfStatusChanged',
}
interface NTQQApiReturnData<PayloadType = unknown> extends Array<any> {
0: {
'type': 'request'
'eventName': NTQQApiClass
'callbackId'?: string
}
1:
Array<{
cmdName: ReceiveCmd
cmdType: 'event'
payload: PayloadType
}>
}
const receiveHooks: Array<{
method: ReceiveCmd
hookFunc: ((payload: any) => void | Promise<void>)
id: string
}> = []
export function hookNTQQApiReceive(window: BrowserWindow) {
const originalSend = window.webContents.send
const patchSend = (channel: string, ...args: NTQQApiReturnData) => {
HOOK_LOG && log(`received ntqq api message: ${channel}`, JSON.stringify(args))
if (args?.[1] instanceof Array) {
for (const receiveData of args?.[1]) {
const ntQQApiMethodName = receiveData.cmdName
// log(`received ntqq api message: ${channel} ${ntQQApiMethodName}`, JSON.stringify(receiveData))
for (const hook of receiveHooks) {
if (hook.method === ntQQApiMethodName) {
new Promise((resolve, reject) => {
try {
const _ = hook.hookFunc(receiveData.payload)
if (hook.hookFunc.constructor.name === 'AsyncFunction') {
(_ as Promise<void>).then()
}
} catch (e) {
log('hook error', e, receiveData.payload)
}
}).then()
}
}
}
}
if (args[0]?.callbackId) {
// log("hookApiCallback", hookApiCallbacks, args)
const callbackId = args[0].callbackId
if (hookApiCallbacks[callbackId]) {
// log("callback found")
new Promise((resolve, reject) => {
hookApiCallbacks[callbackId](args[1])
}).then()
delete hookApiCallbacks[callbackId]
}
}
return originalSend.call(window.webContents, channel, ...args)
}
window.webContents.send = patchSend
}
export function hookNTQQApiCall(window: BrowserWindow) {
// 监听调用NTQQApi
const webContents = window.webContents as any
const ipc_message_proxy = webContents._events['-ipc-message']?.[0] || webContents._events['-ipc-message']
const proxyIpcMsg = new Proxy(ipc_message_proxy, {
apply(target, 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
}
}
export function registerReceiveHook<PayloadType>(method: ReceiveCmd, hookFunc: (payload: PayloadType) => void): string {
const id = uuidv4()
receiveHooks.push({
method,
hookFunc,
id
})
return id
}
export function removeReceiveHook(id: string) {
const index = receiveHooks.findIndex(h => h.id === id)
receiveHooks.splice(index, 1)
}
async function updateGroups(_groups: Group[], needUpdate: boolean = true) {
for (const group of _groups) {
let existGroup = groups.find(g => g.groupCode == group.groupCode)
if (existGroup) {
Object.assign(existGroup, group)
} else {
groups.push(group)
existGroup = group
}
if (needUpdate) {
const members = await NTQQApi.getGroupMembers(group.groupCode)
if (members) {
existGroup.members = members
}
}
}
}
async function processGroupEvent(payload) {
try {
const newGroupList = payload.groupList
for (const group of newGroupList) {
const existGroup = groups.find(g => g.groupCode == group.groupCode)
if (existGroup) {
if (existGroup.memberCount > group.memberCount) {
const oldMembers = existGroup.members
await sleep(200) // 如果请求QQ API的速度过快通常无法正确拉取到最新的群信息因此这里人为引入一个延时
const newMembers = await NTQQApi.getGroupMembers(group.groupCode)
group.members = newMembers
const newMembersSet = new Set<string>() // 建立索引降低时间复杂度
for (const member of newMembers) {
newMembersSet.add(member.uin)
}
for (const member of oldMembers) {
if (!newMembersSet.has(member.uin)) {
postOB11Event(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)) {
postOB11Event(new OB11GroupIncreaseEvent(group.groupCode, parseInt(member.uin)))
break
}
}
}
}
}
updateGroups(newGroupList, false).then()
} 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 {
if (process.platform == 'win32') {
processGroupEvent(payload).then()
}
}
})
registerReceiveHook<{ groupList: Group[], updateType: number }>(ReceiveCmd.GROUPS_UNIX, (payload) => {
if (payload.updateType != 2) {
updateGroups(payload.groupList).then()
} else {
if (process.platform != 'win32') {
processGroupEvent(payload).then()
}
}
})
registerReceiveHook<{
data: Array<{ categoryId: number, categroyName: string, categroyMbCount: number, buddyList: User[] }>
}>(ReceiveCmd.FRIENDS, payload => {
for (const fData of payload.data) {
const _friends = fData.buddyList
for (const friend of _friends) {
const existFriend = friends.find(f => f.uin == friend.uin)
if (!existFriend) {
friends.push(friend)
} else {
Object.assign(existFriend, friend)
}
}
}
})
registerReceiveHook<{ msgList: RawMessage[] }>(ReceiveCmd.NEW_MSG, (payload) => {
const {autoDeleteFile, autoDeleteFileSecond} = getConfigUtil().getConfig()
for (const message of payload.msgList) {
// log("收到新消息push到历史记录", message)
dbUtil.addMsg(message).then()
// 清理文件
if (!autoDeleteFile) {
continue
}
for (const msgElement of message.elements) {
setTimeout(() => {
const picPath = msgElement.picElement?.sourcePath
const pttPath = msgElement.pttElement?.filePath
const pathList = [picPath, pttPath]
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)
})
}
}
}, autoDeleteFileSecond * 1000)
}
}
})
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, ({msgRecord}) => {
const message = msgRecord
const peerUid = message.peerUid
// log("收到自己发送成功的消息", Object.keys(sendMessagePool), message);
const sendCallback = sendMessagePool[peerUid]
if (sendCallback) {
try {
sendCallback(message)
} catch (e) {
log('receive self msg error', e.stack)
}
}
})
registerReceiveHook<{ info: { status: number } }>(ReceiveCmd.SELF_STATUS, (info) => {
selfInfo.online = info.info.status !== 20
})

715
src/ntqqapi/ntcall.ts Normal file
View File

@@ -0,0 +1,715 @@
import {ipcMain} from 'electron'
import {hookApiCallbacks, ReceiveCmd, registerReceiveHook, removeReceiveHook} from './hook'
import {log, sleep} from '../common/utils'
import {
type ChatType,
ElementType,
type Friend,
type FriendRequest,
type Group, GroupMember,
type GroupMemberRole,
type GroupNotifies,
type GroupNotify,
type GroupRequestOperateTypes,
type RawMessage,
type SelfInfo,
type SendMessageElement,
type User
} from './types'
import * as fs from 'node:fs'
import {friendRequests, groupNotifies, selfInfo, uidMaps} from '../common/data'
import {v4 as uuidv4} from 'uuid'
import path from 'path'
import {dbUtil} from "../common/db";
interface IPCReceiveEvent {
eventName: string
callbackId: string
}
export type IPCReceiveDetail = [
{
cmdName: NTQQApiMethod
payload: unknown
},
]
export enum NTQQApiClass {
NT_API = 'ns-ntApi',
FS_API = 'ns-FsApi',
GLOBAL_DATA = 'ns-GlobalDataApi'
}
export enum NTQQApiMethod {
LIKE_FRIEND = 'nodeIKernelProfileLikeService/setBuddyProfileLike',
SELF_INFO = 'fetchAuthData',
FRIENDS = 'nodeIKernelBuddyService/getBuddyList',
GROUPS = 'nodeIKernelGroupService/getGroupList',
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',
IMAGE_SIZE = 'getImageSizeFromPath',
FILE_SIZE = 'getFileSize',
MEDIA_FILE_PATH = 'nodeIKernelMsgService/getRichMediaFilePathForGuild',
RECALL_MSG = 'nodeIKernelMsgService/recallMsg',
SEND_MSG = 'nodeIKernelMsgService/sendMsg',
DOWNLOAD_MEDIA = 'nodeIKernelMsgService/downloadRichMedia',
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',
}
enum NTQQApiChannel {
IPC_UP_2 = 'IPC_UP_2',
IPC_UP_3 = 'IPC_UP_3',
IPC_UP_1 = 'IPC_UP_1',
}
export interface Peer {
chatType: ChatType
peerUid: string // 如果是群聊uid为群号私聊uid就是加密的字符串
guildId?: ''
}
interface NTQQApiParams {
methodName: NTQQApiMethod | string
className?: NTQQApiClass
channel?: NTQQApiChannel
classNameIsRegister?: boolean
args?: unknown[]
cbCmd?: ReceiveCmd | null
cmdCB?: (payload: any) => boolean
afterFirstCmd?: boolean // 是否在methodName调用完之后再去hook cbCmd
timeoutSecond?: number
}
async function callNTQQApi<ReturnType>(params: NTQQApiParams) {
let {
className, methodName, channel, args,
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 await 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) => {
success = true
resolve(r)
}
} else {
// 这里的callback比较特殊QQ后端先返回是否调用成功再返回一条结果数据
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}`)
}
}
}
setTimeout(() => {
// log("ntqq api timeout", success, channel, className, methodName)
if (!success) {
log(`ntqq api timeout ${channel}, ${eventName}, ${methodName}`, apiArgs)
reject(`ntqq api timeout ${channel}, ${eventName}, ${methodName}, ${apiArgs}`)
}
}, _timeout)
ipcMain.emit(
channel,
{},
{type: 'request', callbackId: uuid, eventName},
apiArgs
)
})
}
export const sendMessagePool: Record<string, ((sendSuccessMsg: RawMessage) => void) | null> = {}// peerUid: callbackFunnc
interface GeneralCallResult {
result: number // 0: success
errMsg: string
}
export class NTQQApi {
// static likeFriend = defineNTQQApi<void>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.LIKE_FRIEND)
static async likeFriend(uid: string, count = 1) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.LIKE_FRIEND,
args: [{
doLikeUserInfo: {
friendUid: uid,
sourceId: 71,
doLikeCount: count,
doLikeTollCount: 0
}
}, null]
})
}
static async getSelfInfo() {
return await callNTQQApi<SelfInfo>({
className: NTQQApiClass.GLOBAL_DATA,
// channel: NTQQApiChannel.IPC_UP_3,
methodName: NTQQApiMethod.SELF_INFO,
timeoutSecond: 2
})
}
static async getUserInfo(uid: string) {
const result = await callNTQQApi<{ profiles: Map<string, User> }>({
methodName: NTQQApiMethod.USER_INFO,
args: [{force: true, uids: [uid]}, undefined],
cbCmd: ReceiveCmd.USER_INFO
})
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
]
})
return result.info
}
static async getFriends(forced = false) {
const data = await callNTQQApi<{
data: Array<{
categoryId: number
categroyName: string
categroyMbCount: number
buddyList: Friend[]
}>
}>(
{
methodName: NTQQApiMethod.FRIENDS,
args: [{force_update: forced}, undefined],
cbCmd: ReceiveCmd.FRIENDS
})
const _friends: Friend[] = []
for (const fData of data.data) {
_friends.push(...fData.buddyList)
}
return _friends
}
static async getGroups(forced = false) {
let cbCmd = ReceiveCmd.GROUPS
if (process.platform != 'win32') {
cbCmd = ReceiveCmd.GROUPS_UNIX
}
const result = await callNTQQApi<{
updateType: number
groupList: Group[]
}>({methodName: NTQQApiMethod.GROUPS, args: [{force_update: forced}, undefined], cbCmd})
return result.groupList
}
static async getGroupMembers(groupQQ: string, num = 3000): Promise<GroupMember[]> {
const sceneId = await callNTQQApi({
methodName: NTQQApiMethod.GROUP_MEMBER_SCENE,
args: [{
groupCode: groupQQ,
scene: 'groupMemberList_MainWindow'
}]
})
// log("get group member sceneId", sceneId);
try {
const result = await callNTQQApi<{
result: { infos: any }
}>({
methodName: NTQQApiMethod.GROUP_MEMBERS,
args: [{
sceneId,
num
},
null
]
})
// log("members info", typeof result.result.infos, Object.keys(result.result.infos))
const values = result.result.infos.values()
const members: GroupMember[] = Array.from(values)
for (const member of members) {
uidMaps[member.uid] = member.uin;
}
// log(uidMaps);
// log("members info", values);
log(`get group ${groupQQ} members success`)
return members
} catch (e) {
log(`get group ${groupQQ} members failed`, e)
return []
}
}
static async getFileType(filePath: string) {
return await callNTQQApi<{ ext: string }>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.FILE_TYPE, args: [filePath]
})
}
static async getFileMd5(filePath: string) {
return await callNTQQApi<string>({
className: NTQQApiClass.FS_API,
methodName: NTQQApiMethod.FILE_MD5,
args: [filePath]
})
}
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 async getImageSize(filePath: string) {
return await callNTQQApi<{ width: number, height: number }>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.IMAGE_SIZE, args: [filePath]
})
}
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, elementType: ElementType = ElementType.PIC) {
const md5 = await NTQQApi.getFileMd5(filePath)
let ext = (await NTQQApi.getFileType(filePath))?.ext
if (ext) {
ext = '.' + ext
} else {
ext = ''
}
let fileName = `${path.basename(filePath)}`
if (!fileName.includes('.')) {
fileName += ext
}
const mediaPath = await callNTQQApi<string>({
methodName: NTQQApiMethod.MEDIA_FILE_PATH,
args: [{
path_info: {
md5HexStr: md5,
fileName,
elementType,
elementSubType: 0,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: ''
}
}]
})
log('media path', mediaPath)
await NTQQApi.copyFile(filePath, mediaPath)
const fileSize = await NTQQApi.getFileSize(filePath)
return {
md5,
fileName,
path: mediaPath,
fileSize
}
}
static async downloadMedia(msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string) {
// 用于下载收到的消息中的图片等
if (fs.existsSync(sourcePath)) {
return sourcePath
}
const apiParams = [
{
getReq: {
msgId,
chatType,
peerUid,
elementId,
thumbSize: 0,
downloadType: 1,
filePath: thumbPath
}
},
undefined
]
// 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 async recallMsg(peer: Peer, msgIds: string[]) {
return await callNTQQApi({
methodName: NTQQApiMethod.RECALL_MSG,
args: [{
peer,
msgIds
}, null]
})
}
static async sendMsg(peer: Peer, msgElements: SendMessageElement[], waitComplete = false, timeout = 10000) {
const peerUid = peer.peerUid
// 等待上一个相同的peer发送完
let checkLastSendUsingTime = 0
const waitLastSend = async () => {
if (checkLastSendUsingTime > timeout) {
throw ('发送超时')
}
const lastSending = sendMessagePool[peer.peerUid]
if (lastSending) {
// log("有正在发送的消息,等待中...")
await sleep(500)
checkLastSendUsingTime += 500
return await waitLastSend()
} else {
}
}
await waitLastSend()
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}发送消息成功`)
}
checkSendCompleteUsingTime += 500
if (checkSendCompleteUsingTime > timeout) {
throw ('发送超时')
}
await sleep(500)
return await checkSendComplete()
}
log("开始发送消息", peer, msgElements)
callNTQQApi({
methodName: NTQQApiMethod.SEND_MSG,
args: [{
msgId: '0',
peer,
msgElements,
msgAttributeInfos: new Map()
}, null]
}).then()
return await checkSendComplete()
}
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 = [
{
msgInfos,
srcContact: srcPeer,
dstContact: destPeer,
commentElements: [],
msgAttributeInfos: new Map()
},
null
]
return await new Promise<RawMessage>((resolve, reject) => {
let complete = false
setTimeout(() => {
if (!complete) {
reject('转发消息超时')
}
}, 5000)
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') {
return
}
if (msg.peerUid == destPeer.peerUid && msg.senderUid == selfInfo.uid) {
complete = true
await dbUtil.addMsg(msg)
resolve(msg)
log('转发消息成功:', payload)
}
})
callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.MULTI_FORWARD_MSG,
args: apiArgs
}).then(result => {
log('转发消息结果:', result, apiArgs)
if (result.result !== 0) {
complete = true
reject('转发消息失败,' + JSON.stringify(result))
}
})
})
}
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 handleGroupRequest(seq: string, operateType: GroupRequestOperateTypes, reason?: string) {
const notify: GroupNotify = groupNotifies[seq]
if (!notify) {
throw `${seq}对应的加群通知不存在`
}
delete groupNotifies[seq]
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.HANDLE_GROUP_REQUEST,
args: [
{
doubt: false,
operateMsg: {
operateType, // 2 拒绝
targetMsg: {
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 publishGroupBulletin(groupQQ: string, title: string, content: string) {
}
}

405
src/ntqqapi/types.ts Normal file
View File

@@ -0,0 +1,405 @@
export interface User {
uid: string; // 加密的字符串
uin: string; // QQ号
nick: string;
avatarUrl?: string;
longNick?: string; // 签名
remark?: string
}
export interface SelfInfo extends User {
online?: boolean;
}
export interface Friend extends User {
}
export interface Group {
groupCode: string,
maxMember: number,
memberCount: number,
groupName: string,
groupStatus: 0,
memberRole: 2,
isTop: boolean,
toppedTimestamp: "0",
privilegeFlag: number, //65760
isConf: boolean,
hasModifyConfGroupFace: boolean,
hasModifyConfGroupName: boolean,
remarkName: string,
hasMemo: boolean,
groupShutupExpireTime: string, //"0",
personShutupExpireTime: string, //"0",
discussToGroupUin: string, //"0",
discussToGroupMaxMsgSeq: number,
discussToGroupTime: number,
groupFlagExt: number, //1073938496,
authGroupType: number, //0,
groupCreditLevel: number, //0,
groupFlagExt3: number, //0,
groupOwnerId: {
"memberUin": string, //"0",
"memberUid": string, //"u_fbf8N7aeuZEnUiJAbQ9R8Q"
},
members: GroupMember[] // 原始数据是没有这个的,为了方便自己加了这个字段
}
export enum GroupMemberRole {
normal = 2,
admin = 3,
owner = 4
}
export interface GroupMember {
avatarPath: string;
cardName: string;
cardType: number;
isDelete: boolean;
nick: string;
qid: string;
remark: string;
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,
FACE = 6,
REPLY = 7,
ARK = 10,
}
export interface SendTextElement {
elementType: ElementType.TEXT,
elementId: "",
textElement: {
content: string,
atType: number,
atUid: string,
atTinyId: string,
atNtUid: string,
}
}
export interface SendPttElement {
elementType: ElementType.PTT,
elementId: "",
pttElement: {
fileName: string,
filePath: string,
md5HexStr: string,
fileSize: number,
duration: number, // 单位是秒
formatType: number,
voiceType: number,
voiceChangeType: number,
canConvert2Text: boolean,
waveAmplitudes: number[],
fileSubId: "",
playState: number,
autoConvertText: number,
}
}
export enum PicType {
gif = 2000,
jpg = 1000
}
export interface SendPicElement {
elementType: ElementType.PIC,
elementId: "",
picElement: {
md5HexStr: string,
fileSize: number,
picWidth: number,
picHeight: number,
fileName: string,
sourcePath: string,
original: boolean,
picType: PicType,
picSubType: number,
fileUuid: string,
fileSubId: string,
thumbFileSize: number,
summary: string,
}
}
export interface SendReplyElement {
elementType: ElementType.REPLY,
elementId: "",
replyElement: {
replayMsgSeq: string,
replayMsgId: string,
senderUin: string,
senderUinStr: string,
}
}
export interface SendFaceElement {
elementType: ElementType.FACE,
elementId: "",
faceElement: FaceElement
}
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 SendArkElement {
elementType: ElementType.ARK,
elementId: "",
arkElement: ArkElement
}
export type SendMessageElement = SendTextElement | SendPttElement |
SendPicElement | SendReplyElement | SendFaceElement | SendFileElement | SendArkElement
export enum AtType {
notAt = 0,
atAll = 1,
atUser = 2
}
export enum ChatType {
friend = 1,
group = 2,
temp = 100
}
export interface PttElement {
canConvert2Text: boolean;
duration: number; // 秒数
fileBizId: null;
fileId: number; // 0
fileName: string; // "e4d09c784d5a2abcb2f9980bdc7acfe6.amr"
filePath: string; // "/Users//Library/Containers/com.tencent.qq/Data/Library/Application Support/QQ/nt_qq_a6b15c9820595d25a56c1633ce19ad40/nt_data/Ptt/2023-11/Ori/e4d09c784d5a2abcb2f9980bdc7acfe6.amr"
fileSize: string; // "4261"
fileSubId: string; // "0"
fileUuid: string; // "90j3z7rmRphDPrdVgP9udFBaYar#oK0TWZIV"
formatType: string; // 1
invalidState: number; // 0
md5HexStr: string; // "e4d09c784d5a2abcb2f9980bdc7acfe6"
playState: number; // 0
progress: number; // 0
text: string; // ""
transferStatus: number; // 0
translateStatus: number; // 0
voiceChangeType: number; // 0
voiceType: number; // 0
waveAmplitudes: number[];
}
export interface ArkElement {
bytesData: string;
linkInfo:null,
subElementType:null
}
export const IMAGE_HTTP_HOST = "https://gchat.qpic.cn"
export interface PicElement {
originImageUrl: string; // http url, 没有hosthost是https://gchat.qpic.cn/
sourcePath: string; // 图片本地路径
thumbPath: Map<number, string>;
picWidth: number;
picHeight: number;
fileSize: number;
fileName: string;
fileUuid: string;
}
export interface GrayTipElement {
revokeElement: {
operatorRole: string;
operatorUid: string;
operatorNick: string;
operatorRemark: string;
operatorMemRemark?: string;
wording: string; // 自定义的撤回提示语
}
aioOpGrayTipElement: TipAioOpGrayTipElement
}
export interface FaceElement {
faceIndex: number,
faceType: 1
}
export interface VideoElement {
"filePath": string,
"fileName": string,
"videoMd5": string,
"thumbMd5": string
"fileTime": 87, // second
"thumbSize": 314235, // byte
"fileFormat": 2, // 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": 0
}
export interface TipAioOpGrayTipElement{
operateType: number,
peerUid: string,
fromGrpCodeOfTmpChat: string,
}
export interface TipGroupElement {
"type": 1, // 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": null
}
export interface RawMessage {
msgId: string;
msgShortId?: number; // 自己维护的消息id
msgTime: string; // 时间戳,秒
msgSeq: string;
senderUid: string;
senderUin?: string; // 发送者QQ号
peerUid: string; // 群号 或者 QQ uid
peerUin: string; // 群号 或者 发送者QQ号
sendNickName: string;
sendMemberName?: string; // 发送者群名片
chatType: ChatType;
sendStatus?: number; // 消息状态别人发的2是已撤回自己发的2是已发送
recallTime: string; // 撤回时间, "0"是没有撤回
elements: {
elementId: string,
elementType: ElementType;
replyElement: {
senderUid: string; // 原消息发送者QQ号
sourceMsgIsIncPic: boolean; // 原消息是否有图片
sourceMsgText: string;
replayMsgSeq: string; // 源消息的msgSeq可以通过这个找到源消息的msgId
};
textElement: {
atType: AtType;
atUid: string; // QQ号
content: string;
atNtUid: string; // uid号
};
picElement: PicElement;
pttElement: PttElement;
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 interface GroupNotify {
time: number; // 自己添加的字段,时间戳,毫秒, 用于判断收到短时间内收到重复的notify
seq: string, // 转成数字再除以1000应该就是时间戳
type: GroupNotifyTypes,
status: 0, // 未知
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[]
}
}

View File

@@ -0,0 +1,48 @@
import {ActionName, BaseCheckResult} 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,
}
}
public async handle(payload: PayloadType): Promise<OB11Return<ReturnDataType | null>> {
const result = await this.check(payload);
if (!result.valid) {
return OB11Response.error(result.message, 400);
}
try {
const resData = await this._handle(payload);
return OB11Response.ok(resData);
} catch (e) {
log("发送错误", e.stack)
return OB11Response.error(e.toString(), 200);
}
}
public async websocketHandle(payload: PayloadType, echo: any): Promise<OB11Return<ReturnDataType | null>> {
const result = await this.check(payload)
if (!result.valid) {
return OB11Response.error(result.message, 1400)
}
try {
const resData = await this._handle(payload)
return OB11Response.ok(resData, echo);
} catch (e) {
log("发生错误", e.stack.toString())
return OB11Response.error(e.toString(), 1200, echo)
}
}
protected async _handle(payload: PayloadType): Promise<ReturnDataType> {
throw `pleas override ${this.actionName} _handle`;
}
}
export default BaseAction

View File

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

View File

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

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

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

View File

@@ -0,0 +1,47 @@
import BaseAction from "./BaseAction";
import {fileCache} from "../../common/data";
import {getConfigUtil} from "../../common/utils";
import fs from "fs/promises";
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 = fileCache.get(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

@@ -0,0 +1,16 @@
import {OB11User} from '../types';
import {OB11Constructor} from "../constructor";
import {friends} from "../../common/data";
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
class GetFriendList extends BaseAction<null, OB11User[]> {
actionName = ActionName.GetFriendList
protected async _handle(payload: null) {
return OB11Constructor.friends(friends);
}
}
export default GetFriendList

View File

@@ -0,0 +1,24 @@
import {OB11Group} from '../types';
import {getGroup} from "../../common/data";
import {OB11Constructor} from "../constructor";
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
interface PayloadType {
group_id: number
}
class GetGroupInfo extends BaseAction<PayloadType, OB11Group> {
actionName = ActionName.GetGroupInfo
protected async _handle(payload: PayloadType) {
const group = await getGroup(payload.group_id.toString())
if (group) {
return OB11Constructor.group(group)
} else {
throw `${payload.group_id}不存在`
}
}
}
export default GetGroupInfo

View File

@@ -0,0 +1,16 @@
import {OB11Group} from '../types';
import {OB11Constructor} from "../constructor";
import {groups} from "../../common/data";
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
class GetGroupList extends BaseAction<null, OB11Group[]> {
actionName = ActionName.GetGroupList
protected async _handle(payload: null) {
return OB11Constructor.groups(groups);
}
}
export default GetGroupList

View File

@@ -0,0 +1,26 @@
import {OB11GroupMember} from '../types';
import {getGroupMember} from "../../common/data";
import {OB11Constructor} from "../constructor";
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
export interface PayloadType {
group_id: number
user_id: number
}
class GetGroupMemberInfo extends BaseAction<PayloadType, OB11GroupMember> {
actionName = ActionName.GetGroupMemberInfo
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}不存在`)
}
}
}
export default GetGroupMemberInfo

View File

@@ -0,0 +1,29 @@
import {OB11GroupMember} from '../types';
import {getGroup} from "../../common/data";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {OB11Constructor} from "../constructor";
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
export interface PayloadType {
group_id: number
}
class GetGroupMemberList extends BaseAction<PayloadType, OB11GroupMember[]> {
actionName = ActionName.GetGroupMemberList
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 {
throw (`${payload.group_id}不存在`)
}
}
}
export default GetGroupMemberList

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

@@ -0,0 +1,16 @@
import {OB11User} from '../types';
import {OB11Constructor} from "../constructor";
import {selfInfo} from "../../common/data";
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
class GetLoginInfo extends BaseAction<null, OB11User> {
actionName = ActionName.GetLoginInfo
protected async _handle(payload: null) {
return OB11Constructor.selfInfo(selfInfo);
}
}
export default GetLoginInfo

View File

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

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

@@ -0,0 +1,16 @@
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: selfInfo.online,
good: true
}
}
}

View File

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

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

@@ -0,0 +1,9 @@
import SendMsg from "./SendMsg";
import {ActionName} from "./types";
class SendGroupMsg extends SendMsg {
actionName = ActionName.SendGroupMsg
}
export default SendGroupMsg

View File

@@ -0,0 +1,30 @@
import BaseAction from "./BaseAction";
import {getFriend} from "../../common/data";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
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> {
const qq = payload.user_id.toString();
const friend = await getFriend(qq)
if (!friend) {
throw (`点赞失败,${qq}不是好友`)
}
try {
let result = await NTQQApi.likeFriend(friend.uid, parseInt(payload.times.toString()) || 1);
if (result.result !== 0) {
throw result.errMsg
}
} catch (e) {
throw `点赞失败 ${e}`
}
return null
}
}

View File

@@ -0,0 +1,413 @@
import {AtType, ChatType, 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 "node:fs";
import {log} 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 {
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;
}
export interface ReturnDataType {
message_id: number
}
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.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;
const genGroupPeer = async () => {
group = await getGroup(payload.group_id.toString())
peer.chatType = ChatType.group
// peer.name = group.name
peer.peerUid = group.groupCode
}
const genFriendPeer = () => {
const friend = friends.find(f => f.uin == payload.user_id.toString())
if (friend) {
// peer.name = friend.nickName
peer.peerUid = friend.uid
} else {
peer.chatType = ChatType.temp
const tempUserUid = getUidByUin(payload.user_id.toString())
if (!tempUserUid) {
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.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, isTempMsg)
return {message_id: returnMsg.msgShortId}
} catch (e) {
log("发送消息失败", e.stack.toString())
throw (e.toString())
}
}
protected convertMessage2List(message: OB11MessageMixType) {
if (typeof message === "string") {
// message = [{
// type: OB11MessageDataType.text,
// data: {
// text: message
// }
// }] as OB11MessageData[]
message = decodeCQCode(message.toString())
} else if (!Array.isArray(message)) {
message = [message]
}
return message;
}
private getSpecialMsgNum(payload: OB11PostSendMsg, msgType: OB11MessageDataType): number {
if (Array.isArray(payload.message)) {
return payload.message.filter(msg => msg.type == msgType).length
}
return 0
}
// 返回一个合并转发的消息id
private async handleForwardNode(destPeer: Peer, messageNodes: OB11MessageNode[], group: Group | undefined) {
const selfPeer: Peer = {
chatType: ChatType.friend,
peerUid: selfInfo.uid
}
let selfNodeMsgList: RawMessage[] = [];
let originalNodeMsgList: RawMessage[] = [];
for (const messageNode of messageNodes) {
// 一个node表示一个人的消息
let nodeId = messageNode.data.id;
// 有nodeId表示一个子转发消息卡片
if (nodeId) {
let nodeMsg = await dbUtil.getMsgByShortId(parseInt(nodeId));
if (nodeMsg) {
originalNodeMsgList.push(nodeMsg);
}
} else {
// 自定义的消息
// 提取消息段发给自己生成消息id
try {
const {
sendElements,
deleteAfterSentFiles
} = await this.createSendElements(this.convertMessage2List(messageNode.data.content), group);
log("开始生成转发节点", sendElements);
const nodeMsg = await this.send(selfPeer, sendElements, deleteAfterSentFiles, true);
selfNodeMsgList.push(nodeMsg);
log("转发节点生成成功", nodeMsg.msgId);
} catch (e) {
log("生效转发消息节点失败", e)
}
}
}
let nodeIds: string[] = []
// 检查是否需要克隆直接引用消息id的节点
let needSendSelf = false;
if (selfNodeMsgList.length) {
needSendSelf = true
} else {
needSendSelf = !originalNodeMsgList.every((msg, index) => msg.peerUid === originalNodeMsgList[0].peerUid && msg.recallTime.length < 2)
}
if (needSendSelf) {
nodeIds = selfNodeMsgList.map(msg => msg.msgId);
for (const originalNodeMsg of originalNodeMsgList) {
if (originalNodeMsg.peerUid === selfInfo.uid && originalNodeMsg.recallTime.length < 2) {
nodeIds.push(originalNodeMsg.msgId)
} else { // 需要进行克隆
let sendElements: SendMessageElement[] = []
Object.keys(originalNodeMsg.elements).forEach((eleKey) => {
if (eleKey !== "elementId") {
sendElements.push(originalNodeMsg.elements[eleKey])
}
})
try {
const nodeMsg = await NTQQApi.sendMsg(selfPeer, sendElements, true);
nodeIds.push(nodeMsg.msgId)
log("克隆转发消息成功")
} catch (e) {
log("克隆转发消息失败", e)
}
}
}
} else {
nodeIds = originalNodeMsgList.map(msg => msg.msgId)
}
let srcPeer = selfPeer;
if (!needSendSelf) {
srcPeer = {
chatType: originalNodeMsgList[0].chatType === ChatType.group ? ChatType.group : ChatType.friend,
peerUid: originalNodeMsgList[0].peerUid
}
}
// 开发转发
try {
return await NTQQApi.multiForwardMsg(srcPeer, destPeer, nodeIds)
} catch (e) {
log("forward failed", e)
return null;
}
}
private async createSendElements(messageData: OB11MessageData[], group: Group | undefined, ignoreTypes: OB11MessageDataType[] = []) {
let sendElements: SendMessageElement[] = []
let deleteAfterSentFiles: string[] = []
for (let sendMsg of messageData) {
if (ignoreTypes.includes(sendMsg.type)) {
continue
}
switch (sendMsg.type) {
case OB11MessageDataType.text: {
const text = sendMsg.data?.text;
if (text) {
sendElements.push(SendMsgElementConstructor.text(sendMsg.data!.text))
}
}
break;
case OB11MessageDataType.at: {
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 = await getGroupMember(group?.groupCode, atQQ);
if (atMember) {
sendElements.push(SendMsgElementConstructor.at(atQQ, atMember.uid, AtType.atUser, atMember.cardName || atMember.nick))
}
}
}
}
break;
case OB11MessageDataType.reply: {
let replyMsgId = sendMsg.data.id;
if (replyMsgId) {
const replyMsg = await dbUtil.getMsgBySeqId(replyMsgId)
if (replyMsg) {
sendElements.push(SendMsgElementConstructor.reply(replyMsg.msgSeq, replyMsg.msgId, replyMsg.senderUin, replyMsg.senderUin))
}
}
}
break;
case OB11MessageDataType.face: {
const faceId = sendMsg.data?.id
if (faceId) {
sendElements.push(SendMsgElementConstructor.face(parseInt(faceId)))
}
}
break;
case OB11MessageDataType.image:
case OB11MessageDataType.file:
case OB11MessageDataType.video:
case OB11MessageDataType.voice: {
const file = sendMsg.data?.file
const payloadFileName = sendMsg.data?.name
if (file) {
const {path, isLocal, fileName} = (await uri2local(file))
if (path) {
if (!isLocal) { // 只删除http和base64转过来的文件
deleteAfterSentFiles.push(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, false, payloadFileName || fileName));
} else if (sendMsg.type === OB11MessageDataType.video) {
log("发送视频", path, payloadFileName || fileName)
sendElements.push(await SendMsgElementConstructor.video(path, payloadFileName || fileName));
} else {
sendElements.push(await constructorMap[sendMsg.type](path));
}
}
}
}
break;
}
}
return {
sendElements,
deleteAfterSentFiles
}
}
private async send(peer: Peer, sendElements: SendMessageElement[], deleteAfterSentFiles: string[], waitComplete = false) {
if (!sendElements.length) {
throw ("消息体无法解析")
}
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

@@ -0,0 +1,8 @@
import SendMsg from "./SendMsg";
import {ActionName} from "./types";
class SendPrivateMsg extends SendMsg {
actionName = ActionName.SendPrivateMsg
}
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,31 @@
import BaseAction from "./BaseAction";
import {groupNotifies} from "../../common/data";
import {GroupNotify, 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();
const notify: GroupNotify = groupNotifies[seq]
try {
await NTQQApi.handleGroupRequest(seq,
payload.approve ? GroupRequestOperateTypes.approve : GroupRequestOperateTypes.reject,
payload.reason
)
} catch (e) {
throw e
}
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,78 @@
import GetMsg from './GetMsg'
import GetLoginInfo from './GetLoginInfo'
import GetFriendList from './GetFriendList'
import GetGroupList from './GetGroupList'
import GetGroupInfo from './GetGroupInfo'
import GetGroupMemberList from './GetGroupMemberList'
import GetGroupMemberInfo from './GetGroupMemberInfo'
import SendGroupMsg from './SendGroupMsg'
import SendPrivateMsg from './SendPrivateMsg'
import SendMsg from './SendMsg'
import DeleteMsg from "./DeleteMsg";
import BaseAction from "./BaseAction";
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";
export const actionHandlers = [
new Debug(),
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 SetGroupWholeBan(),
new SetGroupBan(),
new SetGroupKick(),
new SetGroupAdmin(),
new SetGroupName(),
new SetGroupCard(),
new GetImage(),
new GetRecord(),
//以下为go-cqhttp api
new GoCQHTTPSendGroupForwardMsg(),
new GoCQHTTPSendPrivateForwardMsg(),
new GoCQHTTPGetStrangerInfo(),
new GetGuildList(),
new GoCQHTTPMarkMsgAsRead(),
]
function initActionMap() {
const actionMap = new Map<string, BaseAction<any, any>>();
for (const action of actionHandlers) {
actionMap.set(action.actionName, action);
}
return actionMap
}
export const actionMap = initActionMap();

View File

@@ -0,0 +1,51 @@
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 {
Debug = "llonebot_debug",
SendLike = "send_like",
GetLoginInfo = "get_login_info",
GetFriendList = "get_friend_list",
GetGroupInfo = "get_group_info",
GetGroupList = "get_group_list",
GetGroupMemberInfo = "get_group_member_info",
GetGroupMemberList = "get_group_member_list",
GetMsg = "get_msg",
SendMsg = "send_msg",
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",
// 以下为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",
}

View File

@@ -0,0 +1,31 @@
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> {
return {
status: status,
retcode: retcode,
data: data,
message: message,
wording: message,
echo: null
}
}
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 error(err: string, retcode: number, echo: any = null) {
let res = OB11Response.res(null, "failed", retcode, err)
if (!isNull(echo)) {
res.echo = echo;
}
return res;
}
}

291
src/onebot11/constructor.ts Normal file
View File

@@ -0,0 +1,291 @@
import {
OB11Group,
OB11GroupMember,
OB11GroupMemberRole,
OB11Message,
OB11MessageData,
OB11MessageDataType,
OB11User,
OB11UserSex
} from "./types";
import {AtType, ChatType, Group, GroupMember, IMAGE_HTTP_HOST, RawMessage, SelfInfo, User} from '../ntqqapi/types';
import {fileCache, getFriend, getGroupMember, selfInfo, tempGroupCodeMap} from '../common/data';
import {getConfigUtil, log} from "../common/utils";
import {NTQQApi} from "../ntqqapi/ntcall";
import {EventType} from "./event/OB11BaseEvent";
import {encodeCQCode} from "./cqcode";
import {dbUtil} from "../common/db";
export class OB11Constructor {
static async message(msg: RawMessage): Promise<OB11Message> {
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) || Date.now(),
message_id: msg.msgShortId,
real_id: msg.msgId,
message_type: msg.chatType == ChatType.group ? "group" : "private",
sender: {
user_id: parseInt(msg.senderUin),
nickname: msg.sendNickName,
card: msg.sendMemberName || "",
},
raw_message: "",
font: 14,
sub_type: "friend",
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" // 这里go-cqhttp是group而onebot11标准是normal, 蛋疼
resMsg.group_id = parseInt(msg.peerUin)
const member = await getGroupMember(msg.peerUin, msg.senderUin);
if (member) {
resMsg.sender.role = OB11Constructor.groupMemberRole(member.role);
resMsg.sender.nickname = member.nick
}
} else if (msg.chatType == ChatType.friend) {
resMsg.sub_type = "friend"
const friend = await getFriend(msg.senderUin);
if (friend) {
resMsg.sender.nickname = friend.nick;
}
} 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) {
let message_data: OB11MessageData | any = {
data: {},
type: "unknown"
}
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"]["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)
if (atMember) {
atQQ = atMember.uin
}
}
if (atQQ) {
// message_data["data"]["mention"] = atQQ
message_data["data"]["qq"] = atQQ
}
}
} else if (element.textElement) {
message_data["type"] = "text"
let text = element.textElement.content
if (!text.trim()) {
continue;
}
message_data["data"]["text"] = text
} else if (element.replyElement) {
message_data["type"] = "reply"
log("收到回复消息", element.replyElement.replayMsgSeq)
try{
const replyMsg = await dbUtil.getMsgBySeqId(element.replyElement.replayMsgSeq)
if (replyMsg) {
message_data["data"]["id"] = replyMsg.msgShortId.toString()
} else {
continue
}
}catch (e) {
log("获取不到引用的消息", e.stack)
}
} 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
message_data["data"]["url"] = IMAGE_HTTP_HOST + element.picElement.originImageUrl
// message_data["data"]["file_id"] = element.picElement.fileUuid
message_data["data"]["file_size"] = element.picElement.fileSize
fileCache.set(element.picElement.fileName, {
fileName: element.picElement.fileName,
filePath: element.picElement.sourcePath,
fileSize: element.picElement.fileSize.toString(),
url: IMAGE_HTTP_HOST + element.picElement.originImageUrl,
downloadFunc: async () => {
await NTQQApi.downloadMedia(msg.msgId, msg.chatType, msg.peerUid,
element.elementId, element.picElement.thumbPath.get(0), element.picElement.sourcePath)
}
})
// 不在自动下载图片
} 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
fileCache.set(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)
}
})
// 怎么拿到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
fileCache.set(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)
}
})
// 怎么拿到url呢
} else if (element.pttElement) {
message_data["type"] = OB11MessageDataType.voice;
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
fileCache.set(element.pttElement.fileName, {
fileName: element.pttElement.fileName,
filePath: element.pttElement.filePath,
fileSize: element.pttElement.fileSize,
})
// log("收到语音消息", msg)
// window.LLAPI.Ptt2Text(message.raw.msgId, message.peer, messages).then(text => {
// console.log("语音转文字结果", text);
// }).catch(err => {
// console.log("语音转文字失败", err);
// })
} else if (element.arkElement) {
message_data["type"] = OB11MessageDataType.json;
message_data["data"]["data"] = element.arkElement.bytesData;
} else if (element.faceElement) {
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 { // 不使用本地路径
// 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 friend(friend: User): OB11User {
return {
user_id: parseInt(friend.uin),
nickname: friend.nick,
remark: friend.remark
}
}
static selfInfo(selfInfo: SelfInfo): OB11User {
return {
user_id: parseInt(selfInfo.uin),
nickname: selfInfo.nick
}
}
static friends(friends: User[]): OB11User[] {
return friends.map(OB11Constructor.friend)
}
static groupMemberRole(role: number): OB11GroupMemberRole | undefined {
return {
4: OB11GroupMemberRole.owner,
3: OB11GroupMemberRole.admin,
2: OB11GroupMemberRole.member
}[role]
}
static groupMember(group_id: string, member: GroupMember): OB11GroupMember {
return {
group_id: parseInt(group_id),
user_id: parseInt(member.uin),
nickname: member.nick,
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),
}
}
static groupMembers(group: Group): OB11GroupMember[] {
log("construct ob11 group members", group)
return group.members.map(m => OB11Constructor.groupMember(group.groupCode, m))
}
static group(group: Group): OB11Group {
return {
group_id: parseInt(group.groupCode),
group_name: group.groupName,
member_count: group.memberCount,
max_member_count: group.maxMember
}
}
static groups(groups: Group[]): OB11Group[] {
return groups.map(OB11Constructor.group)
}
}

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

@@ -0,0 +1,71 @@
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 CQCodeEscape = (text: string) => {
return text.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;')
.replace(/\&/g, '&amp;')
.replace(/,/g, '&#44;');
};
if (data.type === 'text') {
return CQCodeEscape(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

@@ -0,0 +1,16 @@
import {selfInfo} from "../../common/data";
export enum EventType {
META = "meta_event",
REQUEST = "request",
NOTICE = "notice",
MESSAGE = "message",
MESSAGE_SENT = "message_sent",
}
export abstract class OB11BaseEvent {
time = Math.floor(Date.now() / 1000);
self_id = parseInt(selfInfo.uin);
post_type: EventType;
}

View File

@@ -0,0 +1,5 @@
import {EventType, OB11BaseEvent} from "../OB11BaseEvent";
export abstract class OB11BaseMessageEvent extends OB11BaseEvent {
post_type = EventType.MESSAGE;
}

View File

@@ -0,0 +1,6 @@
import {EventType, OB11BaseEvent} from "../OB11BaseEvent";
export abstract class OB11BaseMetaEvent extends OB11BaseEvent {
post_type = EventType.META;
meta_event_type: string;
}

View File

@@ -0,0 +1,21 @@
import {OB11BaseMetaEvent} from "./OB11BaseMetaEvent";
interface HeartbeatStatus {
online: boolean | null,
good: boolean
}
export class OB11HeartbeatEvent extends OB11BaseMetaEvent {
meta_event_type = "heartbeat";
status: HeartbeatStatus;
interval: number;
public constructor(isOnline: boolean | null, isGood: boolean, interval: number) {
super();
this.interval = interval;
this.status = {
online: isOnline,
good: isGood
}
}
}

View File

@@ -0,0 +1,17 @@
import {OB11BaseMetaEvent} from "./OB11BaseMetaEvent";
export enum LifeCycleSubType {
ENABLE = "enable",
DISABLE = "disable",
CONNECT = "connect"
}
export class OB11LifeCycleEvent extends OB11BaseMetaEvent {
meta_event_type = "lifecycle";
sub_type: LifeCycleSubType;
public constructor(subType: LifeCycleSubType) {
super();
this.sub_type = subType;
}
}

View File

@@ -0,0 +1,5 @@
import {EventType, OB11BaseEvent} from "../OB11BaseEvent";
export abstract class OB11BaseNoticeEvent extends OB11BaseEvent {
post_type = EventType.NOTICE;
}

View File

@@ -0,0 +1,13 @@
import {OB11BaseNoticeEvent} from "./OB11BaseNoticeEvent";
export class OB11FriendRecallNoticeEvent extends OB11BaseNoticeEvent {
notice_type = "friend_recall"
user_id: number
message_id: number
public constructor(userId: number, messageId: number) {
super();
this.user_id = userId;
this.message_id = messageId;
}
}

View File

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

View File

@@ -0,0 +1,14 @@
import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
notice_type = "group_decrease";
sub_type: "leave" | "kick" | "kick_me" = "leave"; // TODO: 实现其他几种子类型的识别 ("leave" | "kick" | "kick_me")
operate_id: number;
constructor(groupId: number, userId: number) {
super();
this.group_id = groupId;
this.operate_id = userId; // 实际上不应该这么实现,但是现在还没有办法识别用户是被踢出的,还是自己主动退出的
this.user_id = userId;
}
}

View File

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

View File

@@ -0,0 +1,6 @@
import {OB11BaseNoticeEvent} from "./OB11BaseNoticeEvent";
export abstract class OB11GroupNoticeEvent extends OB11BaseNoticeEvent {
group_id: number;
user_id: number;
}

View File

@@ -0,0 +1,15 @@
import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export class OB11GroupRecallNoticeEvent extends OB11GroupNoticeEvent {
notice_type = "group_recall"
operator_id: number
message_id: number
constructor(groupId: number, userId: number, operatorId: number, messageId: number) {
super();
this.group_id = groupId;
this.user_id = userId;
this.operator_id = operatorId;
this.message_id = messageId;
}
}

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

@@ -0,0 +1,27 @@
import {Response} from "express";
import {getConfigUtil} from "../../common/utils";
import {OB11Response} from "../action/utils";
import {HttpServerBase} from "../../common/server/http";
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))
}
protected listen(port: number) {
if (getConfigUtil().getConfig().ob11.enableHttp) {
super.listen(port);
}
}
}
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))
}
}

View File

@@ -0,0 +1,57 @@
import {getConfigUtil, log} from "../../common/utils";
import {OB11Message} from "../types";
import {selfInfo} from "../../common/data";
import {OB11BaseMetaEvent} from "../event/meta/OB11BaseMetaEvent";
import {OB11BaseNoticeEvent} from "../event/notice/OB11BaseNoticeEvent";
import {WebSocket as WebSocketClass} from "ws";
import {wsReply} from "./ws/reply";
export type PostEventType = OB11Message | OB11BaseMetaEvent | OB11BaseNoticeEvent
const eventWSList: WebSocketClass[] = [];
export function registerWsEventSender(ws: WebSocketClass) {
eventWSList.push(ws);
}
export function unregisterWsEventSender(ws: WebSocketClass) {
let index = eventWSList.indexOf(ws);
if (index !== -1) {
eventWSList.splice(index, 1);
}
}
export function postWsEvent(event: PostEventType) {
for (const ws of eventWSList) {
new Promise(() => {
wsReply(ws, event);
}).then()
}
}
export function postOB11Event(msg: PostEventType, reportSelf = false) {
const config = getConfigUtil().getConfig();
// 判断msg是否是event
if (!config.reportSelfMessage && !reportSelf) {
if ((msg as OB11Message).user_id.toString() == selfInfo.uin) {
return
}
}
if (config.ob11.enableHttpPost) {
for (const host of config.ob11.httpHosts) {
fetch(host, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-self-id": selfInfo.uin
},
body: JSON.stringify(msg)
}).then((res: any) => {
log(`新消息事件HTTP上报成功: ${host} ` + JSON.stringify(msg));
}, (err: any) => {
log(`新消息事件HTTP上报失败: ${host} ` + err + JSON.stringify(msg));
});
}
}
postWsEvent(msg);
}

View File

@@ -0,0 +1,143 @@
import {getConfigUtil, log} from "../../../common/utils";
import {selfInfo} from "../../../common/data";
import {LifeCycleSubType, OB11LifeCycleEvent} from "../../event/meta/OB11LifeCycleEvent";
import {ActionName} from "../../action/types";
import {OB11Response} from "../../action/utils";
import BaseAction from "../../action/BaseAction";
import {actionMap} from "../../action";
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: WebSocketClass;
public url: string;
private running: boolean = false;
public constructor(url: string) {
this.url = url;
this.running = true;
this.connect();
}
public stop() {
this.running = false;
this.websocket.close();
}
public onopen() {
wsReply(this.websocket, new OB11LifeCycleEvent(LifeCycleSubType.CONNECT));
}
public async onmessage(msg: string) {
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, OB11Response.error("json解析失败请检查数据格式", 1400, echo))
}
const action: BaseAction<any, any> = actionMap.get(receiveData.action);
if (!action) {
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, OB11Response.error(`api处理出错:${e}`, 1200, echo))
}
}
public onclose = function () {
log("反向ws断开", this.url);
unregisterWsEventSender(this.websocket);
if (this.running) {
this.reconnect();
}
}
public send(msg: string) {
if (this.websocket && this.websocket.readyState == WebSocket.OPEN) {
this.websocket.send(msg);
}
}
private reconnect() {
setTimeout(() => {
this.connect();
}, 3000); // TODO: 重连间隔在配置文件中实现
}
private connect() {
const {token, heartInterval} = getConfigUtil().getConfig()
this.websocket = new WebSocketClass(this.url, {
handshakeTimeout: 2000,
perMessageDeflate: false,
headers: {
'X-Self-ID': selfInfo.uin,
'Authorization': `Bearer ${token}`,
'x-client-role': 'Universal', // koishi-adapter-onebot 需要这个字段
}
});
registerWsEventSender(this.websocket);
log("Trying to connect to the websocket server: " + this.url);
this.websocket.on("open", () => {
log("Connected to the websocket server: " + this.url);
this.onopen();
});
this.websocket.on("message", async (data) => {
await this.onmessage(data.toString());
});
this.websocket.on("error", log);
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();
});
}
}
class OB11ReverseWebsockets {
start() {
for (const url of getConfigUtil().getConfig().ob11.wsHosts) {
log("开始连接反向ws", url)
new Promise(() => {
try {
rwsList.push(new ReverseWebsocket(url));
} catch (e) {
log(e.stack);
}
}).then();
}
}
stop() {
for (let rws of rwsList) {
rws.stop();
}
}
restart() {
this.stop();
this.start();
}
}
export const ob11ReverseWebsockets = new OB11ReverseWebsockets();

View File

@@ -0,0 +1,74 @@
import {WebSocket} from "ws";
import {getConfigUtil, log} from "../../../common/utils";
import {actionMap} from "../../action";
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";
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(OB11Response.res(null, "failed", 1403, "token验证失败")))
}
async handleAction(wsClient: WebSocket, actionName: string, params: any, echo?: any) {
const action: BaseAction<any, any> = actionMap.get(actionName);
if (!action) {
return wsReply(wsClient, OB11Response.error("不支持的api " + actionName, 1404, echo))
}
try {
let handleResult = await action.websocketHandle(params, echo);
wsReply(wsClient, handleResult)
} catch (e) {
wsReply(wsClient, OB11Response.error(`api处理出错:${e}`, 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?: any } = {action: null, params: {}}
let echo = null
try {
receiveData = JSON.parse(msg.toString())
echo = receiveData.echo
log("收到正向Websocket消息", receiveData);
} catch (e) {
return wsReply(wsClient, OB11Response.error("json解析失败请检查数据格式", 1400, echo))
}
this.handleAction(wsClient, receiveData.action, receiveData.params, receiveData.echo).then()
})
}
if (url == "/event" || url == "/event/" || url == "/") {
registerWsEventSender(wsClient);
log("event上报ws客户端已连接")
try {
wsReply(wsClient, new OB11LifeCycleEvent(LifeCycleSubType.CONNECT))
} catch (e) {
log("发送生命周期失败", e)
}
const {heartInterval} = getConfigUtil().getConfig();
const wsClientInterval = setInterval(() => {
postWsEvent(new OB11HeartbeatEvent(selfInfo.online, true, heartInterval));
}, heartInterval); // 心跳包
wsClient.on("close", () => {
log("event上报ws客户端已断开")
clearInterval(wsClientInterval);
unregisterWsEventSender(wsClient);
})
}
}
}
export const ob11WebsocketServer = new OB11WebsocketServer()

View File

@@ -0,0 +1,17 @@
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: WebSocketClass, data: OB11Response | PostEventType) {
try {
let packet = Object.assign({}, data);
if (isNull(packet["echo"])) {
delete packet["echo"];
}
wsClient.send(JSON.stringify(packet))
log("ws 消息上报", wsClient.url || "", data);
} catch (e) {
log("websocket 回复失败", e.stack, data);
}
}

209
src/onebot11/types.ts Normal file
View File

@@ -0,0 +1,209 @@
import {RawMessage} from "../ntqqapi/types";
import {EventType} from "./event/OB11BaseEvent";
export interface OB11User {
user_id: number;
nickname: string;
remark?: string
}
export enum OB11UserSex {
male = "male",
female = "female",
unknown = "unknown"
}
export enum OB11GroupMemberRole {
owner = "owner",
admin = "admin",
member = "member",
}
export interface OB11GroupMember {
group_id: number
user_id: number
nickname: string
card?: string
sex?: OB11UserSex
age?: number
join_time?: number
last_sent_time?: number
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 {
group_id: number
group_name: string
member_count?: number
max_member_count?: number
}
interface OB11Sender {
user_id: number,
nickname: string,
sex?: OB11UserSex,
age?: number,
card?: string, // 群名片
level?: string, // 群等级
role?: OB11GroupMemberRole
}
export enum OB11MessageType {
private = "private",
group = "group"
}
export interface OB11Message {
self_id?: number,
time: number,
message_id: number,
real_id: string,
user_id: number,
group_id?: number,
message_type: "private" | "group",
sub_type?: "friend" | "group" | "normal",
sender: OB11Sender,
message: OB11MessageData[] | string,
message_format: 'array' | 'string',
raw_message: string,
font: number,
post_type?: EventType,
raw?: RawMessage
}
export interface OB11Return<DataType> {
status: string
retcode: number
data: DataType
message: 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", // 合并转发消息
}
export interface OB11MessageText {
type: OB11MessageDataType.text,
data: {
text: string, // 纯文本
}
}
interface OB11MessageFileBase {
data: {
name?: string;
file: string,
url?: string;
}
}
export interface OB11MessageImage extends OB11MessageFileBase {
type: OB11MessageDataType.image
}
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: {
qq: string | "all"
}
}
export interface OB11MessageReply {
type: OB11MessageDataType.reply
data: {
id: string
}
}
export interface OB11MessageFace {
type: OB11MessageDataType.face
data: {
id: string
}
}
export type OB11MessageMixType = OB11MessageData[] | string | OB11MessageData;
export interface OB11MessageNode {
type: OB11MessageDataType.node
data: {
id?: string
user_id?: number
nickname: string
content: OB11MessageMixType
}
}
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 | 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 {
app_name: "LLOneBot"
app_version: string
protocol_version: "v11"
}
export interface OB11Status {
online: boolean | null,
good: boolean
}

105
src/onebot11/utils.ts Normal file
View File

@@ -0,0 +1,105 @@
import {DATA_DIR, isGIF, log} from "../common/utils";
import {v4 as uuidv4} from "uuid";
import * as path from 'node:path';
import {fileCache} from "../common/data";
import * as fileType from 'file-type';
const fs = require("fs").promises;
export async function uri2local(uri: string, fileName: string = null) {
if (!fileName) {
fileName = uuidv4();
}
let filePath = path.join(DATA_DIR, fileName)
let url = new URL(uri);
let res = {
success: false,
errMsg: "",
fileName: "",
ext: "",
path: "",
isLocal: false
}
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)
if (!fetchRes.ok) {
res.errMsg = `${url}下载失败,` + fetchRes.statusText
return res
}
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 {
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 = fileCache.get(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";
// }
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
}

View File

@@ -1,78 +1,36 @@
// Electron 主进程 与 渲染进程 交互的桥梁
import {Config, Group, PostDataSendMsg, SelfInfo, SendMsgResult, User} from "./common/types";
import {Config, LLOneBotError} from "./common/types";
import {
CHANNEL_DOWNLOAD_FILE,
CHANNEL_ERROR,
CHANNEL_GET_CONFIG,
CHANNEL_SET_SELF_INFO,
CHANNEL_LOG,
CHANNEL_POST_ONEBOT_DATA,
CHANNEL_RECALL_MSG,
CHANNEL_SEND_MSG,
CHANNEL_SELECT_FILE,
CHANNEL_SET_CONFIG,
CHANNEL_START_HTTP_SERVER,
CHANNEL_UPDATE_FRIENDS,
CHANNEL_UPDATE_GROUPS,
CHANNEL_DELETE_FILE,
CHANNEL_GET_RUNNING_STATUS, CHANNEL_FILE2BASE64
} from "./common/IPCChannel";
} from "./common/channels";
const {contextBridge} = require("electron");
const {ipcRenderer} = require('electron');
// 在window对象下导出只读对象
contextBridge.exposeInMainWorld("llonebot", {
postData: (data: any) => {
ipcRenderer.send(CHANNEL_POST_ONEBOT_DATA, data);
},
updateGroups: (groups: Group[]) => {
ipcRenderer.send(CHANNEL_UPDATE_GROUPS, groups);
},
updateFriends: (friends: User[]) => {
ipcRenderer.send(CHANNEL_UPDATE_FRIENDS, friends);
},
sendSendMsgResult: (sessionId: string, msgResult: SendMsgResult)=>{
ipcRenderer.send(sessionId, msgResult);
},
listenSendMessage: (handle: (jsonData: PostDataSendMsg) => void) => {
ipcRenderer.send(CHANNEL_LOG, "发送消息API已注册");
ipcRenderer.on(CHANNEL_SEND_MSG, (event: any, args: PostDataSendMsg) => {
handle(args)
})
},
listenRecallMessage: (handle: (jsonData: {message_id: string}) => void) => {
ipcRenderer.on(CHANNEL_RECALL_MSG, (event: any, args: {message_id: string}) => {
handle(args)
})
},
startExpress: () => {
ipcRenderer.send(CHANNEL_START_HTTP_SERVER);
},
const llonebot = {
log: (data: any) => {
ipcRenderer.send(CHANNEL_LOG, data);
},
setConfig: (config: Config)=>{
setConfig: (config: Config) => {
ipcRenderer.send(CHANNEL_SET_CONFIG, config);
},
getConfig: async () => {
getConfig: async (): Promise<Config> => {
return ipcRenderer.invoke(CHANNEL_GET_CONFIG);
},
setSelfInfo(selfInfo: SelfInfo){
ipcRenderer.invoke(CHANNEL_SET_SELF_INFO, selfInfo)
getError: async (): Promise<LLOneBotError> => {
return ipcRenderer.invoke(CHANNEL_ERROR);
},
downloadFile: (arg: {uri: string, localFilePath: string}) => {
return ipcRenderer.invoke(CHANNEL_DOWNLOAD_FILE, arg);
},
deleteFile: async (localFilePath: string[]) => {
ipcRenderer.send(CHANNEL_DELETE_FILE, localFilePath);
},
getRunningStatus: () => {
return ipcRenderer.invoke(CHANNEL_GET_RUNNING_STATUS);
},
file2base64: (localFilePath: string) => {
return ipcRenderer.invoke(CHANNEL_FILE2BASE64, localFilePath);
selectFile: (): Promise<string> => {
return ipcRenderer.invoke(CHANNEL_SELECT_FILE);
}
// startExpress,
});
}
export type LLOneBot = typeof llonebot;
// 在window对象下导出只读对象
contextBridge.exposeInMainWorld("llonebot", llonebot);

View File

@@ -1,701 +0,0 @@
/// <reference path="./global.d.ts" />
// import express from "express";
// const { ipcRenderer } = require('electron');
import {
AtType,
ChatType,
Group,
MessageElement, MessageType,
OnebotGroupMemberRole,
Peer,
PostDataSendMsg,
SendMsgResult,
User
} from "./common/types";
import {checkFileReceived} from "./main/utils";
let self_qq: string = ""
let groups: Group[] = []
let friends: User[] = []
let msgHistory: MessageElement[] = []
let uid_maps: Record<string, User> = {} // 一串加密的字符串 -> qq号
function getStrangerByUin(uin: string) {
for (const key in uid_maps) {
if (uid_maps[key].uin === uin) {
return uid_maps[key];
}
}
}
async function getUserInfo(uid: string): Promise<User> {
let user = uid_maps[uid]
if (!user) {
// 从服务器获取用户信息
user = await window.LLAPI.getUserInfo(uid)
uid_maps[uid] = user
}
return user
}
async function getFriends() {
let _friends = await window.LLAPI.getFriendsList(false)
for (let friend of _friends) {
let existFriend = friends.find(f => f.uin == friend.uin)
if (!existFriend) {
friends.push(friend)
}
}
window.llonebot.updateFriends(friends)
return friends
}
async function getFriend(qq: string) {
let friend = friends.find(friend => friend.uin == qq)
if (!friend) {
await getFriends();
friend = friends.find(friend => friend.uin == qq);
}
return friend;
}
async function getGroup(qq: string) {
let group = groups.find(group => group.uid == qq)
if (!group) {
await getGroups();
group = groups.find(group => group.uid == qq)
}
return group
}
async function getGroups() {
let __groups = await window.LLAPI.getGroupsList(false)
for (let group of __groups) {
group.members = [];
let existGroup = groups.find(g => g.uid == group.uid)
if (!existGroup) {
// console.log("更新群列表", groups)
groups.push(group)
}
}
window.llonebot.updateGroups(groups)
return groups
}
async function getGroupMembers(group_qq: string, forced: boolean = false) {
let group = await getGroup(group_qq)
if (!group?.members || group!.members!.length == 0 || forced) {
let res = (await window.LLAPI.getGroupMemberList(group_qq, 5000))
// console.log(`更新群${group}成员列表 await`, _res)
// window.LLAPI.getGroupMemberList(group_qq + "_groupMemberList_MainWindow", 5000).then(res =>{
let members = res.result.infos.values();
console.log("getGroupMemberList api response", res)
if (members && forced) {
group.members = []
}
for (const member of members) {
if (!group!.members!.find(m => m.uid == member.uid)) {
group!.members!.push(member)
}
uid_maps[member.uid] = {
uin: member.uin,
uid: member.uid,
nickName: member.nick
};
}
window.llonebot.updateGroups(groups)
console.log(`更新群${group.name}成员列表`, group)
// })
}
return group?.members
}
async function getGroupMember(group_qq: string, member_uid: string) {
let members = await getGroupMembers(group_qq)
if (members) {
let member = members.find(member => member.uid == member_uid)
if (!member) {
members = await getGroupMembers(group_qq, true)
member = members?.find(member => member.uid == member_uid)
}
return member
}
}
async function handleNewMessage(messages: MessageElement[]) {
console.log("llonebot 收到消息:", messages);
const {debug, enableBase64} = await window.llonebot.getConfig();
for (let message of messages) {
let onebot_message_data: any = {
self: {
platform: "qq",
user_id: self_qq
},
self_id: self_qq,
time: parseInt(message.raw.msgTime || "0"),
type: "message",
post_type: "message",
message_type: message.peer.chatType,
detail_type: message.peer.chatType,
message_id: message.raw.msgId,
sub_type: "",
message: [],
raw_message: "",
font: 14
}
if (debug) {
onebot_message_data.raw = JSON.parse(JSON.stringify(message))
}
if (message.raw.chatType == ChatType.group) {
let group_id = message.peer.uid
let group = (await getGroup(group_id))!
onebot_message_data.detail_type = onebot_message_data.message_type = onebot_message_data.sub_type = "group"
onebot_message_data["group_id"] = message.peer.uid
let groupMember = await getGroupMember(group_id, message.sender.uid)
onebot_message_data["user_id"] = groupMember!.uin
onebot_message_data.sender = {
user_id: groupMember!.uin,
nickname: groupMember!.nick,
card: groupMember!.cardName,
role: OnebotGroupMemberRole[groupMember!.role]
}
// console.log("收到群消息", onebot_message_data)
} else if (message.raw.chatType == ChatType.friend) {
onebot_message_data["user_id"] = message.raw.senderUin;
onebot_message_data.detail_type = onebot_message_data.message_type = "private"
onebot_message_data.sub_type = "friend"
let friend = await getFriend(message.raw.senderUin);
onebot_message_data.sender = {
user_id: friend!.uin,
nickname: friend!.nickName
}
} else if (message.raw.chatType == ChatType.temp) {
let senderQQ = message.raw.senderUin;
let senderUid = message.sender.uid;
let sender = await getUserInfo(senderUid);
onebot_message_data["user_id"] = senderQQ;
onebot_message_data.detail_type = onebot_message_data.message_type = "private"
onebot_message_data.sub_type = "group";
onebot_message_data.sender = {
user_id: senderQQ,
nickname: sender.nickName
}
}
for (let element of message.raw.elements) {
let message_data: any = {
data: {},
type: "unknown"
}
if (element.textElement && element.textElement?.atType !== AtType.notAt) {
message_data["type"] = "at"
if (element.textElement.atType == AtType.atAll) {
message_data["data"]["mention"] = "all"
message_data["data"]["qq"] = "all"
} else {
let uid = element.textElement.atNtUid
let atMember = await getGroupMember(message.peer.uid, uid)
message_data["data"]["mention"] = atMember!.uin
message_data["data"]["qq"] = atMember!.uin
}
} else if (element.textElement) {
message_data["type"] = "text"
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
} else if (element.replyElement) {
message_data["type"] = "reply"
message_data["data"]["id"] = msgHistory.find(msg => msg.raw.msgSeq == element.replyElement.replayMsgSeq)?.raw.msgId
} else if (element.pttElement) {
message_data["type"] = MessageType.voice;
message_data["data"]["file"] = element.pttElement.filePath
message_data["data"]["file_id"] = element.pttElement.fileUuid
// console.log("收到语音消息", message.raw.msgId, message.peer, element.pttElement)
// window.LLAPI.Ptt2Text(message.raw.msgId, message.peer, messages).then(text => {
// console.log("语音转文字结果", text);
// }).catch(err => {
// console.log("语音转文字失败", err);
// })
} else if (element.arkElement) {
message_data["type"] = MessageType.json;
message_data["data"]["data"] = element.arkElement.bytesData;
}
if (message_data.data.file) {
let filePath: string = message_data.data.file;
message_data.data.file = "file://" + filePath
if (enableBase64) {
// filePath = filePath.replace("\\Ori\\", "\\Thumb\\")
let {err, data} = await window.llonebot.file2base64(filePath);
if (err) {
console.log("文件转base64失败", err)
} else {
message_data.data.file = "base64://" + data
}
}
}
onebot_message_data.message.push(message_data)
}
if (msgHistory.length > 10000) {
msgHistory.splice(0, 100)
}
msgHistory.push(message)
console.log("发送上传消息给ipc main", onebot_message_data)
window.llonebot.postData(onebot_message_data);
}
}
async function listenSendMessage(postData: PostDataSendMsg) {
console.log("收到发送消息请求", postData);
let sendMsgResult: SendMsgResult = {
retcode: 0,
status: 0,
data: {},
message: "发送成功"
}
if (postData.action == "send_private_msg" || postData.action == "send_group_msg") {
let peer: Peer | null = null;
if (!postData.params) {
postData.params = {
message: postData.message,
user_id: postData.user_id,
group_id: postData.group_id
}
}
if (postData.action == "send_private_msg") {
let friend = await getFriend(postData.params.user_id)
if (friend) {
console.log("好友消息", postData)
peer = {
chatType: ChatType.friend,
name: friend.nickName,
uid: friend.uid
}
} else {
// 临时消息
console.log("发送临时消息", postData)
let receiver = getStrangerByUin(postData.params.user_id);
if (receiver) {
peer = {
chatType: ChatType.temp,
name: receiver.nickName,
uid: receiver.uid
}
} else {
sendMsgResult.status = -1;
sendMsgResult.retcode = -1;
sendMsgResult.message = `发送失败,未找到对象${postData.params.user_id},检查他是否为好友或是群友`;
}
}
} else if (postData.action == "send_group_msg") {
let group = await getGroup(postData.params.group_id)
if (group) {
peer = {
chatType: ChatType.group,
name: group.name,
uid: group.uid
}
} else {
sendMsgResult.status = -1;
sendMsgResult.retcode = -1;
sendMsgResult.message = `发送失败,未找到群${postData.params.group_id}`;
console.log("未找到群, 发送群消息失败", postData)
}
}
if (peer) {
let sendFiles: string[] = [];
for (let message of postData.params.message) {
if (message.type == "at") {
// @ts-ignore
message.type = "text"
message.atType = AtType.atUser
let atUid = message.data?.qq || message.atUid
if (atUid == "all") {
message.atType = AtType.atAll
atUid = "0";
message.content = `@全体成员`
}
else {
let group = await getGroup(postData.params.group_id)
let atMember = group.members.find(member => member.uin == atUid)
message.atNtUid = atMember.uid
message.atUid = atUid
message.content = `@${atMember.cardName || atMember.nick}`
}
} else if (message.type == "text") {
message.content = message.data?.text || message.content
} else if (message.type == "image" || message.type == "voice" || message.type == "record") {
// 收到的是uri格式的需要转成本地的, uri格式有三种http, file, base64
let url = message.data?.file || message.file
let uri = new URL(url);
let ext: string;
if (message.type == "image") {
ext = ".png"
}
if (message.type == "voice" || message.type == "record") {
message.type = "voice"
ext = ".amr"
}
let localFilePath = `${Date.now()}${ext}`
if (uri.protocol == "file:") {
localFilePath = url.split("file://")[1]
} else {
const {errMsg, path} = await window.llonebot.downloadFile({
uri: url,
fileName: `${Date.now()}${ext}`
})
console.log("下载文件结果", errMsg, path)
if (errMsg) {
console.log("下载文件失败", errMsg);
sendMsgResult.status = -1;
sendMsgResult.retcode = -1;
sendMsgResult.message = `发送失败,下载文件失败,${errMsg}`;
break;
} else {
localFilePath = path;
}
}
message.file = localFilePath
sendFiles.push(localFilePath);
} else if (message.type == "reply") {
let msgId = message.data?.id || message.msgId
let replyMessage = msgHistory.find(msg => msg.raw.msgId == msgId)
message.msgId = msgId
message.msgSeq = replyMessage?.raw.msgSeq || ""
}
}
console.log("发送消息", postData)
if (sendMsgResult.status !== 0) {
window.llonebot.sendSendMsgResult(postData.ipc_uuid, sendMsgResult)
return;
}
window.LLAPI.sendMessage(peer, postData.params.message).then(res => {
console.log("消息发送成功:", peer, postData.params.message)
if (sendFiles.length) {
window.llonebot.deleteFile(sendFiles);
}
window.llonebot.sendSendMsgResult(postData.ipc_uuid, sendMsgResult)
},
err => {
sendMsgResult.status = -1;
sendMsgResult.retcode = -1;
sendMsgResult.message = `发送失败,${err}`;
window.llonebot.sendSendMsgResult(postData.ipc_uuid, sendMsgResult)
console.log("消息发送失败", postData, err)
})
} else {
console.log(sendMsgResult, postData);
window.llonebot.sendSendMsgResult(postData.ipc_uuid, sendMsgResult)
}
}
}
function recallMessage(msgId: string) {
let msg = msgHistory.find(msg => msg.raw.msgId == msgId)
window.LLAPI.recallMessage(msg.peer, [msgId]).then()
}
let chatListEle: HTMLCollectionOf<Element>
async function getGroupsMembers(groupsArg: Group[]) {
// 批量获取群成员列表
let failedGroups: Group[] = []
for (const group of groupsArg) {
let handledGroup = await getGroupMembers(group.uid, true)
if (handledGroup.length == 0) {
failedGroups.push(group)
}
}
if (failedGroups.length > 0) {
console.log("获取群成员列表失败,重试", failedGroups.map(group => group.name))
setTimeout(() => {
getGroupsMembers(failedGroups).then()
}, 1000)
} else {
window.llonebot.log("全部群成员获取完毕")
}
}
function onNewMessages(messages: MessageElement[]) {
async function func(messages: MessageElement[]) {
console.log("收到新消息", messages)
if (!self_qq) {
self_qq = (await window.LLAPI.getAccountInfo()).uin
}
await handleNewMessage(messages);
}
func(messages).then(() => {
})
// console.log("chatListEle", chatListEle)
}
async function initAccountInfo() {
let accountInfo = await window.LLAPI.getAccountInfo();
window.llonebot.log("getAccountInfo " + JSON.stringify(accountInfo));
if (!accountInfo.uid) {
return false;
}
let selfInfo = await window.LLAPI.getUserInfo(accountInfo.uid);
window.llonebot.setSelfInfo({
user_id: accountInfo.uin,
nickname: selfInfo.nickName
});
window.llonebot.log("selfInfo " + JSON.stringify(selfInfo));
return true;
}
function onLoad() {
window.llonebot.log("llonebot render onLoad");
window.llonebot.getRunningStatus().then(running => {
if (running) {
return;
}
initAccountInfo().then(
(initSuccess) => {
if (!initSuccess) {
return;
}
if (friends.length == 0) {
getFriends().then(() => {
});
}
if (groups.length == 0) {
getGroups().then(() => {
getGroupsMembers(groups).then(() => {
});
});
}
window.LLAPI.on("new-messages", onNewMessages);
window.LLAPI.on("new-send-messages", onNewMessages);
window.llonebot.log("llonebot render start");
window.llonebot.startExpress();
window.llonebot.listenSendMessage((postData: PostDataSendMsg) => {
listenSendMessage(postData).then().catch(err => console.log("listenSendMessage err", err))
})
window.llonebot.listenRecallMessage((arg: { message_id: string }) => {
// console.log("listenRecallMessage", arg)
recallMessage(arg.message_id)
})
window.llonebot.log("llonebot loaded");
// window.LLAPI.add_qmenu((qContextMenu: Node) => {
// let btn = document.createElement("a")
// btn.className = "q-context-menu-item q-context-menu-item--normal vue-component"
// btn.setAttribute("aria-disabled", "false")
// btn.setAttribute("role", "menuitem")
// btn.setAttribute("tabindex", "-1")
// btn.onclick = () => {
// // window.LLAPI.getPeer().then(peer => {
// // // console.log("current peer", peer)
// // if (peer && peer.chatType == "group") {
// // getGroupMembers(peer.uid, true).then(()=> {
// // console.log("获取群成员列表成功", groups);
// // alert("获取群成员列表成功")
// // })
// // }
// // })
// async function func() {
// for (const group of groups) {
// await getGroupMembers(group.uid, true)
// }
// }
//
// func().then(() => {
// console.log("获取群成员列表结果", groups);
// // 找到members数量为空的群
// groups.map(group => {
// if (group.members.length == 0) {
// console.log(`${group.name}群成员为空`)
// }
// })
// window.llonebot.updateGroups(groups)
// })
// }
// btn.innerText = "获取群成员列表"
// console.log(qContextMenu)
// // qContextMenu.appendChild(btn)
// })
//
// window.LLAPI.on("context-msg-menu", (event, target, msgIds) => {
// console.log("msg menu", event, target, msgIds);
// })
//
// // console.log("getAccountInfo", LLAPI.getAccountInfo());
// function getChatListEle() {
// chatListEle = document.getElementsByClassName("viewport-list__inner")
// console.log("chatListEle", chatListEle)
// if (chatListEle.length == 0) {
// setTimeout(getChatListEle, 500)
// } else {
// try {
// // 选择要观察的目标节点
// const targetNode = chatListEle[0];
//
// // 创建一个观察器实例并传入回调函数
// const observer = new MutationObserver(function (mutations) {
// mutations.forEach(function (mutation) {
// // console.log("chat list changed", mutation.type); // 输出 mutation 的类型
// // 获得当前聊天窗口
// window.LLAPI.getPeer().then(peer => {
// // console.log("current peer", peer)
// if (peer && peer.chatType == "group") {
// getGroupMembers(peer.uid, false).then()
// }
// })
// });
// });
//
// // 配置观察选项
// const config = {attributes: true, childList: true, subtree: true};
//
// // 传入目标节点和观察选项
// observer.observe(targetNode, config);
//
// } catch (e) {
// window.llonebot.log(e)
// }
// }
// }
//
// // getChatListEle();
}
);
});
}
// 打开设置界面时触发
async function onSettingWindowCreated(view: Element) {
window.llonebot.log("setting window created");
let config = await window.llonebot.getConfig()
function creatHostEleStr(host: string) {
let eleStr = `
<setting-item data-direction="row" class="hostItem vertical-list-item">
<h2>事件上报地址(http)</h2>
<input class="host input-text" type="text" value="${host}"
style="width:60%;padding: 5px"
placeholder="如果localhost上报失败试试局域网ip"/>
</setting-item>
`
return eleStr
}
let hostsEleStr = ""
for (const host of config.hosts) {
hostsEleStr += creatHostEleStr(host);
}
let html = `
<div class="config_view">
<setting-section>
<setting-panel>
<setting-list class="wrap">
<setting-item class="vertical-list-item" data-direction="row">
<setting-text>监听端口</setting-text>
<input id="port" type="number" value="${config.port}"/>
</setting-item>
<div>
<button id="addHost" class="q-button">添加上报地址</button>
</div>
<div id="hostItems">
${hostsEleStr}
</div>
<button id="save" class="q-button">保存(监听端口重启QQ后生效)</button>
</setting-list>
</setting-panel>
<setting-panel>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<div>
<div>上报文件进行base64编码</div>
<div class="tips">不开启时,上报文件将以本地路径形式发送</div>
</div>
<setting-switch id="switchBase64" ${config.enableBase64 ? "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-panel>
</setting-section>
</div>
<style>
setting-panel {
padding: 10px;
}
.tips {
font-size: 0.75rem;
}
</style>
`
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
function addHostEle(initValue: string = "") {
let addressDoc = parser.parseFromString(creatHostEleStr(initValue), "text/html");
let addressEle = addressDoc.querySelector("setting-item")
let hostItemsEle = document.getElementById("hostItems");
hostItemsEle.appendChild(addressEle);
}
doc.getElementById("addHost").addEventListener("click", () => addHostEle())
function switchClick(eleId: string, configKey: string) {
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", "")
}
window.llonebot.setConfig(config)
})
}
switchClick("debug", "debug");
switchClick("switchBase64", "enableBase64");
doc.getElementById("save")?.addEventListener("click",
() => {
const portEle: HTMLInputElement = document.getElementById("port") as HTMLInputElement
const hostEles: HTMLCollectionOf<HTMLInputElement> = document.getElementsByClassName("host") as HTMLCollectionOf<HTMLInputElement>;
// const port = doc.querySelector("input[type=number]")?.value
// const host = doc.querySelector("input[type=text]")?.value
// 获取端口和host
const port = portEle.value
let hosts: string[] = [];
for (const hostEle of hostEles) {
if (hostEle.value) {
hosts.push(hostEle.value);
}
}
config.port = parseInt(port);
config.hosts = hosts;
window.llonebot.setConfig(config);
alert("保存成功");
})
doc.body.childNodes.forEach(node => {
view.appendChild(node);
});
}
setTimeout(onLoad, 5000)
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>`;
}

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