Compare commits

...

126 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
a98ce843ef chore:Optimized GitHub issue template 2024-02-07 18:13:36 +08:00
90 changed files with 7641 additions and 4288 deletions

View File

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

2
.gitignore vendored
View File

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

21
LICENSE Normal file
View File

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

134
README.md
View File

@@ -1,26 +1,56 @@
# LLOneBot API
# LLOneBot API
LiteLoaderQQNT的OneBot11协议插件
TG群<https://t.me/+nLZEnpne-pQ1OWFl>
*注意:本文档对应的是 LiteLoader 1.0.0及以上版本如果你使用的是旧版本请切换到本项目v1分支查看文档*
*V3之后不再需要LLAPI*
## 安装方法
### 通用手动安装方法
1.安装[LiteLoaderQQNT](https://liteloaderqqnt.github.io/guide/install.html)
2.安装本项目插件[OneBotApi](https://github.com/linyuchen/LiteLoaderQQNT-OneBotApi/releases/), 注意本插件2.0以下的版本不支持LiteLoader 1.0.0及以上版本
2.安装本项目插件[LLOneBot](https://github.com/linyuchen/LiteLoaderQQNT-OneBotApi/releases/), 注意本插件2.0以下的版本不支持LiteLoader 1.0.0及以上版本
*关于插件的安装方法: 下载后解压复制到插件目录*
*插件目录:`LiteLoaderQQNT/plugins`*
## 支持的API
安装后的目录结构如下
```
├── plugins
│ ├── LLOneBot
│ │ └── main/
│ │ └── preload/
│ │ └── renderer/
│ │ └── manifest.json
│ │ └── node_modules/...
```
### 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] http事件上报,不支持快捷回复等快捷操作
- [x] 正向websocket
- [x] 反向websocket
@@ -31,9 +61,20 @@ LiteLoaderQQNT的OneBot11协议插件
- [x] 获取群列表
- [x] 获取群成员列表
- [x] 撤回消息
- [x] 处理添加好友请求
- [x] 处理加群请求
- [x] 退群
- [x] 上报好友消息
- [x] 上报添加好友请求
- [x] 上报群消息
- [x] 上报好友、群消息撤回
- [x] 上报加群请求
- [x] 上报群员人数变动(尚不支持识别群员人数变动原因)
- [x] 设置群管理员
- [x] 群禁言/全体禁言
- [x] 群踢人
- [x] 群改群成员名片
- [x] 修改群名
消息格式支持:
- [x] cq码
@@ -45,29 +86,20 @@ LiteLoaderQQNT的OneBot11协议插件
- [x] 语音(支持mp3、wav等多种音频格式直接发送)
- [x] json消息(只上报)
- [x] 转发消息记录(目前只能发不能收)
- [ ] 红包
- [ ] xml
支持的api:
- [x] get_login_info
- [x] send_msg
- [x] send_group_msg
- [x] send_private_msg
- [x] delete_msg
- [x] get_group_list
- [x] get_group_info
- [x] get_group_member_list
- [x] get_group_member_info
- [x] get_friend_list
- [x] get_msg
- [x] get_version_info
- [x] get_status
- [x] can_send_image
- [x] can_send_record
支持的go-cqhtp api:
- [x] send_private_forward_msg
- [x] send_group_forward_msg
- [x] 视频(上报时暂时只有个空的file)
- [x] 文件(上报时暂时只有个空的file), type为file, data为{file: uri}, 发送时uri支持http://, file://, base64://
```
{
"type": "file",
"data": {
"file": "file:///D:/1.txt",
"name": "自定义显示的文件名" // 此字段不是必须的
}
}
```
- [ ] 发送音乐卡片
- [ ] 红包(没有计划支持)
- [ ] xml (没有计划支持)
## 示例
@@ -86,6 +118,7 @@ LiteLoaderQQNT的OneBot11协议插件
<summary>调用接口报404</summary>
<br/>
目前没有支持全部的onebot规范接口请检查是否调用了不支持的接口
-
</details>
<br/>
@@ -103,12 +136,57 @@ LiteLoaderQQNT的OneBot11协议插件
</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
### 支持的go-cqhtp api:
- [x] send_private_forward_msg
- [x] send_group_forward_msg
- [x] get_stranger_info
## TODO
- [x] 重构摆脱LLAPI目前调用LLAPI只能在renderer进程调用需重构成在main进程调用
- [x] 支持正、反向websocket感谢@disymayufei的PR
- [x] 转发消息记录
- [ ] 好友点赞api
- [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,10 +1,10 @@
{
"manifest_version": 4,
"type": "extension",
"name": "LLOneBot",
"name": "LLOneBot v3.13.2",
"slug": "LLOneBot",
"description": "LiteLoaderQQNT的OneBotApi",
"version": "3.5.0",
"version": "3.13.2",
"thumbnail": "./icon.png",
"authors": [
{
@@ -26,8 +26,8 @@
"darwin"
],
"injects": {
"renderer": "./renderer.js",
"main": "./main.js",
"preload": "./preload.js"
"renderer": "./renderer/index.js",
"main": "./main/main.cjs",
"preload": "./preload/preload.cjs"
}
}
}

7656
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
import express, {Express, Request, Response} from "express";
import express, {Express, json, Request, Response} from "express";
import {getConfigUtil, log} from "../utils";
import http from "http";
const JSONbig = require('json-bigint')({storeAsString: true});
type RegisterHandler = (res: Response, payload: any) => Promise<any>
export abstract class HttpServerBase {
@@ -14,23 +12,7 @@ export abstract class HttpServerBase {
constructor() {
this.expressAPP = express();
this.expressAPP.use(express.urlencoded({extended: true, limit: "500mb"}));
this.expressAPP.use((req, res, next) => {
let data = '';
req.on('data', chunk => {
data += chunk.toString();
});
req.on('end', () => {
if (data) {
try {
// log("receive raw", data)
req.body = JSONbig.parse(data);
} catch (e) {
return next(e);
}
}
next();
});
});
this.expressAPP.use(json({limit: "500mb"}));
}
authorize(req: Request, res: Response, next: () => void) {
@@ -63,13 +45,13 @@ export abstract class HttpServerBase {
}
stop() {
if (this.server){
if (this.server) {
this.server.close()
this.server = null;
}
}
restart(port: number){
restart(port: number) {
this.stop()
this.start(port)
}
@@ -81,20 +63,20 @@ export abstract class HttpServerBase {
url = "/" + url
}
if (!this.expressAPP[method]){
if (!this.expressAPP[method]) {
const err = `${this.name} register router failed${method} not exist`;
log(err);
throw err;
}
this.expressAPP[method](url, this.authorize, async (req: Request, res: Response) => {
let payload = req.body;
if (method == "get"){
if (method == "get") {
payload = req.query
}
log("收到http请求", url, payload);
try{
try {
res.send(await handler(res, payload))
}catch (e) {
} catch (e) {
this.handleFailed(res, payload, e.stack.toString())
}
});

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
import * as path from "path";
import * as path from "node:path";
import {selfInfo} from "./data";
import {ConfigUtil} from "./config";
import util from "util";
import {encode, getDuration} from "silk-wasm";
import {encode, getDuration, isWav} from "silk-wasm";
import fs from 'fs';
import {v4 as uuidv4} from "uuid";
import ffmpeg from "fluent-ffmpeg"
export const CONFIG_DIR = global.LiteLoader.plugins["LLOneBot"].path.data;
export const DATA_DIR = global.LiteLoader.plugins["LLOneBot"].path.data;
export function getConfigUtil() {
const configFilePath = path.join(CONFIG_DIR, `config_${selfInfo.uin}.json`)
const configFilePath = path.join(DATA_DIR, `config_${selfInfo.uin}.json`)
return new ConfigUtil(configFilePath)
}
@@ -32,7 +33,7 @@ function truncateString(obj: any, maxLength = 500) {
export function log(...msg: any[]) {
if (!getConfigUtil().getConfig().log) {
return
return //console.log(...msg);
}
let currentDateTime = new Date().toLocaleString();
const date = new Date();
@@ -54,7 +55,7 @@ export function log(...msg: any[]) {
logMsg = `${currentDateTime} ${userInfo}: ${logMsg}\n\n`
// sendLog(...msg);
// console.log(msg)
fs.appendFile(path.join(CONFIG_DIR, `llonebot-${currentDate}.log`), logMsg, (err: any) => {
fs.appendFile(path.join(DATA_DIR, `llonebot-${currentDate}.log`), logMsg, (err: any) => {
})
}
@@ -136,7 +137,26 @@ export function mergeNewProperties(newObj: any, oldObj: any) {
});
}
export function checkFfmpeg(newPath: string = null): Promise<boolean> {
return new Promise((resolve, reject) => {
if (newPath) {
ffmpeg.setFfmpegPath(newPath);
ffmpeg.getAvailableFormats((err, formats) => {
if (err) {
log('ffmpeg is not installed or not found in PATH:', err);
resolve(false)
} else {
log('ffmpeg is installed.');
resolve(true);
}
})
}
});
}
export async function encodeSilk(filePath: string) {
const fsp = require("fs").promises
function getFileHeader(filePath: string) {
// 定义要读取的字节数
const bytesToRead = 7;
@@ -154,35 +174,76 @@ export async function encodeSilk(filePath: string) {
}
}
async function getAudioSampleRate(filePath: string) {
try {
const mm = await import('music-metadata');
const metadata = await mm.parseFile(filePath);
log(`${filePath}采样率`, metadata.format.sampleRate);
return metadata.format.sampleRate;
} catch (error) {
log(`${filePath}采样率获取失败`, error.stack);
// console.error(error);
}
async function isWavFile(filePath: string) {
return isWav(fs.readFileSync(filePath));
}
// async function getAudioSampleRate(filePath: string) {
// try {
// const mm = await import('music-metadata');
// const metadata = await mm.parseFile(filePath);
// log(`${filePath}采样率`, metadata.format.sampleRate);
// return metadata.format.sampleRate;
// } catch (error) {
// log(`${filePath}采样率获取失败`, error.stack);
// // console.error(error);
// }
// }
try {
const fileName = path.basename(filePath);
const pcm = fs.readFileSync(filePath);
const pttPath = path.join(CONFIG_DIR, uuidv4());
const pttPath = path.join(DATA_DIR, uuidv4());
if (getFileHeader(filePath) !== "02232153494c4b") {
log(`语音文件${filePath}需要转换`)
const sampleRate = await getAudioSampleRate(filePath) || 44100;
const silk = await encode(pcm, sampleRate);
log(`语音文件${filePath}需要转换成silk`)
const _isWav = await isWavFile(filePath);
const wavPath = pttPath + ".wav"
if (!_isWav) {
log(`语音文件${filePath}正在转换成wav`)
// let voiceData = await fsp.readFile(filePath)
await new Promise((resolve, reject) => {
const ffmpegPath = getConfigUtil().getConfig().ffmpeg;
if (ffmpegPath) {
ffmpeg.setFfmpegPath(ffmpegPath);
}
ffmpeg(filePath).toFormat("wav").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);
log(`语音文件${filePath}转换成功!`)
fs.unlink(wavPath, (err) => {
});
log(`语音文件${filePath}转换成功!`, pttPath)
return {
converted: true,
path: pttPath,
duration: silk.duration,
};
} else {
const duration = getDuration(pcm);
const pcm = fs.readFileSync(filePath);
let duration = 0;
try{
duration = getDuration(pcm);
}catch (e) {
log("获取语音文件时长失败", filePath, e.stack)
duration = fs.statSync(filePath).size / 1024 / 3 // 每3kb大约1s
duration = Math.floor(duration)
duration = Math.max(1, duration)
log("使用文件大小估算时长", duration)
}
return {
converted: false,
path: filePath,
@@ -193,4 +254,8 @@ export async function encodeSilk(filePath: string) {
log("convert silk failed", error.stack);
return {};
}
}
export function isNull(value: any) {
return value === undefined || value === null;
}

14
src/global.d.ts vendored
View File

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

View File

@@ -1,22 +1,47 @@
// 运行在 Electron 主进程 下的插件入口
import {BrowserWindow, ipcMain} from 'electron';
import fs from 'fs';
import {BrowserWindow, dialog, ipcMain} from 'electron';
import * as fs from 'node:fs';
import {Config} from "../common/types";
import {CHANNEL_GET_CONFIG, CHANNEL_LOG, CHANNEL_SET_CONFIG,} from "../common/channels";
import {
CHANNEL_ERROR,
CHANNEL_GET_CONFIG,
CHANNEL_LOG,
CHANNEL_SELECT_FILE,
CHANNEL_SET_CONFIG,
} from "../common/channels";
import {ob11WebsocketServer} from "../onebot11/server/ws/WebsocketServer";
import {CONFIG_DIR, getConfigUtil, log} from "../common/utils";
import {addHistoryMsg, getGroupMember, msgHistory, selfInfo} from "../common/data";
import {checkFfmpeg, DATA_DIR, getConfigUtil, log} from "../common/utils";
import {
friendRequests, getFriend,
getGroup,
getGroupMember,
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, RawMessage} from "../ntqqapi/types";
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 {postEvent} from "../onebot11/server/postevent";
import {postOB11Event} from "../onebot11/server/postOB11Event";
import {ob11ReverseWebsockets} from "../onebot11/server/ws/ReverseWebsocket";
import {EventType} from "../onebot11/event/OB11BaseEvent";
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";
let running = false;
@@ -25,13 +50,48 @@ let running = false;
// 加载插件时触发
function onLoad() {
log("llonebot main onLoad");
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, {recursive: true});
}
ipcMain.handle(CHANNEL_GET_CONFIG, (event: any, arg: any) => {
return getConfigUtil().getConfig()
ipcMain.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.on(CHANNEL_SET_CONFIG, (event: any, arg: Config) => {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, {recursive: true});
}
ipcMain.handle(CHANNEL_ERROR, (event, arg) => {
return llonebotError;
})
ipcMain.handle(CHANNEL_GET_CONFIG, async (event, arg) => {
const config = getConfigUtil().getConfig()
return config;
})
ipcMain.on(CHANNEL_SET_CONFIG, (event, arg: Config) => {
let oldConfig = getConfigUtil().getConfig();
getConfigUtil().setConfig(arg)
if (arg.ob11.httpPort != oldConfig.ob11.httpPort && arg.ob11.enableHttp) {
@@ -76,21 +136,28 @@ function onLoad() {
}
}
}
// 检查ffmpeg
if (arg.ffmpeg) {
checkFfmpeg(arg.ffmpeg).then(success => {
if (success) {
llonebotError.ffmpegError = ''
}
})
}
})
ipcMain.on(CHANNEL_LOG, (event: any, arg: any) => {
ipcMain.on(CHANNEL_LOG, (event, arg) => {
log(arg);
})
function postReceiveMsg(msgList: RawMessage[]) {
async function postReceiveMsg(msgList: RawMessage[]) {
const {debug, reportSelfMessage} = getConfigUtil().getConfig();
for (let message of msgList) {
// log("收到新消息", message)
message.msgShortId = msgHistory[message.msgId]?.msgShortId
if (!message.msgShortId) {
addHistoryMsg(message);
}
message.msgShortId = await dbUtil.addMsg(message)
OB11Constructor.message(message).then((msg) => {
if (debug) {
msg.raw = message;
@@ -99,19 +166,18 @@ function onLoad() {
if (isSelfMsg && !reportSelfMessage) {
return
}
postEvent(msg);
postOB11Event(msg);
// log("post msg", msg)
}).catch(e => log("constructMessage error: ", e.toString()));
}).catch(e => log("constructMessage error: ", e.stack.toString()));
}
}
async function start() {
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, (payload) => {
async function startReceiveHook() {
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, async (payload) => {
try {
postReceiveMsg(payload.msgList);
await postReceiveMsg(payload.msgList);
} catch (e) {
log("report message error: ", e.toString());
log("report message error: ", e.stack.toString());
}
})
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.UPDATE_MSG, async (payload) => {
@@ -119,13 +185,15 @@ function onLoad() {
// log("message update", message.sendStatus, message)
if (message.recallTime != "0") {
// 撤回消息上报
const oriMessage = msgHistory[message.msgId]
const oriMessage = await dbUtil.getMsgByLongId(message.msgId)
if (!oriMessage) {
continue
}
oriMessage.recallTime = message.recallTime
dbUtil.updateMsg(oriMessage).then();
if (message.chatType == ChatType.friend) {
const friendRecallEvent = new OB11FriendRecallNoticeEvent(parseInt(message.senderUin), oriMessage.msgShortId);
postEvent(friendRecallEvent);
postOB11Event(friendRecallEvent);
} else if (message.chatType == ChatType.group) {
let operatorId = message.senderUin
for (const element of message.elements) {
@@ -140,27 +208,155 @@ function onLoad() {
oriMessage.msgShortId
)
postEvent(groupRecallEvent);
postOB11Event(groupRecallEvent);
}
// 不让入库覆盖原来消息,不然就获取不到撤回的消息内容了
continue
}
addHistoryMsg(message)
dbUtil.addMsg(message).then();
}
})
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, (payload) => {
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, async (payload) => {
const {reportSelfMessage} = getConfigUtil().getConfig();
if (!reportSelfMessage) {
return
}
// log("reportSelfMessage", payload)
try {
postReceiveMsg([payload.msgRecord]);
await postReceiveMsg([payload.msgRecord]);
} catch (e) {
log("report self message error: ", e.toString());
log("report self message error: ", e.stack.toString());
}
})
registerReceiveHook<{
"doubt": boolean,
"oldestUnreadSeq": string,
"unreadCount": number
}>(ReceiveCmd.UNREAD_GROUP_NOTIFY, async (payload) => {
if (payload.unreadCount) {
// log("开始获取群通知详情")
let notify: GroupNotifies;
try {
notify = await NTQQApi.getGroupNotifies();
} catch (e) {
// log("获取群通知详情失败", e);
return
}
const notifies = notify.notifies.slice(0, payload.unreadCount)
// log("获取群通知详情完成", notifies, payload);
for (const notify of notifies) {
try {
notify.time = Date.now();
const notifyTime = parseInt(notify.seq) / 1000
// log(`加群通知时间${notifyTime}`, `LLOneBot启动时间${startTime}`);
// if (notifyTime < startTime) {
// continue;
// }
let existNotify = 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)
@@ -168,33 +364,39 @@ function onLoad() {
log("http server start failed", e);
}
}
if (config.ob11.enableWs){
if (config.ob11.enableWs) {
ob11WebsocketServer.start(config.ob11.wsPort);
}
if (config.ob11.enableWsReverse){
if (config.ob11.enableWsReverse) {
ob11ReverseWebsockets.start();
}
log("LLOneBot start")
}
let getSelfNickCount = 0;
const init = async () => {
try {
log("start get self info")
const _ = await NTQQApi.getSelfInfo();
log("get self info api result:", _);
Object.assign(selfInfo, _);
selfInfo.nick = selfInfo.uin;
log("get self simple info", _);
} catch (e) {
log("retry get self info");
log("retry get self info", e);
}
log("self info", selfInfo);
if (selfInfo.uin) {
try {
const userInfo = (await NTQQApi.getUserInfo(selfInfo.uid));
const userInfo = (await NTQQApi.getUserDetailInfo(selfInfo.uid));
log("self info", userInfo);
if (userInfo) {
selfInfo.nick = userInfo.nick;
} else {
return setTimeout(init, 1000);
getSelfNickCount++;
if (getSelfNickCount < 10) {
return setTimeout(init, 1000);
}
}
} catch (e) {
log("get self nickname failed", e.toString());

View File

@@ -1,15 +1,16 @@
import {
AtType,
ElementType,
ElementType, PicType, SendArkElement,
SendFaceElement,
SendFileElement,
SendPicElement,
SendPttElement,
SendReplyElement,
SendTextElement
} from "./types";
import {NTQQApi} from "./ntcall";
import {encodeSilk, log} from "../common/utils";
import fs from "fs";
import {encodeSilk, isGIF} from "../common/utils";
import * as fs from "node:fs";
export class SendMsgElementConstructor {
@@ -55,7 +56,7 @@ export class SendMsgElementConstructor {
}
static async pic(picPath: string): Promise<SendPicElement> {
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(picPath);
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(picPath, ElementType.PIC);
const imageSize = await NTQQApi.getImageSize(picPath);
const picElement = {
md5HexStr: md5,
@@ -65,7 +66,7 @@ export class SendMsgElementConstructor {
fileName: fileName,
sourcePath: path,
original: true,
picType: 1001,
picType: isGIF(picPath) ? PicType.gif : PicType.jpg,
picSubType: 0,
fileUuid: "",
fileSubId: "",
@@ -80,12 +81,40 @@ export class SendMsgElementConstructor {
};
}
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);
if (converted){
fs.unlink(silkPath, ()=>{});
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(silkPath, ElementType.PTT);
if (converted) {
fs.unlink(silkPath, () => {
});
}
return {
elementType: ElementType.PTT,
@@ -121,4 +150,12 @@ export class SendMsgElementConstructor {
}
}
}
static ark(data: any): SendArkElement {
return {
elementType: ElementType.ARK,
elementId: "",
arkElement: data
}
}
}

View File

@@ -1,63 +1,72 @@
import {BrowserWindow} from 'electron';
import {log, sleep} from "../common/utils";
import {NTQQApi, NTQQApiClass, sendMessagePool} from "./ntcall";
import {Group, RawMessage, User} from "./types";
import {addHistoryMsg, friends, groups, msgHistory} from "../common/data";
import {OB11GroupDecreaseEvent} from "../onebot11/event/notice/OB11GroupDecreaseEvent";
import {OB11GroupIncreaseEvent} from "../onebot11/event/notice/OB11GroupIncreaseEvent";
import {v4 as uuidv4} from "uuid"
import {postEvent} from "../onebot11/server/postevent";
import {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 let hookApiCallbacks: Record<string, (apiReturn: any) => void> = {}
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",
GROUPS = "nodeIKernelGroupListener/onGroupListUpdate",
GROUPS_UNIX = "onGroupListUpdate",
FRIENDS = "onBuddyListChange"
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
},
'type': 'request'
'eventName': NTQQApiClass
'callbackId'?: string
}
1:
{
cmdName: ReceiveCmd,
cmdType: "event",
Array<{
cmdName: ReceiveCmd
cmdType: 'event'
payload: PayloadType
}[]
}>
}
let receiveHooks: Array<{
method: ReceiveCmd,
const receiveHooks: Array<{
method: ReceiveCmd
hookFunc: ((payload: any) => void | Promise<void>)
id: string
}> = []
export function hookNTQQApiReceive(window: BrowserWindow) {
const originalSend = window.webContents.send;
const originalSend = window.webContents.send
const patchSend = (channel: string, ...args: NTQQApiReturnData) => {
// log(`received ntqq api message: ${channel}`, JSON.stringify(args))
HOOK_LOG && log(`received ntqq api message: ${channel}`, JSON.stringify(args))
if (args?.[1] instanceof Array) {
for (let receiveData of args?.[1]) {
const ntQQApiMethodName = receiveData.cmdName;
for (const receiveData of args?.[1]) {
const ntQQApiMethodName = receiveData.cmdName
// log(`received ntqq api message: ${channel} ${ntQQApiMethodName}`, JSON.stringify(receiveData))
for (let hook of receiveHooks) {
for (const hook of receiveHooks) {
if (hook.method === ntQQApiMethodName) {
new Promise((resolve, reject) => {
try {
let _ = hook.hookFunc(receiveData.payload)
if (hook.hookFunc.constructor.name === "AsyncFunction") {
const _ = hook.hookFunc(receiveData.payload)
if (hook.hookFunc.constructor.name === 'AsyncFunction') {
(_ as Promise<void>).then()
}
} catch (e) {
log("hook error", e, receiveData.payload)
log('hook error', e, receiveData.payload)
}
}).then()
}
@@ -66,36 +75,36 @@ export function hookNTQQApiReceive(window: BrowserWindow) {
}
if (args[0]?.callbackId) {
// log("hookApiCallback", hookApiCallbacks, args)
const callbackId = args[0].callbackId;
const callbackId = args[0].callbackId
if (hookApiCallbacks[callbackId]) {
// log("callback found")
new Promise((resolve, reject) => {
hookApiCallbacks[callbackId](args[1]);
hookApiCallbacks[callbackId](args[1])
}).then()
delete hookApiCallbacks[callbackId];
delete hookApiCallbacks[callbackId]
}
}
return originalSend.call(window.webContents, channel, ...args);
return originalSend.call(window.webContents, channel, ...args)
}
window.webContents.send = patchSend;
window.webContents.send = patchSend
}
export function hookNTQQApiCall(window: BrowserWindow) {
// 监听调用NTQQApi
let webContents = window.webContents as any;
const ipc_message_proxy = webContents._events["-ipc-message"]?.[0] || webContents._events["-ipc-message"];
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) {
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;
// }
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 {
@@ -105,30 +114,29 @@ export function registerReceiveHook<PayloadType>(method: ReceiveCmd, hookFunc: (
hookFunc,
id
})
return id;
return id
}
export function removeReceiveHook(id: string) {
const index = receiveHooks.findIndex(h => h.id === id)
receiveHooks.splice(index, 1);
receiveHooks.splice(index, 1)
}
async function updateGroups(_groups: Group[], needUpdate: boolean = true) {
for (let group of _groups) {
let existGroup = groups.find(g => g.groupCode == group.groupCode);
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;
Object.assign(existGroup, group)
} else {
groups.push(group)
existGroup = group
}
if (needUpdate) {
const members = await NTQQApi.getGroupMembers(group.groupCode);
const members = await NTQQApi.getGroupMembers(group.groupCode)
if (members) {
existGroup.members = members;
existGroup.members = members
}
}
}
@@ -136,87 +144,83 @@ async function updateGroups(_groups: Group[], needUpdate: boolean = true) {
async function processGroupEvent(payload) {
try {
const newGroupList = payload.groupList;
const newGroupList = payload.groupList
for (const group of newGroupList) {
let existGroup = groups.find(g => g.groupCode == group.groupCode);
const existGroup = groups.find(g => g.groupCode == group.groupCode)
if (existGroup) {
if (existGroup.memberCount > group.memberCount) {
const oldMembers = existGroup.members;
const oldMembers = existGroup.members
await sleep(200); // 如果请求QQ API的速度过快通常无法正确拉取到最新的群信息因此这里人为引入一个延时
const newMembers = await NTQQApi.getGroupMembers(group.groupCode);
await sleep(200) // 如果请求QQ API的速度过快通常无法正确拉取到最新的群信息因此这里人为引入一个延时
const newMembers = await NTQQApi.getGroupMembers(group.groupCode)
group.members = newMembers;
const newMembersSet = new Set<string>(); // 建立索引降低时间复杂度
group.members = newMembers
const newMembersSet = new Set<string>() // 建立索引降低时间复杂度
for (const member of newMembers) {
newMembersSet.add(member.uin);
newMembersSet.add(member.uin)
}
for (const member of oldMembers) {
if (!newMembersSet.has(member.uin)) {
postEvent(new OB11GroupDecreaseEvent(group.groupCode, parseInt(member.uin)));
break;
postOB11Event(new OB11GroupDecreaseEvent(group.groupCode, parseInt(member.uin)))
break
}
}
}
else if (existGroup.memberCount < group.memberCount) {
const oldMembers = existGroup.members;
const oldMembersSet = new Set<string>();
} else if (existGroup.memberCount < group.memberCount) {
const oldMembers = existGroup.members
const oldMembersSet = new Set<string>()
for (const member of oldMembers) {
oldMembersSet.add(member.uin);
oldMembersSet.add(member.uin)
}
await sleep(200);
const newMembers = await NTQQApi.getGroupMembers(group.groupCode);
await sleep(200)
const newMembers = await NTQQApi.getGroupMembers(group.groupCode)
group.members = newMembers;
group.members = newMembers
for (const member of newMembers) {
if (!oldMembersSet.has(member.uin)) {
postEvent(new OB11GroupIncreaseEvent(group.groupCode, parseInt(member.uin)));
break;
postOB11Event(new OB11GroupIncreaseEvent(group.groupCode, parseInt(member.uin)))
break
}
}
}
}
}
updateGroups(newGroupList, false).then();
}
catch (e) {
updateGroups(payload.groupList).then();
console.log(e);
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();
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();
updateGroups(payload.groupList).then()
} else {
if (process.platform != 'win32') {
processGroupEvent(payload).then()
}
}
})
registerReceiveHook<{
data: { categoryId: number, categroyName: string, categroyMbCount: number, buddyList: User[] }[]
data: Array<{ categoryId: number, categroyName: string, categroyMbCount: number, buddyList: User[] }>
}>(ReceiveCmd.FRIENDS, payload => {
for (const fData of payload.data) {
const _friends = fData.buddyList;
for (let friend of _friends) {
let existFriend = friends.find(f => f.uin == friend.uin)
const _friends = fData.buddyList
for (const friend of _friends) {
const existFriend = friends.find(f => f.uin == friend.uin)
if (!existFriend) {
friends.push(friend)
} else {
@@ -226,27 +230,54 @@ registerReceiveHook<{
}
})
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, (payload) => {
registerReceiveHook<{ msgList: RawMessage[] }>(ReceiveCmd.NEW_MSG, (payload) => {
const {autoDeleteFile, autoDeleteFileSecond} = getConfigUtil().getConfig()
for (const message of payload.msgList) {
// log("收到新消息push到历史记录", message)
addHistoryMsg(message)
}
const msgIds = Object.keys(msgHistory);
if (msgIds.length > 30000) {
delete msgHistory[msgIds.sort()[0]]
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;
const message = msgRecord
const peerUid = message.peerUid
// log("收到自己发送成功的消息", Object.keys(sendMessagePool), message);
const sendCallback = sendMessagePool[peerUid];
const sendCallback = sendMessagePool[peerUid]
if (sendCallback) {
try {
sendCallback(message);
sendCallback(message)
} catch (e) {
log("receive self msg error", e.stack)
log('receive self msg error', e.stack)
}
}
})
registerReceiveHook<{ info: { status: number } }>(ReceiveCmd.SELF_STATUS, (info) => {
selfInfo.online = info.info.status !== 20
})

View File

@@ -1,10 +1,26 @@
import {ipcMain} from "electron";
import {hookApiCallbacks, ReceiveCmd, registerReceiveHook, removeReceiveHook} from "./hook";
import {log} from "../common/utils";
import {ChatType, Friend, Group, GroupMember, RawMessage, SelfInfo, SendMessageElement, User} from "./types";
import * as fs from "fs";
import {addHistoryMsg, msgHistory, selfInfo, uidMaps} from "../common/data";
import {v4 as uuidv4} from "uuid"
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
@@ -19,104 +35,134 @@ export type IPCReceiveDetail = [
]
export enum NTQQApiClass {
NT_API = "ns-ntApi",
FS_API = "ns-FsApi",
GLOBAL_DATA = "ns-GlobalDataApi"
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",
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",
MULTI_FORWARD_MSG = "nodeIKernelMsgService/multiForwardMsgWithComment" // 合并转发
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",
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?: ""
}
enum CallBackType {
UUID,
METHOD
peerUid: string // 如果是群聊uid为群号私聊uid就是加密的字符串
guildId?: ''
}
interface NTQQApiParams {
methodName: NTQQApiMethod,
className?: NTQQApiClass,
channel?: NTQQApiChannel,
args?: unknown[],
methodName: NTQQApiMethod | string
className?: NTQQApiClass
channel?: NTQQApiChannel
classNameIsRegister?: boolean
args?: unknown[]
cbCmd?: ReceiveCmd | null
timeoutSecond?: number,
cmdCB?: (payload: any) => boolean
afterFirstCmd?: boolean // 是否在methodName调用完之后再去hook cbCmd
timeoutSecond?: number
}
function callNTQQApi<ReturnType>(params: NTQQApiParams) {
async function callNTQQApi<ReturnType>(params: NTQQApiParams) {
let {
className, methodName, channel, args,
cbCmd, timeoutSecond: timeout
} = params;
className = className ?? NTQQApiClass.NT_API;
channel = channel ?? NTQQApiChannel.IPC_UP_2;
args = args ?? [];
timeout = timeout ?? 5;
const uuid = uuidv4();
cbCmd, timeoutSecond: timeout,
classNameIsRegister, cmdCB, afterFirstCmd
} = params
className = className ?? NTQQApiClass.NT_API
channel = channel ?? NTQQApiChannel.IPC_UP_2
args = args ?? []
timeout = timeout ?? 5
afterFirstCmd = afterFirstCmd ?? true
const uuid = uuidv4()
// log("callNTQQApi", channel, className, methodName, args, uuid)
return new Promise((resolve: (data: ReturnType) => void, reject) => {
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) {
const hookId = registerReceiveHook<ReturnType>(cbCmd, (payload) => {
log(methodName, "second callback", cbCmd, payload);
removeReceiveHook(hookId);
success = true
resolve(payload);
})
if (result?.result == 0 || result === undefined) {
afterFirstCmd && secondCallback()
} else {
success = true
reject(`ntqq api call failed, ${result.errMsg}`);
reject(`ntqq api call failed, ${result.errMsg}`)
}
}
}
setTimeout(() => {
// log("ntqq api timeout", success, channel, className, methodName)
if (!success) {
log(`ntqq api timeout ${channel}, ${className}, ${methodName}`)
reject(`ntqq api timeout ${channel}, ${className}, ${methodName}`)
log(`ntqq api timeout ${channel}, ${eventName}, ${methodName}`, apiArgs)
reject(`ntqq api timeout ${channel}, ${eventName}, ${methodName}, ${apiArgs}`)
}
}, _timeout)
const eventName = className + "-" + channel[channel.length - 1];
const apiArgs = [methodName, ...args]
ipcMain.emit(
channel,
{},
@@ -126,19 +172,17 @@ function callNTQQApi<ReturnType>(params: NTQQApiParams) {
})
}
export let sendMessagePool: Record<string, ((sendSuccessMsg: RawMessage) => void) | null> = {}// peerUid: callbackFunnc
export const sendMessagePool: Record<string, ((sendSuccessMsg: RawMessage) => void) | null> = {}// peerUid: callbackFunnc
interface GeneralCallResult {
result: number, // 0: success
result: number // 0: success
errMsg: string
}
export class NTQQApi {
// static likeFriend = defineNTQQApi<void>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.LIKE_FRIEND)
static likeFriend(uid: string, count = 1) {
return callNTQQApi({
static async likeFriend(uid: string, count = 1) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.LIKE_FRIEND,
args: [{
doLikeUserInfo: {
@@ -151,10 +195,12 @@ export class NTQQApi {
})
}
static getSelfInfo() {
return callNTQQApi<SelfInfo>({
static async getSelfInfo() {
return await callNTQQApi<SelfInfo>({
className: NTQQApiClass.GLOBAL_DATA,
methodName: NTQQApiMethod.SELF_INFO, timeoutSecond: 2
// channel: NTQQApiChannel.IPC_UP_3,
methodName: NTQQApiMethod.SELF_INFO,
timeoutSecond: 2
})
}
@@ -167,21 +213,41 @@ export class NTQQApi {
return result.profiles.get(uid)
}
static async getUserDetailInfo(uid: string) {
const result = await callNTQQApi<{ info: User }>({
methodName: NTQQApiMethod.USER_DETAIL_INFO,
cbCmd: ReceiveCmd.USER_DETAIL_INFO,
afterFirstCmd: false,
cmdCB: (payload) => {
const success = payload.info.uid == uid
// log("get user detail info", success, uid, payload)
return success
},
args: [
{
uid
},
null
]
})
return result.info
}
static async getFriends(forced = false) {
const data = await callNTQQApi<{
data: {
categoryId: number,
categroyName: string,
categroyMbCount: number,
data: Array<{
categoryId: number
categroyName: string
categroyMbCount: number
buddyList: Friend[]
}[]
}>
}>(
{
methodName: NTQQApiMethod.FRIENDS,
args: [{force_update: forced}, undefined],
cbCmd: ReceiveCmd.FRIENDS
})
let _friends: Friend[] = [];
const _friends: Friend[] = []
for (const fData of data.data) {
_friends.push(...fData.buddyList)
}
@@ -190,22 +256,22 @@ export class NTQQApi {
static async getGroups(forced = false) {
let cbCmd = ReceiveCmd.GROUPS
if (process.platform != "win32") {
if (process.platform != 'win32') {
cbCmd = ReceiveCmd.GROUPS_UNIX
}
const result = await callNTQQApi<{
updateType: number,
updateType: number
groupList: Group[]
}>({methodName: NTQQApiMethod.GROUPS, args: [{force_update: forced}, undefined], cbCmd})
return result.groupList
}
static async getGroupMembers(groupQQ: string, num = 3000) {
static async getGroupMembers(groupQQ: string, num = 3000): Promise<GroupMember[]> {
const sceneId = await callNTQQApi({
methodName: NTQQApiMethod.GROUP_MEMBER_SCENE,
args: [{
groupCode: groupQQ,
scene: "groupMemberList_MainWindow"
scene: 'groupMemberList_MainWindow'
}]
})
// log("get group member sceneId", sceneId);
@@ -215,21 +281,22 @@ export class NTQQApi {
}>({
methodName: NTQQApiMethod.GROUP_MEMBERS,
args: [{
sceneId: sceneId,
num: num
sceneId,
num
},
null
]
})
// log("members info", typeof result.result.infos, Object.keys(result.result.infos))
let values = result.result.infos.values()
const values = result.result.infos.values()
let members = Array.from(values) as GroupMember[]
for(const member of members){
// uidMaps[member.uid] = member.uin;
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)
@@ -237,70 +304,74 @@ export class NTQQApi {
}
}
static getFileType(filePath: string) {
return callNTQQApi<{ ext: string }>({
static async getFileType(filePath: string) {
return await callNTQQApi<{ ext: string }>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.FILE_TYPE, args: [filePath]
})
}
static getFileMd5(filePath: string) {
return callNTQQApi<string>({
static async getFileMd5(filePath: string) {
return await callNTQQApi<string>({
className: NTQQApiClass.FS_API,
methodName: NTQQApiMethod.FILE_MD5,
args: [filePath]
})
}
static copyFile(filePath: string, destPath: string) {
return callNTQQApi<string>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.FILE_COPY, args: [{
static async copyFile(filePath: string, destPath: string) {
return await callNTQQApi<string>({
className: NTQQApiClass.FS_API,
methodName: NTQQApiMethod.FILE_COPY,
args: [{
fromPath: filePath,
toPath: destPath
}]
})
}
static getImageSize(filePath: string) {
return callNTQQApi<{ width: number, height: number }>({
static async getImageSize(filePath: string) {
return await callNTQQApi<{ width: number, height: number }>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.IMAGE_SIZE, args: [filePath]
})
}
static getFileSize(filePath: string) {
return callNTQQApi<number>({
static async getFileSize(filePath: string) {
return await callNTQQApi<number>({
className: NTQQApiClass.FS_API, methodName: NTQQApiMethod.FILE_SIZE, args: [filePath]
})
}
// 上传文件到QQ的文件夹
static async uploadFile(filePath: string) {
const md5 = await NTQQApi.getFileMd5(filePath);
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
ext = '.' + ext
} else {
ext = ""
ext = ''
}
let fileName = `${path.basename(filePath)}`
if (!fileName.includes('.')) {
fileName += ext
}
const fileName = `${md5}${ext}`;
const mediaPath = await callNTQQApi<string>({
methodName: NTQQApiMethod.MEDIA_FILE_PATH,
args: [{
path_info: {
md5HexStr: md5,
fileName: fileName,
elementType: 2,
fileName,
elementType,
elementSubType: 0,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: ""
file_uuid: ''
}
}]
})
log("media path", mediaPath)
await NTQQApi.copyFile(filePath, mediaPath);
const fileSize = await NTQQApi.getFileSize(filePath);
log('media path', mediaPath)
await NTQQApi.copyFile(filePath, mediaPath)
const fileSize = await NTQQApi.getFileSize(filePath)
return {
md5,
fileName,
@@ -317,98 +388,121 @@ export class NTQQApi {
const apiParams = [
{
getReq: {
msgId: msgId,
chatType: chatType,
peerUid: peerUid,
elementId: elementId,
msgId,
chatType,
peerUid,
elementId,
thumbSize: 0,
downloadType: 1,
filePath: thumbPath,
},
filePath: thumbPath
}
},
undefined,
undefined
]
await callNTQQApi({methodName: NTQQApiMethod.DOWNLOAD_MEDIA, args: apiParams})
// log("需要下载media", sourcePath);
await callNTQQApi({
methodName: NTQQApiMethod.DOWNLOAD_MEDIA,
args: apiParams,
cbCmd: ReceiveCmd.MEDIA_DOWNLOAD_COMPLETE,
cmdCB: (payload: { notifyInfo: { filePath: string } }) => {
// log("media 下载完成判断", payload.notifyInfo.filePath, sourcePath);
return payload.notifyInfo.filePath == sourcePath
}
})
return sourcePath
}
static recallMsg(peer: Peer, msgIds: string[]) {
return callNTQQApi({
methodName: NTQQApiMethod.RECALL_MSG, args: [{
static async recallMsg(peer: Peer, msgIds: string[]) {
return await callNTQQApi({
methodName: NTQQApiMethod.RECALL_MSG,
args: [{
peer,
msgIds
}, null]
})
}
static sendMsg(peer: Peer, msgElements: SendMessageElement[], waitComplete = false, timeout=10000) {
const sendTimeout = timeout
static async sendMsg(peer: Peer, msgElements: SendMessageElement[], waitComplete = false, timeout = 10000) {
const peerUid = peer.peerUid
return new Promise<RawMessage>((resolve, reject) => {
const peerUid = peer.peerUid;
let usingTime = 0;
let success = false;
let isTimeout = false;
const checkSuccess = () => {
if (!success) {
sendMessagePool[peerUid] = null;
isTimeout = true;
reject("发送超时")
}
// 等待上一个相同的peer发送完
let checkLastSendUsingTime = 0
const waitLastSend = async () => {
if (checkLastSendUsingTime > timeout) {
throw ('发送超时')
}
setTimeout(checkSuccess, sendTimeout);
const lastSending = sendMessagePool[peer.peerUid]
if (lastSending) {
// log("有正在发送的消息,等待中...")
await sleep(500)
checkLastSendUsingTime += 500
return await waitLastSend()
} else {
const checkLastSend = () => {
let lastSending = sendMessagePool[peerUid]
if (sendTimeout < usingTime) {
sendMessagePool[peerUid] = null;
isTimeout = true;
reject("发送超时")
}
if (!!lastSending) {
// log("有正在发送的消息,等待中...")
usingTime += 500;
setTimeout(checkLastSend, 500);
} else {
log("可以进行发送消息,设置发送成功回调", sendMessagePool)
sendMessagePool[peerUid] = (rawMessage: RawMessage) => {
sendMessagePool[peerUid] = null;
const checkSendComplete = () => {
if (isTimeout) {
return reject("发送超时")
}
if (msgHistory[rawMessage.msgId]?.sendStatus == 2) {
success = true;
resolve(rawMessage);
} else {
setTimeout(checkSendComplete, 500)
}
}
if (waitComplete) {
checkSendComplete();
} else {
success = true;
resolve(rawMessage);
}
}
}
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}发送消息成功`)
}
checkLastSend()
callNTQQApi({
methodName: NTQQApiMethod.SEND_MSG,
args: [{
msgId: "0",
peer, msgElements,
msgAttributeInfos: new Map(),
}, null]
}).then()
})
checkSendCompleteUsingTime += 500
if (checkSendCompleteUsingTime > timeout) {
throw ('发送超时')
}
await sleep(500)
return await checkSendComplete()
}
log("开始发送消息", peer, msgElements)
callNTQQApi({
methodName: NTQQApiMethod.SEND_MSG,
args: [{
msgId: '0',
peer,
msgElements,
msgAttributeInfos: new Map()
}, null]
}).then()
return await checkSendComplete()
}
static multiForwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
let msgInfos = msgIds.map(id => {
return {msgId: id, senderShowName: "LLOneBot"}
static async forwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.FORWARD_MSG,
args:[
{
msgIds: msgIds,
srcContact: srcPeer,
dstContacts: [
destPeer
],
commentElements: [],
msgAttributeInfos: new Map()
},
null,
]
})
}
static async multiForwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
const msgInfos = msgIds.map(id => {
return {msgId: id, senderShowName: selfInfo.nick}
})
const apiArgs = [
{
@@ -418,44 +512,204 @@ export class NTQQApi {
commentElements: [],
msgAttributeInfos: new Map()
},
null,
null
]
return new Promise<RawMessage>((resolve, reject) => {
return await new Promise<RawMessage>((resolve, reject) => {
let complete = false
setTimeout(() => {
if (!complete) {
reject("转发消息超时");
reject('转发消息超时')
}
}, 5000)
registerReceiveHook(ReceiveCmd.SELF_SEND_MSG, (payload: { msgRecord: RawMessage }) => {
const msg = payload.msgRecord;
registerReceiveHook(ReceiveCmd.SELF_SEND_MSG, async (payload: { msgRecord: RawMessage }) => {
const msg = payload.msgRecord
// 需要判断它是转发的消息,并且识别到是当前转发的这一条
const arkElement = msg.elements.find(ele => ele.arkElement)
if (!arkElement) {
// log("收到的不是转发消息")
return
}
const forwardData: any = JSON.parse(arkElement.arkElement.bytesData);
if (forwardData.app != "com.tencent.multimsg") {
const forwardData: any = JSON.parse(arkElement.arkElement.bytesData)
if (forwardData.app != 'com.tencent.multimsg') {
return
}
if (msg.peerUid == destPeer.peerUid && msg.senderUid == selfInfo.uid) {
complete = true;
addHistoryMsg(msg)
resolve(msg);
log("转发消息成功:", payload)
complete = true
await dbUtil.addMsg(msg)
resolve(msg)
log('转发消息成功:', payload)
}
})
callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.MULTI_FORWARD_MSG,
args: apiArgs
}).then(result => {
log("转发消息结果:", result, apiArgs)
log('转发消息结果:', result, apiArgs)
if (result.result !== 0) {
complete = true;
reject("转发消息失败," + JSON.stringify(result));
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) {
}
}

View File

@@ -8,7 +8,7 @@ export interface User {
}
export interface SelfInfo extends User {
online?: boolean;
}
export interface Friend extends User {
@@ -45,6 +45,12 @@ export interface Group {
members: GroupMember[] // 原始数据是没有这个的,为了方便自己加了这个字段
}
export enum GroupMemberRole {
normal = 2,
admin = 3,
owner = 4
}
export interface GroupMember {
avatarPath: string;
cardName: string;
@@ -53,18 +59,21 @@ export interface GroupMember {
nick: string;
qid: string;
remark: string;
role: number; // 群主:4, 管理员:3群员:2
role: GroupMemberRole; // 群主:4, 管理员:3群员:2
shutUpTime: number; // 禁言时间,单位是什么暂时不清楚
uid: string; // 加密的字符串
uin: string; // QQ号
isRobot: boolean;
}
export enum ElementType {
TEXT = 1,
PIC = 2,
FILE = 3,
PTT = 4,
FACE = 6,
REPLY = 7,
ARK = 10,
}
export interface SendTextElement {
@@ -87,7 +96,7 @@ export interface SendPttElement {
filePath: string,
md5HexStr: string,
fileSize: number,
duration: number,
duration: number, // 单位是秒
formatType: number,
voiceType: number,
voiceChangeType: number,
@@ -99,6 +108,10 @@ export interface SendPttElement {
}
}
export enum PicType {
gif = 2000,
jpg = 1000
}
export interface SendPicElement {
elementType: ElementType.PIC,
elementId: "",
@@ -110,7 +123,7 @@ export interface SendPicElement {
fileName: string,
sourcePath: string,
original: boolean,
picType: number,
picType: PicType,
picSubType: number,
fileUuid: string,
fileSubId: string,
@@ -136,7 +149,37 @@ export interface SendFaceElement {
faceElement: FaceElement
}
export type SendMessageElement = SendTextElement | SendPttElement | SendPicElement | SendReplyElement | SendFaceElement
export interface FileElement {
"fileMd5"?: "",
"fileName": string,
"filePath": string,
"fileSize": string,
"picHeight"?: number,
"picWidth"?: number,
"picThumbPath"?: {},
"file10MMd5"?: "",
"fileSha"?: "",
"fileSha3"?: "",
"fileUuid"?: "",
"fileSubId"?: "",
"thumbFileSize"?: number
}
export interface SendFileElement {
elementType: ElementType.FILE,
elementId: "",
fileElement: FileElement
}
export interface SendArkElement {
elementType: ElementType.ARK,
elementId: "",
arkElement: ArkElement
}
export type SendMessageElement = SendTextElement | SendPttElement |
SendPicElement | SendReplyElement | SendFaceElement | SendFileElement | SendArkElement
export enum AtType {
notAt = 0,
@@ -175,6 +218,8 @@ export interface PttElement {
export interface ArkElement {
bytesData: string;
linkInfo:null,
subElementType:null
}
export const IMAGE_HTTP_HOST = "https://gchat.qpic.cn"
@@ -199,6 +244,7 @@ export interface GrayTipElement {
operatorMemRemark?: string;
wording: string; // 自定义的撤回提示语
}
aioOpGrayTipElement: TipAioOpGrayTipElement
}
export interface FaceElement {
@@ -206,10 +252,66 @@ export interface FaceElement {
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;
msgTime: string; // 时间戳,秒
msgSeq: string;
senderUid: string;
senderUin?: string; // 发送者QQ号
@@ -222,6 +324,7 @@ export interface RawMessage {
recallTime: string; // 撤回时间, "0"是没有撤回
elements: {
elementId: string,
elementType: ElementType;
replyElement: {
senderUid: string; // 原消息发送者QQ号
sourceMsgIsIncPic: boolean; // 原消息是否有图片
@@ -239,5 +342,64 @@ export interface RawMessage {
arkElement: ArkElement;
grayTipElement: GrayTipElement;
faceElement: FaceElement;
videoElement: VideoElement;
fileElement: FileElement;
}[];
}
export enum GroupNotifyTypes {
INVITE_ME = 1,
INVITED_JOIN = 4, // 有人接受了邀请入群
JOIN_REQUEST = 7,
ADMIN_SET = 8,
ADMIN_UNSET = 12,
MEMBER_EXIT = 11, // 主动退出?
}
export interface GroupNotifies {
doubt: boolean,
nextStartSeq: string,
notifies: GroupNotify[],
}
export 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

@@ -1,9 +1,11 @@
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,
@@ -19,11 +21,12 @@ class BaseAction<PayloadType, ReturnDataType> {
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: string): Promise<OB11Return<ReturnDataType | null>> {
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)
@@ -32,6 +35,7 @@ class BaseAction<PayloadType, ReturnDataType> {
const resData = await this._handle(payload)
return OB11Response.ok(resData, echo);
} catch (e) {
log("发生错误", e.stack.toString())
return OB11Response.error(e.toString(), 1200, echo)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import BaseAction from "./BaseAction";
import {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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import SendMsg from "./SendMsg";
import {ActionName} from "./types";
class SendGroupMsg extends SendMsg{
class SendGroupMsg extends SendMsg {
actionName = ActionName.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

@@ -1,15 +1,23 @@
import {AtType, ChatType, Group, SendMessageElement} from "../../ntqqapi/types";
import {addHistoryMsg, friends, getGroup, getHistoryMsgByShortId, getUidByUin, selfInfo,} from "../../common/data";
import {OB11MessageData, OB11MessageDataType, OB11MessageMixType, OB11MessageNode, OB11PostSendMsg} from '../types';
import {AtType, ChatType, Group, RawMessage, SendArkElement, SendMessageElement} from "../../ntqqapi/types";
import {friends, getFriend, getGroup, getGroupMember, getUidByUin, selfInfo,} from "../../common/data";
import {
OB11MessageCustomMusic,
OB11MessageData,
OB11MessageDataType,
OB11MessageMixType,
OB11MessageNode,
OB11PostSendMsg
} from '../types';
import {NTQQApi, Peer} from "../../ntqqapi/ntcall";
import {SendMsgElementConstructor} from "../../ntqqapi/constructor";
import {uri2local} from "../utils";
import BaseAction from "./BaseAction";
import {ActionName, BaseCheckResult} from "./types";
import * as fs from "fs";
import * as fs from "node:fs";
import {log} from "../../common/utils";
import {v4 as uuidv4} from "uuid"
import {parseCQCode} from "../cqcode";
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 {
@@ -55,13 +63,29 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
protected async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> {
const messages = this.convertMessage2List(payload.message);
const fmNum = this.forwardMsgNum(payload)
const fmNum = this.getSpecialMsgNum(payload, OB11MessageDataType.node)
if (fmNum && fmNum != messages.length) {
return {
valid: false,
message: "转发消息不能和普通消息混在一起发送,转发需要保证message只有type为node的元素"
}
}
if (payload.group_id && !(await getGroup(payload.group_id))) {
return {
valid: false,
message: `${payload.group_id}不存在`
}
}
if (payload.user_id && payload.message_type !== "group") {
if (!(await getFriend(payload.user_id))) {
if (!ALLOW_SEND_TEMP_MSG) {
return {
valid: false,
message: `不能发送临时消息`
}
}
}
}
return {
valid: true,
}
@@ -72,17 +96,16 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
chatType: ChatType.friend,
peerUid: ""
}
let isTempMsg = false;
let group: Group | undefined = undefined;
if (payload?.group_id) {
const genGroupPeer = async () => {
group = await getGroup(payload.group_id.toString())
if (!group) {
throw (`${payload.group_id}不存在`)
}
peer.chatType = ChatType.group
// peer.name = group.name
peer.peerUid = group.groupCode
} else if (payload?.user_id) {
}
const genFriendPeer = () => {
const friend = friends.find(f => f.uin == payload.user_id.toString())
if (friend) {
// peer.name = friend.nickName
@@ -94,24 +117,50 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
throw (`找不到私聊对象${payload.user_id}`)
}
// peer.name = tempUser.nickName
isTempMsg = true;
peer.peerUid = tempUserUid;
}
}
if (payload?.group_id && payload.message_type === "group") {
await genGroupPeer()
} else if (payload?.user_id) {
genFriendPeer()
} else if (payload.group_id) {
await genGroupPeer()
} else {
throw ("发送消息参数错误, 请指定group_id或user_id")
}
const messages = this.convertMessage2List(payload.message);
if (this.forwardMsgNum(payload)) {
if (this.getSpecialMsgNum(payload, OB11MessageDataType.node)) {
try {
const returnMsg = await this.handleForwardNode(peer, messages as OB11MessageNode[], group)
return {message_id: returnMsg.msgShortId}
} catch (e) {
throw ("发送转发消息失败 " + e.toString())
}
} else {
if (this.getSpecialMsgNum(payload, OB11MessageDataType.music)) {
const music: OB11MessageCustomMusic = messages[0] as OB11MessageCustomMusic
if (music) {
const {url, audio, title, content, image} = music.data;
const selfPeer: Peer = {peerUid: selfInfo.uid, chatType: ChatType.friend}
// 搞不定!
// const musicMsg = await this.send(selfPeer, [this.genMusicElement(url, audio, title, content, image)], [], false)
// 转发
// const res = await NTQQApi.forwardMsg(selfPeer, peer, [musicMsg.msgId])
// log("转发音乐消息成功", res);
// return {message_id: musicMsg.msgShortId}
}
}
}
// log("send msg:", peer, sendElements)
const {sendElements, deleteAfterSentFiles} = await this.createSendElements(messages, group)
try {
const returnMsg = await this.send(peer, sendElements, deleteAfterSentFiles)
const returnMsg = await this.send(peer, sendElements, deleteAfterSentFiles, isTempMsg)
return {message_id: returnMsg.msgShortId}
} catch (e) {
log("发送消息失败", e.stack.toString())
throw (e.toString())
}
}
@@ -124,16 +173,16 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
// text: message
// }
// }] as OB11MessageData[]
message = parseCQCode(message.toString())
message = decodeCQCode(message.toString())
} else if (!Array.isArray(message)) {
message = [message]
}
return message;
}
private forwardMsgNum(payload: OB11PostSendMsg): number {
private getSpecialMsgNum(payload: OB11PostSendMsg, msgType: OB11MessageDataType): number {
if (Array.isArray(payload.message)) {
return payload.message.filter(msg => msg.type == OB11MessageDataType.node).length
return payload.message.filter(msg => msg.type == msgType).length
}
return 0
}
@@ -144,15 +193,16 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
chatType: ChatType.friend,
peerUid: selfInfo.uid
}
let nodeIds: string[] = []
for (const messageNode of messageNodes){
let selfNodeMsgList: RawMessage[] = [];
let originalNodeMsgList: RawMessage[] = [];
for (const messageNode of messageNodes) {
// 一个node表示一个人的消息
let nodeId = messageNode.data.id;
// 有nodeId表示一个子转发消息卡片
if (nodeId) {
let nodeMsg = getHistoryMsgByShortId(nodeId);
if (nodeMsg){
nodeIds.push(nodeMsg.msgId);
let nodeMsg = await dbUtil.getMsgByShortId(parseInt(nodeId));
if (nodeMsg) {
originalNodeMsgList.push(nodeMsg);
}
} else {
// 自定义的消息
@@ -164,7 +214,7 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
} = await this.createSendElements(this.convertMessage2List(messageNode.data.content), group);
log("开始生成转发节点", sendElements);
const nodeMsg = await this.send(selfPeer, sendElements, deleteAfterSentFiles, true);
nodeIds.push(nodeMsg.msgId)
selfNodeMsgList.push(nodeMsg);
log("转发节点生成成功", nodeMsg.msgId);
} catch (e) {
log("生效转发消息节点失败", e)
@@ -172,9 +222,49 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
}
}
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(selfPeer, destPeer, nodeIds)
return await NTQQApi.multiForwardMsg(srcPeer, destPeer, nodeIds)
} catch (e) {
log("forward failed", e)
return null;
@@ -203,7 +293,8 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
if (atQQ === "all") {
sendElements.push(SendMsgElementConstructor.at(atQQ, atQQ, AtType.atAll, "全体成员"))
} else {
const atMember = group?.members.find(m => m.uin == atQQ)
// const atMember = group?.members.find(m => m.uin == atQQ)
const atMember = await getGroupMember(group?.groupCode, atQQ);
if (atMember) {
sendElements.push(SendMsgElementConstructor.at(atQQ, atMember.uid, AtType.atUser, atMember.cardName || atMember.nick))
}
@@ -214,8 +305,7 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
case OB11MessageDataType.reply: {
let replyMsgId = sendMsg.data.id;
if (replyMsgId) {
replyMsgId = replyMsgId.toString()
const replyMsg = getHistoryMsgByShortId(replyMsgId)
const replyMsg = await dbUtil.getMsgBySeqId(replyMsgId)
if (replyMsg) {
sendElements.push(SendMsgElementConstructor.reply(replyMsg.msgSeq, replyMsg.msgId, replyMsg.senderUin, replyMsg.senderUin))
}
@@ -229,19 +319,33 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
}
}
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} = (await uri2local(uuidv4(), file))
const {path, isLocal, fileName} = (await uri2local(file))
if (path) {
if (!isLocal) { // 只删除http和base64转过来的文件
deleteAfterSentFiles.push(path)
}
if (sendMsg.type === OB11MessageDataType.image) {
sendElements.push(await SendMsgElementConstructor.pic(path))
const constructorMap = {
[OB11MessageDataType.image]: SendMsgElementConstructor.pic,
[OB11MessageDataType.voice]: SendMsgElementConstructor.ptt,
[OB11MessageDataType.video]: SendMsgElementConstructor.video,
[OB11MessageDataType.file]: SendMsgElementConstructor.file,
}
if (sendMsg.type === OB11MessageDataType.file) {
log("发送文件", path, payloadFileName || fileName)
sendElements.push(await SendMsgElementConstructor.file(path, 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 SendMsgElementConstructor.ptt(path))
sendElements.push(await constructorMap[sendMsg.type](path));
}
}
}
@@ -262,11 +366,48 @@ export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
throw ("消息体无法解析")
}
const returnMsg = await NTQQApi.sendMsg(peer, sendElements, waitComplete, 20000);
addHistoryMsg(returnMsg)
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,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

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

View File

@@ -15,22 +15,54 @@ 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(),
]

View File

@@ -2,17 +2,20 @@ export type BaseCheckResult = ValidCheckResult | InvalidCheckResult
export interface ValidCheckResult {
valid: true
[k: string | number]: any
}
export interface InvalidCheckResult {
valid: false
message: string
[k: string | number]: any
}
export enum ActionName {
TestForwardMsg = "test_forward_msg",
Debug = "llonebot_debug",
SendLike = "send_like",
GetLoginInfo = "get_login_info",
GetFriendList = "get_friend_list",
GetGroupInfo = "get_group_info",
@@ -24,11 +27,25 @@ export enum ActionName {
SendGroupMsg = "send_group_msg",
SendPrivateMsg = "send_private_msg",
DeleteMsg = "delete_msg",
SetGroupAddRequest = "set_group_add_request",
SetFriendAddRequest = "set_friend_add_request",
SetGroupLeave = "set_group_leave",
GetVersionInfo = "get_version_info",
GetStatus = "get_status",
CanSendRecord = "can_send_record",
CanSendImage = "can_send_image",
SetGroupKick = "set_group_kick",
SetGroupBan = "set_group_ban",
SetGroupWholeBan = "set_group_whole_ban",
SetGroupAdmin = "set_group_admin",
SetGroupCard = "set_group_card",
SetGroupName = "set_group_name",
GetImage = "get_image",
GetRecord = "get_record",
// 以下为go-cqhttp api
GoCQHTTP_SendGroupForwardMsg = "send_group_forward_msg",
GoCQHTTP_SendPrivateForwardMsg = "send_private_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

@@ -1,4 +1,5 @@
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> {
@@ -8,21 +9,21 @@ export class OB11Response {
data: data,
message: message,
wording: message,
echo: ""
echo: null
}
}
static ok<T>(data: T, echo: string = "") {
static ok<T>(data: T, echo: any = null) {
let res = OB11Response.res<T>(data, "ok", 0)
if (echo) {
if (!isNull(echo)) {
res.echo = echo;
}
return res;
}
static error(err: string, retcode: number, echo: string = "") {
static error(err: string, retcode: number, echo: any = null) {
let res = OB11Response.res(null, "failed", retcode, err)
if (echo) {
if (!isNull(echo)) {
res.echo = echo;
}
return res;

View File

@@ -5,24 +5,27 @@ import {
OB11Message,
OB11MessageData,
OB11MessageDataType,
OB11User
OB11User,
OB11UserSex
} from "./types";
import {AtType, ChatType, Group, GroupMember, IMAGE_HTTP_HOST, RawMessage, SelfInfo, User} from '../ntqqapi/types';
import {getFriend, getGroupMember, getHistoryMsgBySeq, selfInfo} from '../common/data';
import {file2base64, getConfigUtil, log} from "../common/utils";
import {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} = getConfigUtil().getConfig()
const {enableLocalFile2Url, ob11: {messagePostFormat}} = getConfigUtil().getConfig()
const message_type = msg.chatType == ChatType.group ? "group" : "private";
const resMsg: OB11Message = {
self_id: parseInt(selfInfo.uin),
user_id: parseInt(msg.senderUin),
time: parseInt(msg.msgTime) || 0,
time: parseInt(msg.msgTime) || Date.now(),
message_id: msg.msgShortId,
real_id: msg.msgId,
message_type: msg.chatType == ChatType.group ? "group" : "private",
@@ -34,7 +37,8 @@ export class OB11Constructor {
raw_message: "",
font: 14,
sub_type: "friend",
message: [],
message: messagePostFormat === 'string' ? '' : [],
message_format: messagePostFormat === 'string' ? 'string' : 'array',
post_type: selfInfo.uin == msg.senderUin ? EventType.MESSAGE_SENT : EventType.MESSAGE,
}
if (msg.chatType == ChatType.group) {
@@ -53,6 +57,10 @@ export class OB11Constructor {
}
} else if (msg.chatType == ChatType.temp) {
resMsg.sub_type = "group"
const tempGroupCode = tempGroupCodeMap[msg.peerUin]
if (tempGroupCode) {
resMsg.group_id = parseInt(tempGroupCode)
}
}
for (let element of msg.elements) {
@@ -63,7 +71,7 @@ export class OB11Constructor {
if (element.textElement && element.textElement?.atType !== AtType.notAt) {
message_data["type"] = OB11MessageDataType.at
if (element.textElement.atType == AtType.atAll) {
message_data["data"]["mention"] = "all"
// message_data["data"]["mention"] = "all"
message_data["data"]["qq"] = "all"
} else {
let atUid = element.textElement.atNtUid
@@ -75,43 +83,94 @@ export class OB11Constructor {
}
}
if (atQQ) {
message_data["data"]["mention"] = atQQ
// message_data["data"]["mention"] = atQQ
message_data["data"]["qq"] = atQQ
}
}
} else if (element.textElement) {
message_data["type"] = "text"
let text= element.textElement.content
if (!text.trim()){
let text = element.textElement.content
if (!text.trim()) {
continue;
}
message_data["data"]["text"] = text
if (text){
resMsg.raw_message += text
}
} else if (element.picElement) {
message_data["type"] = "image"
message_data["data"]["file_id"] = element.picElement.fileUuid
message_data["data"]["path"] = element.picElement.sourcePath
message_data["data"]["file"] = element.picElement.sourcePath
message_data["data"]["http_file"] = IMAGE_HTTP_HOST + element.picElement.originImageUrl
try {
await NTQQApi.downloadMedia(msg.msgId, msg.chatType, msg.peerUid,
element.elementId, element.picElement.thumbPath.get(0), element.picElement.sourcePath)
} catch (e) {
}
} else if (element.replyElement) {
message_data["type"] = "reply"
const replyMsg = getHistoryMsgBySeq(element.replyElement.replayMsgSeq)
if (replyMsg) {
message_data["data"]["id"] = replyMsg.msgShortId
} else {
continue
log("收到回复消息", element.replyElement.replayMsgSeq)
try{
const replyMsg = await dbUtil.getMsgBySeqId(element.replyElement.replayMsgSeq)
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.filePath
message_data["data"]["file_id"] = element.pttElement.fileUuid
message_data["data"]["file"] = element.pttElement.fileName
message_data["data"]["path"] = element.pttElement.filePath
// message_data["data"]["file_id"] = element.pttElement.fileUuid
message_data["data"]["file_size"] = element.pttElement.fileSize
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 => {
@@ -126,25 +185,37 @@ export class OB11Constructor {
message_data["type"] = OB11MessageDataType.face;
message_data["data"]["id"] = element.faceElement.faceIndex.toString();
}
if (message_data.data.file) {
let filePath: string = message_data.data.file;
if (!enableLocalFile2Url) {
message_data.data.file = "file://" + filePath
} else { // 不使用本地路径
if (message_data.data.http_file) {
message_data.data.file = message_data.data.http_file
} else {
let {err, data} = await file2base64(filePath);
if (err) {
log("文件转base64失败", filePath, err)
} else {
message_data.data.file = "base64://" + data
}
}
}
}
// 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) {
resMsg.message.push(message_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();
@@ -184,7 +255,19 @@ export class OB11Constructor {
group_id: parseInt(group_id),
user_id: parseInt(member.uin),
nickname: member.nick,
card: member.cardName
card: member.cardName,
sex: OB11UserSex.unknown,
age: 0,
area: "",
level: 0,
join_time: 0, // 暂时没法获取
last_sent_time: 0, // 暂时没法获取
title_expire_time: 0,
unfriendly: false,
card_changeable: true,
is_robot: member.isRobot,
shut_up_timestamp: member.shutUpTime,
role: OB11Constructor.groupMemberRole(member.role),
}
}
@@ -205,4 +288,4 @@ export class OB11Constructor {
static groups(groups: Group[]): OB11Group[] {
return groups.map(OB11Constructor.group)
}
}
}

View File

@@ -29,7 +29,7 @@ function h(type: string, data: any) {
}
}
export function parseCQCode(source: string): OB11MessageData[] {
export function decodeCQCode(source: string): OB11MessageData[] {
const elements: any[] = []
let result: ReturnType<typeof from>
while ((result = from(source))) {
@@ -44,6 +44,28 @@ export function parseCQCode(source: string): OB11MessageData[] {
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

@@ -10,7 +10,7 @@ export enum EventType {
export abstract class OB11BaseEvent {
time = new Date().getTime();
time = Math.floor(Date.now() / 1000);
self_id = parseInt(selfInfo.uin);
post_type: EventType;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,21 @@
import {getConfigUtil, log} from "../../../common/utils";
import * as WebSocket from "ws";
import {selfInfo} from "../../../common/data";
import {LifeCycleSubType, OB11LifeCycleEvent} from "../../event/meta/OB11LifeCycleEvent";
import {ActionName} from "../../action/types";
import {OB11Response} from "../../action/utils";
import BaseAction from "../../action/BaseAction";
import {actionMap} from "../../action";
import {registerWsEventSender, unregisterWsEventSender} from "../postevent";
import {postWsEvent, registerWsEventSender, unregisterWsEventSender} from "../postOB11Event";
import {wsReply} from "./reply";
import {WebSocket as WebSocketClass} from "ws";
import {OB11HeartbeatEvent} from "../../event/meta/OB11HeartbeatEvent";
export let rwsList: ReverseWebsocket[] = [];
export class ReverseWebsocket {
public websocket: WebSocket.WebSocket;
public websocket: WebSocketClass;
public url: string;
private running: boolean = false;
@@ -33,8 +35,8 @@ export class ReverseWebsocket {
}
public async onmessage(msg: string) {
let receiveData: { action: ActionName, params: any, echo?: string } = {action: null, params: {}}
let echo = ""
let receiveData: { action: ActionName, params: any, echo?: any } = {action: null, params: {}}
let echo = null
try {
receiveData = JSON.parse(msg.toString())
echo = receiveData.echo
@@ -75,8 +77,8 @@ export class ReverseWebsocket {
}
private connect() {
const {token} = getConfigUtil().getConfig()
this.websocket = new WebSocket.WebSocket(this.url, {
const {token, heartInterval} = getConfigUtil().getConfig()
this.websocket = new WebSocketClass(this.url, {
handshakeTimeout: 2000,
perMessageDeflate: false,
headers: {
@@ -100,7 +102,11 @@ export class ReverseWebsocket {
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();
});

View File

@@ -2,7 +2,7 @@ import {WebSocket} from "ws";
import {getConfigUtil, log} from "../../../common/utils";
import {actionMap} from "../../action";
import {OB11Response} from "../../action/utils";
import {postWsEvent, registerWsEventSender, unregisterWsEventSender} from "../postevent";
import {postWsEvent, registerWsEventSender, unregisterWsEventSender} from "../postOB11Event";
import {ActionName} from "../../action/types";
import BaseAction from "../../action/BaseAction";
import {LifeCycleSubType, OB11LifeCycleEvent} from "../../event/meta/OB11LifeCycleEvent";
@@ -10,6 +10,7 @@ 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;
@@ -18,7 +19,7 @@ class OB11WebsocketServer extends WebsocketServerBase {
wsClient.send(JSON.stringify(OB11Response.res(null, "failed", 1403, "token验证失败")))
}
async handleAction(wsClient: WebSocket, actionName: string, params: any, echo?: string) {
async handleAction(wsClient: WebSocket, actionName: string, params: any, echo?: any) {
const action: BaseAction<any, any> = actionMap.get(actionName);
if (!action) {
return wsReply(wsClient, OB11Response.error("不支持的api " + actionName, 1404, echo))
@@ -34,8 +35,8 @@ class OB11WebsocketServer extends WebsocketServerBase {
onConnect(wsClient: WebSocket, url: string, req: IncomingMessage) {
if (url == "/api" || url == "/api/" || url == "/") {
wsClient.on("message", async (msg) => {
let receiveData: { action: ActionName, params: any, echo?: string } = {action: null, params: {}}
let echo = ""
let receiveData: { action: ActionName, params: any, echo?: any } = {action: null, params: {}}
let echo = null
try {
receiveData = JSON.parse(msg.toString())
echo = receiveData.echo
@@ -58,7 +59,7 @@ class OB11WebsocketServer extends WebsocketServerBase {
}
const {heartInterval} = getConfigUtil().getConfig();
const wsClientInterval = setInterval(() => {
postWsEvent(new OB11HeartbeatEvent(true, true, heartInterval));
postWsEvent(new OB11HeartbeatEvent(selfInfo.online, true, heartInterval));
}, heartInterval); // 心跳包
wsClient.on("close", () => {
log("event上报ws客户端已断开")

View File

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

View File

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

View File

@@ -1,15 +1,22 @@
import {CONFIG_DIR, isGIF} from "../common/utils";
import * as path from 'path';
import {OB11MessageData} from "./types";
import {DATA_DIR, isGIF, log} from "../common/utils";
import {v4 as uuidv4} from "uuid";
import * as path from 'node:path';
import {fileCache} from "../common/data";
import * as fileType from 'file-type';
const fs = require("fs").promises;
export async function uri2local(fileName: string, uri: string){
let filePath = path.join(CONFIG_DIR, fileName)
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
}
@@ -19,6 +26,7 @@ export async function uri2local(fileName: string, uri: string){
try {
const buffer = Buffer.from(base64Data, 'base64');
await fs.writeFile(filePath, buffer);
} catch (e: any) {
res.errMsg = `base64文件下载失败,` + e.toString()
return res
@@ -33,29 +41,63 @@ export async function uri2local(fileName: string, uri: string){
let blob = await fetchRes.blob();
let buffer = await blob.arrayBuffer();
try {
const pathInfo = path.parse(decodeURIComponent(url.pathname))
if (pathInfo.name){
fileName = pathInfo.name
if (pathInfo.ext){
fileName += pathInfo.ext
res.ext = pathInfo.ext
}
}
res.fileName = fileName
filePath = path.join(DATA_DIR, uuidv4() + fileName)
await fs.writeFile(filePath, Buffer.from(buffer));
} catch (e: any) {
res.errMsg = `${url}下载失败,` + e.toString()
return res
}
} else if (url.protocol === "file:"){
// await fs.copyFile(url.pathname, filePath);
let pathname = decodeURIComponent(url.pathname)
if (process.platform === "win32"){
filePath = pathname.slice(1)
}
else{
filePath = pathname
} else {
let pathname: string;
if (url.protocol === "file:") {
// await fs.copyFile(url.pathname, filePath);
pathname = decodeURIComponent(url.pathname)
if (process.platform === "win32") {
filePath = pathname.slice(1)
} else {
filePath = pathname
}
} else {
const cache = 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";
// 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

1
src/version.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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