@karinjs/adapter-qqbot 2.0.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -255
- package/dist/apps/cleanup.d.ts +5 -0
- package/dist/apps/cleanup.js +22 -0
- package/dist/apps/login.d.ts +1 -3
- package/dist/apps/login.js +65 -16
- package/dist/common-by1GpV0b.js +49 -0
- package/dist/config-B_Gc9JKZ.js +253 -0
- package/dist/index.d.ts +511 -25
- package/dist/index.js +2327 -242
- package/dist/message-Dt4QkDYn.js +1739 -0
- package/dist/{onboard-BX8g8Wik.js → onboard-BXQ1cG8h.js} +10 -7
- package/dist/utils-DIq0lGMt.js +4 -0
- package/dist/web.config.d.ts +21 -19
- package/dist/web.config.js +232 -88
- package/package.json +6 -11
- package/dist/config-H7LrvlNU.js +0 -197
- package/dist/utils-DO4gEkVN.js +0 -3
package/README.md
CHANGED
|
@@ -1,29 +1,6 @@
|
|
|
1
1
|
# @karinjs/adapter-qqbot
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](./LICENSE)
|
|
5
|
-
|
|
6
|
-
为 [Karin](https://github.com/KarinJS/Karin) 框架提供 QQ 官方机器人接入能力。
|
|
7
|
-
|
|
8
|
-
> **2.0 已发布**:完整重写,对齐 QQ 开放平台 2026 年起的新事件协议
|
|
9
|
-
> (`GROUP_MESSAGE_CREATE` 全量群聊消息 + `mentions` 数组),并适配
|
|
10
|
-
> Markdown / Keyboard 全量开放后的发送方式。
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## 特性
|
|
15
|
-
|
|
16
|
-
- 🌐 **完整事件支持**:群聊(@/全量)、单聊、频道、频道私信、按钮点击、机器人入退群、主动消息开关
|
|
17
|
-
- 📨 **双发送通道**:自动选择「经典通道」(text + media)或「Markdown 通道」(msg_type=2 + keyboard),按消息内容与配置开关
|
|
18
|
-
- 🔌 **双接入方式**:WebSocket(推荐,无需公网)/ Webhook(QQ 主动推送)
|
|
19
|
-
- ♻️ **WebSocket Resume**:断网自动重连,携带 `session_id` / `seq` 通过 `op:6` 恢复,减少消息丢失
|
|
20
|
-
- 🔑 **扫码登录**:插件首次启动自动二维码引导,授权后写回 `config.json`
|
|
21
|
-
- 🧩 **按钮回调**:完整接入 `INTERACTION_CREATE`,自动 ack 并以普通消息事件投递给业务层
|
|
22
|
-
- 🛡️ **签名校验**:webhook 入口自动用 ed25519 校验
|
|
23
|
-
- 📐 **完整 TypeScript 类型**
|
|
24
|
-
- 🧹 **零 1.x 残留**:不再依赖 Markdown 模板 / keyboard 模板 / 中转服务
|
|
25
|
-
|
|
26
|
-
---
|
|
3
|
+
Karin 的 QQ 官方机器人适配器。
|
|
27
4
|
|
|
28
5
|
## 安装
|
|
29
6
|
|
|
@@ -31,59 +8,13 @@
|
|
|
31
8
|
pnpm add @karinjs/adapter-qqbot
|
|
32
9
|
```
|
|
33
10
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
## 快速开始
|
|
11
|
+
要求:Node.js >= 22、Karin >= 1.15
|
|
39
12
|
|
|
40
|
-
|
|
13
|
+
## 配置
|
|
41
14
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
==========================================
|
|
46
|
-
未检测到 QQBot 配置
|
|
47
|
-
请使用任意可用 bot(如 OneBot / Console 适配器)
|
|
48
|
-
对机器人发送指令: #QQ登录
|
|
49
|
-
扫码授权完成后会自动写入配置并完成初始化
|
|
50
|
-
|
|
51
|
-
⚠ 注意:扫码授权会刷新该机器人的 secret,旧 secret 立即失效
|
|
52
|
-
==========================================
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
按提示,在**任意已连接的 bot 适配器**(OneBot、Console、Discord …)下,给机器人发送:
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
#QQ登录 # 或 #qq登录 / #qqlogin / #QQLogin
|
|
59
|
-
```
|
|
15
|
+
首次启动没有 QQBot 配置时,使用**任意已连接的机器人**或在**控制台中**发送 `#QQ登录` 完成扫码授权。
|
|
60
16
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
```
|
|
64
|
-
▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢
|
|
65
|
-
▢ ASCII QR ▢▢▢▢▢▢▢
|
|
66
|
-
▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢
|
|
67
|
-
|
|
68
|
-
请使用 QQ 扫描上方二维码,或在手机 QQ 打开链接:
|
|
69
|
-
https://q.qq.com/qqbot/openclaw/connect.html?task_id=...
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
手机 QQ 扫码授权后,插件自动:
|
|
73
|
-
|
|
74
|
-
1. 从平台拉取 `appId` + 加密的 `secret`
|
|
75
|
-
2. 本地 AES-256-GCM 解密
|
|
76
|
-
3. 调 `/users/@me` 获取昵称
|
|
77
|
-
4. 写入 `${karin-base}/@karinjs-adapter-qqbot/config/config.json`
|
|
78
|
-
5. `watch` 回调自动初始化 bot,无需重启
|
|
79
|
-
|
|
80
|
-
> 该指令同样可用于**新增账号**和**刷新已有账号的 secret**。
|
|
81
|
-
|
|
82
|
-
### 方式 B:手动配置
|
|
83
|
-
|
|
84
|
-
在 [QQ 开放平台](https://bot.q.qq.com/) 创建机器人,记录 `AppID` 与 `Secret`,然后编辑:
|
|
85
|
-
|
|
86
|
-
`@karinjs/adapter-qqbot/config/config.json`:
|
|
17
|
+
也可以访问 Karin WebUI 可视化编辑 或者 手动编辑:`@karinjs-adapter-qqbot/config/config.json`。
|
|
87
18
|
|
|
88
19
|
```json
|
|
89
20
|
[
|
|
@@ -91,236 +22,162 @@ pnpm add @karinjs/adapter-qqbot
|
|
|
91
22
|
"name": "我的机器人",
|
|
92
23
|
"appId": "1234567890",
|
|
93
24
|
"secret": "your-secret",
|
|
94
|
-
"prodApi": "https://api.sgroup.qq.com",
|
|
95
|
-
"sandboxApi": "https://sandbox.api.sgroup.qq.com",
|
|
96
|
-
"tokenApi": "https://bots.qq.com/app/getAppAccessToken",
|
|
97
25
|
"sandbox": false,
|
|
98
26
|
"qqEnable": true,
|
|
99
27
|
"guildEnable": true,
|
|
100
28
|
"guildMode": 0,
|
|
101
|
-
"
|
|
102
|
-
|
|
29
|
+
"proxy": {
|
|
30
|
+
"prodApi": "https://api.sgroup.qq.com",
|
|
31
|
+
"sandboxApi": "https://sandbox.api.sgroup.qq.com",
|
|
32
|
+
"tokenApi": "https://bots.qq.com/app/getAppAccessToken",
|
|
33
|
+
"prodWs": "wss://api.sgroup.qq.com/websocket/",
|
|
34
|
+
"sandboxWs": "wss://sandbox.api.sgroup.qq.com/websocket/"
|
|
35
|
+
},
|
|
103
36
|
"keyboard": { "enable": true },
|
|
37
|
+
"markdown": { "enable": true },
|
|
104
38
|
"event": { "type": 2 }
|
|
105
39
|
}
|
|
106
40
|
]
|
|
107
41
|
```
|
|
108
42
|
|
|
109
|
-
|
|
43
|
+
- `event.type`: `0` 关闭、`1` Webhook、`2` WebSocket(默认)。
|
|
44
|
+
- `guildMode`: `0` 公域,只收 @ 消息;`1` 私域,接收全部频道消息。
|
|
45
|
+
- `proxy`: 自定义连接代理地址。`prodApi` / `sandboxApi` 作为 OpenAPI 根地址使用,后端拼接路径时会兼容末尾 `/`;`tokenApi` 是完整接口地址;`prodWs` / `sandboxWs` 是完整 WebSocket 网关地址,支持 `ws://` 和 `wss://`,路径和查询参数会按配置原样连接。
|
|
46
|
+
- `markdown.enable`: 是否让普通文本和图片自动合并为 Markdown 消息,默认开启。关闭后普通图片会改为单独上传发送;插件显式传入的 `segment.markdown` 不受此开关影响。
|
|
110
47
|
|
|
111
|
-
|
|
48
|
+
## 按钮
|
|
112
49
|
|
|
113
|
-
|
|
50
|
+
按钮一般和 Markdown 一起发送,常用有三种:
|
|
114
51
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
| `appId` | string | — | 机器人 AppID(**必填**) |
|
|
119
|
-
| `secret` | string | — | 机器人 Secret(**必填**) |
|
|
120
|
-
| `prodApi` | string | `https://api.sgroup.qq.com` | 正式环境 API 基址 |
|
|
121
|
-
| `sandboxApi` | string | `https://sandbox.api.sgroup.qq.com` | 沙盒环境 API 基址 |
|
|
122
|
-
| `tokenApi` | string | `https://bots.qq.com/app/getAppAccessToken` | access_token 获取接口 |
|
|
123
|
-
| `sandbox` | boolean | `false` | 是否启用沙盒环境 |
|
|
124
|
-
| `qqEnable` | boolean | `true` | 是否处理单聊 / 群聊 |
|
|
125
|
-
| `guildEnable` | boolean | `true` | 是否处理频道消息 |
|
|
126
|
-
| `guildMode` | `0` \| `1` | `0` | `0` 公域(只接 @ 消息)/ `1` 私域(所有消息) |
|
|
127
|
-
| `regex` | object[] | `[{reg:"^/", rep:"#"}]` | 收到消息后对文本执行的正则替换 |
|
|
128
|
-
| `keyboard.enable` | boolean | `true` | 是否将文本中的 URL **自动**转为 keyboard 按钮 |
|
|
129
|
-
| `event.type` | `0` \| `1` \| `2` | `2` | 事件接收方式:0 关闭 / 1 webhook / 2 WebSocket |
|
|
52
|
+
- 跳转按钮:点击后打开 `link`。
|
|
53
|
+
- 指令按钮:点击后把 `data` 发送成一条普通消息。
|
|
54
|
+
- 回调按钮:点击后不会在聊天框发消息,但适配器会下发一条 Karin 消息事件,消息内容就是 `data`。
|
|
130
55
|
|
|
131
|
-
|
|
56
|
+
下面这个示例先发送一组按钮,然后分别接住“指令按钮”和“回调按钮”的点击结果:
|
|
132
57
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
58
|
+
```ts
|
|
59
|
+
import karin, { segment } from 'node-karin'
|
|
60
|
+
|
|
61
|
+
export const buttonDemo = karin.command(/^按钮示例$/, async (e) => {
|
|
62
|
+
await e.reply([
|
|
63
|
+
segment.markdown('#### 按钮示例\n请选择一个操作:'),
|
|
64
|
+
segment.button([
|
|
65
|
+
{ text: '打开文档', link: 'https://bot.q.qq.com/wiki/' },
|
|
66
|
+
{ text: '发送指令', data: '按钮示例 帮助', enter: true },
|
|
67
|
+
{ text: '回调确认', callback: true, data: '按钮示例 确认' },
|
|
68
|
+
]),
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const buttonHelp = karin.command(/^按钮示例 帮助$/, async (e) => {
|
|
73
|
+
await e.reply('这是指令按钮发送出来的消息。')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export const buttonConfirm = karin.command(/^按钮示例 确认$/, async (e) => {
|
|
77
|
+
await e.reply(`收到回调按钮:${e.msg}`)
|
|
78
|
+
})
|
|
79
|
+
```
|
|
136
80
|
|
|
137
|
-
|
|
138
|
-
- 无需公网 IP / HTTPS 证书
|
|
139
|
-
- intents 自动探测,从最大权限位逐级回退到可用集合
|
|
140
|
-
- **自动 Resume**:断网时记录 `session_id` / `seq`,下次连接走 `op:6` 让服务端补发丢失的事件;服务端拒绝 Resume 时降级 Identify
|
|
141
|
-
- 指数退避重连:`1.5s × 2^n + jitter`,上限 30s
|
|
81
|
+
如果你希望回复自动带上一组按钮,可以把按钮单独注册出来。下面这个分页示例会根据当前消息生成“上一页 / 下一页”:
|
|
142
82
|
|
|
143
|
-
|
|
83
|
+
```ts
|
|
84
|
+
import karin, { segment } from 'node-karin'
|
|
144
85
|
|
|
145
|
-
|
|
146
|
-
- 自动处理 `op:13` 鉴权回调(签名回包)
|
|
147
|
-
- 普通事件用 ed25519 校验签名
|
|
148
|
-
- 需要公网 HTTPS:在 QQ 开放平台后台配置「统一回调地址」为 `https://your-domain/qqbot/webhook`
|
|
86
|
+
const getPage = (msg = '') => Number(msg.match(/^菜单(?:\s+(\d+))?$/)?.[1] || 1)
|
|
149
87
|
|
|
150
|
-
|
|
88
|
+
export const menuDemo = karin.command(/^菜单(?:\s+\d+)?$/, async (e) => {
|
|
89
|
+
const page = getPage(e.msg)
|
|
90
|
+
await e.reply(segment.markdown(`#### 菜单\n当前第 ${page} 页`))
|
|
91
|
+
})
|
|
151
92
|
|
|
152
|
-
|
|
93
|
+
export const menuKeyboard = karin.button(/^菜单(?:\s+\d+)?$/, (next, args) => {
|
|
94
|
+
const page = getPage(args?.e?.msg)
|
|
153
95
|
|
|
154
|
-
|
|
155
|
-
|
|
96
|
+
return segment.keyboard([
|
|
97
|
+
[
|
|
98
|
+
{ text: '上一页', data: `菜单 ${Math.max(1, page - 1)}`, enter: true },
|
|
99
|
+
{ text: '下一页', data: `菜单 ${page + 1}`, enter: true },
|
|
100
|
+
],
|
|
101
|
+
])
|
|
102
|
+
})
|
|
103
|
+
```
|
|
156
104
|
|
|
157
|
-
|
|
158
|
-
- 图片合并为 `` 内嵌到同一条 markdown
|
|
159
|
-
- `segment.button(...)` / `segment.keyboard(...)` 自动挂到 `keyboard.content.rows`,与 markdown 同条下发
|
|
160
|
-
- 文本中 URL(开启 `keyboard.enable` 时)自动 `extractUrlButtons` 转 keyboard 按钮
|
|
161
|
-
- 视频 / 语音 / 文件不能进 markdown,由 pipeline 自动以 `msg_type=7` 紧随 markdown 主消息**并发补发**
|
|
105
|
+
简单理解:`karin.button` 是“自动追加按钮”的规则。适配器回复时会自动用 `buttonHandle(e.msg, { e })` 查找匹配规则,所以用户发送 `菜单 2`,就会命中 `/^菜单(?:\s+\d+)?$/`,按钮函数再通过 `args?.e?.msg` 算出当前页。
|
|
162
106
|
|
|
163
|
-
|
|
107
|
+
自己调用 `buttonHandle` 时只记三点:
|
|
164
108
|
|
|
165
|
-
|
|
109
|
+
- 参数一是匹配文本,通常填 `e.msg`,也可以是 `'菜单'`。
|
|
110
|
+
- 参数二是给按钮函数的上下文,常用 `{ e }`;需要状态可以写 `{ e, page: 2 }`。
|
|
111
|
+
- 一个规则里调用 `next()`,才会继续匹配后面的规则。
|
|
166
112
|
|
|
167
|
-
|
|
113
|
+
回调按钮的 `data` 会变成一条 Karin 消息内容,所以建议直接写成插件能识别的命令,例如 `按钮示例 确认`、`菜单 下一页`。
|
|
168
114
|
|
|
169
|
-
|
|
170
|
-
2. 将事件转为对应场景的消息事件投递:
|
|
171
|
-
- `chat_type=0` → guild message
|
|
172
|
-
- `chat_type=1` → group message
|
|
173
|
-
- `chat_type=2` → friend message
|
|
174
|
-
3. 消息 elements 结构:
|
|
115
|
+
## 发送消息与资源文件
|
|
175
116
|
|
|
176
|
-
|
|
177
|
-
[
|
|
178
|
-
segment.at(selfId), // 群 / 单聊场景自动补
|
|
179
|
-
segment.text(button_data),
|
|
180
|
-
segment.json('{"tag":"qqbot-button-click","button_id":"...","button_data":"...",...}'),
|
|
181
|
-
// srcReply 末尾自动追加 segment.pasmsg(id, 'event')
|
|
182
|
-
]
|
|
183
|
-
```
|
|
117
|
+
适配器会尽量帮你发送图片、视频、语音和文件。推荐配置 `fileToUrl` 上传处理器,把本地文件、截图、Base64 等资源上传到你的图床、对象存储或 CDN,并返回一个 QQ 能访问的链接。
|
|
184
118
|
|
|
185
|
-
|
|
119
|
+
配置 `fileToUrl` 后:
|
|
186
120
|
|
|
187
|
-
|
|
121
|
+
- 本地图片可以正常嵌入 Markdown 消息。
|
|
122
|
+
- 视频、语音和文件会优先使用你返回的链接发送,通常比直接上传给 QQ 更稳定。
|
|
123
|
+
- 如果资源本身已经是 `http` / `https` 链接,适配器会直接使用它。
|
|
188
124
|
|
|
189
|
-
|
|
125
|
+
没有配置 `fileToUrl` 时:
|
|
190
126
|
|
|
191
|
-
|
|
127
|
+
- 单独发送图片、视频、语音、文件时,适配器会尝试直接交给 QQ 发送。
|
|
128
|
+
- 较大的资源会使用 QQ 的大文件上传流程,优先保证消息能发出去。
|
|
129
|
+
- 较大的图片或视频直接交给 QQ 发送时,QQ 客户端可能会把它显示成群文件,而不是图片或视频卡片;配置 `fileToUrl` 后通常可以避免这种显示问题。
|
|
130
|
+
- 直接交给 QQ 只是兜底方案,生产环境仍然建议准备自己的文件服务。
|
|
192
131
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
"d": {
|
|
197
|
-
"author": {
|
|
198
|
-
"id": "...", "member_openid": "...", "union_openid": "",
|
|
199
|
-
"username": "小布丁qwq", "bot": false
|
|
200
|
-
},
|
|
201
|
-
"content": " /分布 ",
|
|
202
|
-
"group_id": "...", "group_openid": "...",
|
|
203
|
-
"id": "ROBOT1.0_...",
|
|
204
|
-
"mentions": [
|
|
205
|
-
{ "is_you": true, "scope": "single", "username": "方舟生存飞升", "...": "..." }
|
|
206
|
-
],
|
|
207
|
-
"message_scene": { "ext": ["msg_idx=REFIDX_..."], "source": "default" },
|
|
208
|
-
"message_type": 0,
|
|
209
|
-
"timestamp": "2026-05-08T13:24:53+08:00"
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
```
|
|
132
|
+
> **注意:Markdown 里的图片必须配置 `fileToUrl`。**
|
|
133
|
+
>
|
|
134
|
+
> 只要你发送的内容包含 Markdown,并且 Markdown 里有图片、本地生成图、Base64 图等资源,就必须配置 `fileToUrl`。因为 Markdown 里的图片只能写成 QQ 能访问的链接,适配器不能把 Markdown 文本里的图片自动当成附件上传。
|
|
213
135
|
|
|
214
|
-
|
|
136
|
+
你可以在自己的 Karin 插件中编写并注册 `fileToUrl` Handler:
|
|
215
137
|
|
|
216
|
-
|
|
217
|
-
-
|
|
218
|
-
|
|
219
|
-
|
|
138
|
+
```js
|
|
139
|
+
// plugins/karin-plugin-example/fileToUrl.js
|
|
140
|
+
import karin, { common } from 'node-karin'
|
|
141
|
+
import size from 'image-size'
|
|
220
142
|
|
|
221
|
-
|
|
143
|
+
export const uploadResource = karin.handler('fileToUrl', async (args) => {
|
|
144
|
+
const { file, type, filename } = args
|
|
145
|
+
const buffer = await common.buffer(file)
|
|
222
146
|
|
|
223
|
-
|
|
147
|
+
// 由你实现:上传到图床/对象存储,返回可公开访问的 URL。
|
|
148
|
+
const url = await uploadToYourStorage(buffer, filename || 'file.bin')
|
|
224
149
|
|
|
225
|
-
|
|
226
|
-
- **引用回复**:传入 `segment.reply(messageId)`,仅附加在第一条消息上
|
|
227
|
-
- **被动消息白名单**:
|
|
228
|
-
- 单聊 `event_id` 接受:`INTERACTION_CREATE` / `C2C_MSG_RECEIVE` / `FRIEND_ADD`
|
|
229
|
-
- 群聊 `event_id` 接受:`INTERACTION_CREATE` / `GROUP_ADD_ROBOT` / `GROUP_MSG_RECEIVE`
|
|
150
|
+
if (type !== 'image') return { url }
|
|
230
151
|
|
|
231
|
-
|
|
152
|
+
const dimension = size(buffer) // 获取图片宽高
|
|
153
|
+
return {
|
|
154
|
+
url,
|
|
155
|
+
width: dimension.width || 100,
|
|
156
|
+
height: dimension.height || 100
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
```
|
|
232
160
|
|
|
233
|
-
|
|
161
|
+
> **注意:Handler 插件的 key 必须精确为 `fileToUrl`。**
|
|
162
|
+
>
|
|
163
|
+
> 图片必须返回 `{ url, width, height }`;其他资源返回 `{ url }`。
|
|
234
164
|
|
|
235
|
-
|
|
165
|
+
建议生产环境都配置 `fileToUrl`。这样图片、视频和文件都可以先上传到你自己的文件服务,再交给 QQ 发送,成功率和可控性都会更好。
|
|
236
166
|
|
|
237
|
-
|
|
238
|
-
- 沙盒、QQ 场景、频道场景、频道私域模式开关
|
|
239
|
-
- 正则规则列表(每行 `<regex> <replacement>`)
|
|
240
|
-
- **URL 自动按钮** 开关(`keyboard.enable`)
|
|
241
|
-
- 事件接收方式(关闭 / Webhook / WebSocket)
|
|
167
|
+
## 事件
|
|
242
168
|
|
|
243
|
-
|
|
169
|
+
- 支持 QQ 单聊、群聊、频道、频道私信和按钮回调。
|
|
170
|
+
- 支持 `GROUP_MESSAGE_CREATE` 全量群消息和 `author.member_role` 身份字段。
|
|
171
|
+
- 支持 `GROUP_MEMBER_ADD`、`GROUP_MEMBER_REMOVE` 群成员进退群事件。
|
|
172
|
+
- 单聊同一 `msg_id` 最多回复 4 次;群聊规则保持官方限制。
|
|
244
173
|
|
|
245
174
|
## 开发
|
|
246
175
|
|
|
247
176
|
```bash
|
|
248
|
-
git clone https://github.com/KarinJS/karin-plugin-adapter-qqbot.git
|
|
249
|
-
cd karin-plugin-adapter-qqbot
|
|
250
177
|
pnpm install
|
|
251
|
-
pnpm
|
|
252
|
-
pnpm build # tsc + tsdown
|
|
178
|
+
pnpm build
|
|
253
179
|
```
|
|
254
180
|
|
|
255
|
-
源码结构(2.0):
|
|
256
|
-
|
|
257
|
-
```
|
|
258
|
-
src/
|
|
259
|
-
├── connection/
|
|
260
|
-
│ ├── routing.ts express 路由聚合
|
|
261
|
-
│ ├── webhook.ts POST /qqbot/webhook
|
|
262
|
-
│ ├── transport.ts 事件总线(appId → 业务)
|
|
263
|
-
│ └── ws/
|
|
264
|
-
│ ├── client.ts 单连接生命周期 + 心跳 + Resume
|
|
265
|
-
│ ├── intents.ts intents 探测与回退
|
|
266
|
-
│ └── manager.ts 多 bot 连接管理
|
|
267
|
-
├── core/
|
|
268
|
-
│ ├── index.ts createBot / destroyBot / initQQBotAdapter
|
|
269
|
-
│ ├── api/ QQBotApi 门面 + http/messages/media/interaction/meta/builders
|
|
270
|
-
│ ├── internal/axios.ts access_token 缓存 + 自动刷新
|
|
271
|
-
│ ├── adapter/
|
|
272
|
-
│ │ ├── base.ts AdapterQQBot
|
|
273
|
-
│ │ ├── grouping.ts 消息归类
|
|
274
|
-
│ │ ├── pipeline-qq.ts QQ 单聊/群聊发送管线
|
|
275
|
-
│ │ ├── pipeline-guild.ts 频道发送管线
|
|
276
|
-
│ │ └── text-to-md.ts URL→ 按钮、图片→ markdown
|
|
277
|
-
│ ├── event/
|
|
278
|
-
│ │ ├── dispatcher.ts 事件分发器
|
|
279
|
-
│ │ ├── message.ts 群/单/频道/私信消息
|
|
280
|
-
│ │ ├── notice.ts 入退群/好友/主动消息开关
|
|
281
|
-
│ │ ├── interaction.ts 按钮回调
|
|
282
|
-
│ │ └── conver.ts 报文 → karin elements
|
|
283
|
-
│ └── onboard/
|
|
284
|
-
│ ├── crypto.ts AES-256-GCM 解密
|
|
285
|
-
│ ├── portal.ts q.qq.com 接口
|
|
286
|
-
│ ├── qr.ts 终端二维码 + 轮询
|
|
287
|
-
│ └── index.ts runQrOnboard / needQrOnboard
|
|
288
|
-
├── apps/ karin app 插件
|
|
289
|
-
│ └── login.ts #QQ登录 指令
|
|
290
|
-
├── types/ 事件、配置、opcode 类型
|
|
291
|
-
└── utils/ 日志、配置读写、文本工具
|
|
292
|
-
|
|
293
|
-
demo/ 示例 / 烟测 app(不进 npm 包,不自动加载)
|
|
294
|
-
└── qqtest.ts QQBot 2.0 真号烟测命令集合
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
### 调试
|
|
298
|
-
|
|
299
|
-
WS 入站消息、bus 派发、dispatcher 路由等关键链路均通过 `logger.debug` 输出。
|
|
300
|
-
诊断「事件收不到」类问题时,把 karin 的 `log.level` 改成 `debug` 即可看到完整链路。
|
|
301
|
-
|
|
302
|
-
---
|
|
303
|
-
|
|
304
|
-
## 从 1.x 升级
|
|
305
|
-
|
|
306
|
-
2.0 是破坏式重写,**不提供配置自动迁移**。需要手动调整:
|
|
307
|
-
|
|
308
|
-
| 1.x | 2.0 |
|
|
309
|
-
| --- | --- |
|
|
310
|
-
| `markdown.mode: 0/1/2/3/4/5`(模式选择 / 模板) | 全部删除,永远默认走 markdown 通道 |
|
|
311
|
-
| `event.wsUrl` / `event.wsToken` | 删除(中转方案已废弃) |
|
|
312
|
-
| 新增 | `keyboard.enable: true`(默认,URL 自动转按钮) |
|
|
313
|
-
|
|
314
|
-
完整改动详见 [CHANGELOG.md](./CHANGELOG.md) 2.0.0 段落。
|
|
315
|
-
|
|
316
|
-
---
|
|
317
|
-
|
|
318
|
-
## 反馈
|
|
319
|
-
|
|
320
|
-
- Issue:https://github.com/KarinJS/karin-plugin-adapter-qqbot/issues
|
|
321
|
-
|
|
322
|
-
---
|
|
323
|
-
|
|
324
181
|
## License
|
|
325
182
|
|
|
326
183
|
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { n as config } from "../config-B_Gc9JKZ.js";
|
|
2
|
+
import { t as getMessageStore } from "../message-Dt4QkDYn.js";
|
|
3
|
+
import karin from "node-karin";
|
|
4
|
+
|
|
5
|
+
//#region src/apps/cleanup.ts
|
|
6
|
+
/**
|
|
7
|
+
* 判断是否有任意 QQBot 配置开启消息缓存。
|
|
8
|
+
*
|
|
9
|
+
* @returns true 表示需要注册消息缓存清理任务。
|
|
10
|
+
*/
|
|
11
|
+
const enabledMessageCache = () => {
|
|
12
|
+
return config().some((item) => item.messageCache.enable);
|
|
13
|
+
};
|
|
14
|
+
/** 每 10 分钟清理一次过期的 getMsg/getHistoryMsg 本地缓存。 */
|
|
15
|
+
const clearMessageCache = enabledMessageCache() ? karin.task("qqbot 适配器消息缓存清理", "*/10 * * * *", async () => await getMessageStore().cleanupExpired(), {
|
|
16
|
+
name: "qqbot 适配器消息缓存清理任务",
|
|
17
|
+
log: false,
|
|
18
|
+
type: "skip"
|
|
19
|
+
}) : void 0;
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { clearMessageCache };
|
package/dist/apps/login.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import * as _$node_karin0 from "node-karin";
|
|
2
|
-
|
|
3
1
|
//#region src/apps/login.d.ts
|
|
4
2
|
/**
|
|
5
3
|
* #QQ登录 指令
|
|
@@ -15,6 +13,6 @@ import * as _$node_karin0 from "node-karin";
|
|
|
15
13
|
*
|
|
16
14
|
* ⚠ 注意:扫码授权会刷新该 appId 的 secret,旧 secret 立即失效
|
|
17
15
|
*/
|
|
18
|
-
declare const qqLogin:
|
|
16
|
+
declare const qqLogin: import("node-karin").Command<keyof import("node-karin").MessageEventMap>;
|
|
19
17
|
//#endregion
|
|
20
18
|
export { qqLogin };
|
package/dist/apps/login.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as runQrOnboard } from "../onboard-
|
|
3
|
-
import karin
|
|
1
|
+
import { m as log } from "../config-B_Gc9JKZ.js";
|
|
2
|
+
import { n as runQrOnboard } from "../onboard-BXQ1cG8h.js";
|
|
3
|
+
import karin, { segment } from "node-karin";
|
|
4
4
|
|
|
5
5
|
//#region src/apps/login.ts
|
|
6
6
|
/**
|
|
@@ -19,30 +19,79 @@ import karin$1, { segment } from "node-karin";
|
|
|
19
19
|
*/
|
|
20
20
|
/** 互斥锁:避免并发扫码 */
|
|
21
21
|
let busy = false;
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* 判断触发 #QQ登录 的上游是否为 Karin 控制台适配器。
|
|
24
|
+
* Console 适配器无法直接在聊天窗口展示二维码图片,因此需要额外打印终端二维码。
|
|
25
|
+
*/
|
|
26
|
+
const isConsoleAdapter = (bot) => {
|
|
27
|
+
const selfId = String(bot.selfId || "").toLowerCase();
|
|
28
|
+
const name = String(bot.adapter.name || "").toLowerCase();
|
|
29
|
+
const protocol = String(bot.adapter.protocol || "").toLowerCase();
|
|
30
|
+
return selfId === "console" || name === "console" || name.endsWith("/console") || protocol === "console";
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* 记录一次回复返回的消息 ID,便于扫码流程结束后清理二维码、链接等临时信息。
|
|
34
|
+
* @param result 回复返回值。
|
|
35
|
+
* @param messageIds 已登记的消息 ID 集合。
|
|
36
|
+
*/
|
|
37
|
+
const trackReply = (result, messageIds) => {
|
|
38
|
+
const messageId = result?.messageId;
|
|
39
|
+
if (messageId && !messageIds.includes(messageId)) messageIds.push(messageId);
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* 撤回扫码流程中发出的临时消息。
|
|
43
|
+
* @param bot 触发登录的机器人实例。
|
|
44
|
+
* @param contact 回复所在会话。
|
|
45
|
+
* @param messageIds 需要撤回的消息 ID。
|
|
46
|
+
*/
|
|
47
|
+
const recallTrackedReplies = async (bot, contact, messageIds) => {
|
|
48
|
+
for (const messageId of messageIds) try {
|
|
49
|
+
await bot.recallMsg(contact, messageId);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
log("warn", `[扫码登录] 撤回临时消息失败: ${messageId}`, err);
|
|
52
|
+
}
|
|
53
|
+
messageIds.length = 0;
|
|
54
|
+
};
|
|
55
|
+
const qqLogin = karin.command(/^#(?:qq登录|qqlogin)$/i, async (e) => {
|
|
23
56
|
if (busy) {
|
|
24
57
|
await e.reply("已有扫码授权在进行中,请稍候再试");
|
|
25
58
|
return;
|
|
26
59
|
}
|
|
27
60
|
busy = true;
|
|
61
|
+
const messageIds = [];
|
|
62
|
+
/**
|
|
63
|
+
* 发送并登记扫码流程的临时回复。
|
|
64
|
+
* @param elements 回复内容。
|
|
65
|
+
*/
|
|
66
|
+
const replyAndTrack = async (elements) => {
|
|
67
|
+
const result = await e.reply(elements);
|
|
68
|
+
trackReply(result, messageIds);
|
|
69
|
+
return result;
|
|
70
|
+
};
|
|
28
71
|
try {
|
|
29
|
-
await
|
|
72
|
+
await replyAndTrack([segment.text("已开始 QQ 扫码登录授权\n⚠ 扫码完成会刷新该机器人的 secret,旧 secret 立即失效")]);
|
|
30
73
|
log("info", `[扫码登录] 由用户 ${e.userId} 触发`);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
74
|
+
const ok = await runQrOnboard({
|
|
75
|
+
printTerminal: isConsoleAdapter(e.bot),
|
|
76
|
+
onQr: async ({ base64, url, refresh }) => {
|
|
77
|
+
const head = refresh === 0 ? "请使用手机 QQ 扫描二维码完成授权(二维码 60s 内有效,过期会自动刷新最多 3 次):" : `上一张二维码已过期,已自动刷新(第 ${refresh}/3 次,仍为 60s 有效期):`;
|
|
78
|
+
try {
|
|
79
|
+
await replyAndTrack([
|
|
80
|
+
segment.text(head),
|
|
81
|
+
segment.image(base64),
|
|
82
|
+
segment.text(`或在手机 QQ 打开链接:\n${url}`)
|
|
83
|
+
]);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log("warn", "[扫码登录] 推送二维码失败:", err);
|
|
86
|
+
}
|
|
41
87
|
}
|
|
42
|
-
}
|
|
88
|
+
});
|
|
89
|
+
await recallTrackedReplies(e.bot, e.contact, messageIds);
|
|
90
|
+
if (ok) await e.reply("扫码授权成功,配置已保存,QQBot 实例正在初始化…");
|
|
43
91
|
else await e.reply("扫码授权失败或超时,请重试 #QQ登录");
|
|
44
92
|
} catch (err) {
|
|
45
93
|
log("error", "[扫码登录] 异常:", err);
|
|
94
|
+
await recallTrackedReplies(e.bot, e.contact, messageIds);
|
|
46
95
|
await e.reply("扫码授权过程中发生异常,详情见服务端日志");
|
|
47
96
|
} finally {
|
|
48
97
|
busy = false;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { common, logger } from "node-karin";
|
|
2
|
+
import qrcode from "qrcode";
|
|
3
|
+
import size from "image-size";
|
|
4
|
+
import lodash from "node-karin/lodash";
|
|
5
|
+
import GetUrls from "@karinjs/geturls";
|
|
6
|
+
|
|
7
|
+
//#region src/utils/common.ts
|
|
8
|
+
/** 睡眠 */
|
|
9
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
/** 随机数 */
|
|
11
|
+
const random = (min, max) => lodash.random(min, max);
|
|
12
|
+
/**
|
|
13
|
+
* 伪事件日志(红字)
|
|
14
|
+
*/
|
|
15
|
+
const fakeEvent = (log) => {
|
|
16
|
+
logger.fatal(`${logger.red("[QQ Official Bot][虚假事件]")} ${log.replace(/\n/g, "\\n")}`);
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* 提取文本中的 URL 列表
|
|
20
|
+
* 中文不符合 URL 规范,先替换为 `|` 再扫描
|
|
21
|
+
*/
|
|
22
|
+
const handleUrl = (text, exclude = []) => {
|
|
23
|
+
const cleaned = text.replace(/[一-龥]/g, "|");
|
|
24
|
+
return GetUrls.getUrls(cleaned, {
|
|
25
|
+
exclude,
|
|
26
|
+
stripWWW: false,
|
|
27
|
+
normalizeProtocol: false,
|
|
28
|
+
removeQueryParameters: false,
|
|
29
|
+
removeSingleSlash: false,
|
|
30
|
+
sortQueryParameters: false,
|
|
31
|
+
stripAuthentication: false,
|
|
32
|
+
stripTextFragment: false,
|
|
33
|
+
removeTrailingSlash: false,
|
|
34
|
+
normalize: false
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* 读取图片宽高
|
|
39
|
+
*/
|
|
40
|
+
const getImageSize = async (url) => {
|
|
41
|
+
const { width = 100, height = 100 } = size(await common.buffer(url));
|
|
42
|
+
return {
|
|
43
|
+
width,
|
|
44
|
+
height
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
//#endregion
|
|
49
|
+
export { sleep as a, random as i, getImageSize as n, handleUrl as r, fakeEvent as t };
|