qq-bot-roost 0.8.3 → 0.8.6

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 扫码一键创建 QQ 官方机器人。
3
+ *
4
+ * 官方开放平台提供了一套「绑定」接口,扫一下手机 QQ 就能拿到 AppID / AppSecret,
5
+ * 不用去 q.qq.com 手动建机器人、再手动复制两个值。
6
+ *
7
+ * 三个接口(都是 POST,JSON):
8
+ *
9
+ * 1. /lite/create_bind_task { key } → { data: { task_id } }
10
+ * key 是我们自己生成的 base64(32 字节随机数),后面用它解密 secret
11
+ *
12
+ * 2. 二维码内容不是图片,是一个 URL,前端自己画成二维码:
13
+ * https://q.qq.com/qqbot/openclaw/connect.html?task_id=<id>&_wv=2
14
+ *
15
+ * 3. /lite/poll_bind_result { task_id } → { data: { status, bot_appid, bot_encrypt_secret } }
16
+ * status: 0 无 / 1 待扫码 / 2 完成 / 3 过期
17
+ *
18
+ * 完成时 `bot_encrypt_secret` 是 AES-256-GCM 密文:
19
+ * base64 解开 = nonce(12) + ciphertext + tag(16),key 就是第 1 步的 bindKey。
20
+ *
21
+ * 为什么不用第三方库:`node:crypto` 自带 aes-256-gcm,零依赖。
22
+ *
23
+ * 参考实现:AstrBot 的 `qqofficial/login_registration.py`(协议一致,值得致敬)。
24
+ */
25
+
26
+ import { randomBytes, createDecipheriv } from 'node:crypto';
27
+
28
+ /** 绑定接口的域名。留个常量,方便以后换成测试环境 */
29
+ const BIND_HOST = 'q.qq.com';
30
+ const TIMEOUT_MS = 10_000;
31
+
32
+ /** 轮询状态码 */
33
+ export const BIND_STATUS = {
34
+ NONE: 0,
35
+ PENDING: 1,
36
+ DONE: 2,
37
+ EXPIRED: 3,
38
+ };
39
+
40
+ async function postJson(url, payload) {
41
+ const ac = new AbortController();
42
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
43
+ try {
44
+ const res = await fetch(url, {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
47
+ body: JSON.stringify(payload),
48
+ signal: ac.signal,
49
+ });
50
+ const text = await res.text();
51
+ let data = null;
52
+ try {
53
+ data = text ? JSON.parse(text) : null;
54
+ } catch {
55
+ throw new Error(`绑定接口返回的不是 JSON(HTTP ${res.status}):${text.slice(0, 120)}`);
56
+ }
57
+ if (!res.ok) throw new Error(`绑定接口失败(HTTP ${res.status}):${data?.msg ?? text.slice(0, 120)}`);
58
+ if (data && data.retcode != null && Number(data.retcode) !== 0) {
59
+ throw new Error(data.msg || data.message || `绑定接口返回 retcode=${data.retcode}`);
60
+ }
61
+ return data ?? {};
62
+ } catch (e) {
63
+ if (e?.name === 'AbortError') throw new Error('连不上 q.qq.com(超时 10 秒),检查网络或代理');
64
+ throw e;
65
+ } finally {
66
+ clearTimeout(timer);
67
+ }
68
+ }
69
+
70
+ /** 二维码里要放的内容 */
71
+ export function connectUrl(taskId) {
72
+ return `https://${BIND_HOST}/qqbot/openclaw/connect.html?task_id=${encodeURIComponent(taskId)}&_wv=2`;
73
+ }
74
+
75
+ /**
76
+ * 建一个绑定任务。
77
+ *
78
+ * @returns {Promise<{taskId:string, bindKey:string, qrUrl:string}>}
79
+ */
80
+ export async function createBindTask() {
81
+ // 32 字节随机数当 AES-256 的 key,base64 后发给官方
82
+ const bindKey = randomBytes(32).toString('base64');
83
+ const data = await postJson(`https://${BIND_HOST}/lite/create_bind_task`, { key: bindKey });
84
+ const taskId = String(data?.data?.task_id ?? '').trim();
85
+ if (!taskId) throw new Error('绑定任务没返回 task_id,可能是接口变了');
86
+ return { taskId, bindKey, qrUrl: connectUrl(taskId) };
87
+ }
88
+
89
+ /**
90
+ * 解 AppSecret。
91
+ *
92
+ * 密文格式:base64( nonce[12] + ciphertext + tag[16] )
93
+ * 少了任何一段都会解不开——所以长度不够就直接报错,别硬解。
94
+ */
95
+ export function decryptSecret(encrypted, bindKey) {
96
+ let key;
97
+ let raw;
98
+ try {
99
+ key = Buffer.from(bindKey, 'base64');
100
+ raw = Buffer.from(String(encrypted), 'base64');
101
+ } catch {
102
+ throw new Error('凭证解码失败(bindKey 或密文不是合法 base64)');
103
+ }
104
+ if (key.length !== 32) throw new Error(`bindKey 长度不对(要 32 字节,实际 ${key.length})`);
105
+ if (raw.length <= 28) throw new Error(`密文太短(${raw.length} 字节,至少要 29)`);
106
+
107
+ const nonce = raw.subarray(0, 12);
108
+ const tag = raw.subarray(raw.length - 16);
109
+ const ciphertext = raw.subarray(12, raw.length - 16);
110
+
111
+ const d = createDecipheriv('aes-256-gcm', key, nonce);
112
+ d.setAuthTag(tag);
113
+ try {
114
+ return Buffer.concat([d.update(ciphertext), d.final()]).toString('utf8');
115
+ } catch {
116
+ throw new Error('AppSecret 解不开(密钥不匹配或数据被改动)');
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 查一次扫码结果。
122
+ *
123
+ * @returns {Promise<{status:'pending'|'done'|'expired'|'error', appId?:string, appSecret?:string, message?:string}>}
124
+ */
125
+ export async function pollBindResult({ taskId, bindKey }) {
126
+ if (!taskId) throw new Error('缺 task_id');
127
+ if (!bindKey) throw new Error('缺 bind_key');
128
+
129
+ const data = await postJson(`https://${BIND_HOST}/lite/poll_bind_result`, { task_id: taskId });
130
+ const p = data?.data ?? {};
131
+ const status = Number(p.status ?? BIND_STATUS.NONE);
132
+
133
+ if (status === BIND_STATUS.DONE) {
134
+ const appId = String(p.bot_appid ?? '').trim();
135
+ const encrypted = String(p.bot_encrypt_secret ?? '').trim();
136
+ if (!appId || !encrypted) {
137
+ return { status: 'error', message: '扫码成功了,但官方没返回完整凭证(缺 appid 或 secret)' };
138
+ }
139
+ try {
140
+ return { status: 'done', appId, appSecret: decryptSecret(encrypted, bindKey) };
141
+ } catch (e) {
142
+ return { status: 'error', message: e.message };
143
+ }
144
+ }
145
+
146
+ if (status === BIND_STATUS.EXPIRED) {
147
+ return { status: 'expired', message: '二维码过期了,重新生成一个' };
148
+ }
149
+
150
+ return { status: 'pending' };
151
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 把一段文字画成二维码(SVG 字符串)。
3
+ *
4
+ * 为什么**服务端画**:DSH 的插件前端只有一个 client.js 入口,
5
+ * 再往里塞一个 QR 库既占体积又要改加载方式。服务端画好直接给 `<svg>` 字符串,
6
+ * 前端一句 innerHTML 就完事。
7
+ *
8
+ * 库是 `qrcode-generator` 1.4.4(MIT,Kazuhiko Arase),放在 web/vendor/。
9
+ * 它是 UMD:本仓是 ESM(package.json "type": "module"),直接 import 拿不到,
10
+ * 所以在 vm 里当浏览器脚本跑一遍,取全局 `qrcode`——不需要任何依赖。
11
+ */
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import vm from 'node:vm';
15
+
16
+ let qrcode = null;
17
+
18
+ function lib() {
19
+ if (qrcode) return qrcode;
20
+ const src = readFileSync(new URL('../../web/vendor/qrcode.js', import.meta.url), 'utf8');
21
+ const sandbox = {};
22
+ vm.createContext(sandbox);
23
+ vm.runInContext(src, sandbox);
24
+ if (typeof sandbox.qrcode !== 'function') throw new Error('QR 库没加载上(vendor/qrcode.js 坏了?)');
25
+ qrcode = sandbox.qrcode;
26
+ return qrcode;
27
+ }
28
+
29
+ /**
30
+ * @param {string} text 要编码的内容
31
+ * @param {{cellSize?:number, margin?:number}} [opt]
32
+ * @returns {string} 完整 `<svg>…</svg>`
33
+ */
34
+ export function toSvg(text, { cellSize = 4, margin = 6 } = {}) {
35
+ const qr = lib()(0, 'M'); // 0 = 自动选版本,M = 中等纠错
36
+ qr.addData(String(text));
37
+ qr.make();
38
+ return qr.createSvgTag({ cellSize, margin });
39
+ }
package/src/web/server.js CHANGED
@@ -30,6 +30,8 @@ import { suspendPluginWatch } from '../services/plugin-watch.js';
30
30
  import { onMessage as onBusMessage } from '../message-bus.js';
31
31
  import { handleQqCall, capabilityPayload } from './qq-call.js';
32
32
  import { runDoctor, summarize } from '../doctor.js';
33
+ import { createBindTask, pollBindResult } from '../qq/qrlogin.js';
34
+ import { toSvg as qrToSvg } from '../qq/qrsvg.js';
33
35
  import {
34
36
  PRESETS,
35
37
  PROBE_URL,
@@ -96,6 +98,9 @@ async function readBody(req, limit = 1024 * 256) {
96
98
  *
97
99
  * @returns {Promise<number>} 实际监听成功的端口
98
100
  */
101
+ /** 扫码建机器人的临时任务表:taskId → { bindKey, at } */
102
+ const qrTasks = new Map();
103
+
99
104
  function listenWithFallback(server, host, startPort, { tries = 20, onBusy } = {}) {
100
105
  return new Promise((resolve, reject) => {
101
106
  let attempt = 0;
@@ -704,6 +709,46 @@ export async function startWebServer({
704
709
  return json(res, 200, { bots: list, current: rt?.id ?? null });
705
710
  }
706
711
 
712
+ /**
713
+ * 扫码建机器人:两步(建任务 / 轮询)。
714
+ *
715
+ * bindKey 是解 AppSecret 的密钥,**只在服务端留着**,
716
+ * 下发出去等于把密钥给了浏览器,没必要。
717
+ * 任务 10 分钟后自动清掉,免得 Map 无限涨。
718
+ */
719
+ if (pathname === '/api/bots/qr/start' && req.method === 'POST') {
720
+ for (const [k, v] of qrTasks) {
721
+ if (Date.now() - v.at > 10 * 60_000) qrTasks.delete(k);
722
+ }
723
+ try {
724
+ const t = await createBindTask();
725
+ qrTasks.set(t.taskId, { bindKey: t.bindKey, at: Date.now() });
726
+ // qrSvg 给不装 QR 库的调用方(比如 dsh-roost 面板)直接用
727
+ let qrSvg = '';
728
+ try {
729
+ qrSvg = qrToSvg(t.qrUrl);
730
+ } catch {}
731
+ return json(res, 200, { taskId: t.taskId, qrUrl: t.qrUrl, qrSvg });
732
+ } catch (e) {
733
+ return json(res, 502, { error: e.message });
734
+ }
735
+ }
736
+
737
+ if (pathname === '/api/bots/qr/poll' && req.method === 'POST') {
738
+ const body = await readBody(req);
739
+ const taskId = String(body.taskId ?? '').trim();
740
+ const rec = qrTasks.get(taskId);
741
+ if (!rec) return json(res, 400, { error: '这个二维码已经失效了,重新生成一个' });
742
+ try {
743
+ const r = await pollBindResult({ taskId, bindKey: rec.bindKey });
744
+ // 有结论就把任务删掉,别让它一直轮询
745
+ if (r.status !== 'pending') qrTasks.delete(taskId);
746
+ return json(res, 200, r);
747
+ } catch (e) {
748
+ return json(res, 502, { error: e.message });
749
+ }
750
+ }
751
+
707
752
  if (pathname === '/api/bots' && req.method === 'POST') {
708
753
  if (!onBotAdd) return json(res, 501, { error: '当前模式不支持新建机器人' });
709
754
  const body = await readBody(req);
package/src/webhook.js CHANGED
@@ -234,7 +234,27 @@ export class WebhookServer {
234
234
  });
235
235
 
236
236
  if (isInteraction) {
237
- // 我们的 putInteraction 是插件主动调的,这里先给 0 兜底
237
+ /**
238
+ * 按钮点击要**两层**确认,少一层客户端就一直转圈:
239
+ * 1. HTTP ACK `{op:12, code:0}` —— 告诉平台「我收到了」
240
+ * 2. `PUT /interactions/{id}` —— 告诉客户端「这次点击处理完了」
241
+ *
242
+ * 以前只做了第 1 层(下面那行注释以为 putInteraction 插件会自己调,
243
+ * 实际上没人调),所以按钮点下去会一直转。
244
+ *
245
+ * **只有 type 11(消息按钮)和 12(快捷菜单)需要回执**,
246
+ * 其他类型(消息反馈 13、清空会话 14、切模型 16、授权 18/19)官方明确不用回。
247
+ * 所以这里按 type 收紧——乱回可能被判非法,而且同一 id 只能回一次。
248
+ */
249
+ const t = Number(d?.type ?? d?.data?.type ?? 0);
250
+ if (t === 11 || t === 12) {
251
+ try {
252
+ const r = bot.api?.putInteraction?.(d?.id, 0);
253
+ if (r && typeof r.catch === 'function') r.catch(() => {});
254
+ } catch {
255
+ /* 回执失败不影响主流程 */
256
+ }
257
+ }
238
258
  return this.#json(res, 200, { op: 12, code: 0 });
239
259
  }
240
260
  }
package/web/app.js CHANGED
@@ -273,6 +273,94 @@ async function deleteBot(id) {
273
273
  if (CURRENT_BOT) await selectBot(CURRENT_BOT);
274
274
  }
275
275
 
276
+ /* ---------------- 扫码一键创建 ---------------- */
277
+
278
+ /**
279
+ * 官方有「绑定」接口,手机 QQ 扫一下就能拿到 AppID / AppSecret,
280
+ * 不用去 q.qq.com 手动建机器人、再手动复制两个值。
281
+ *
282
+ * 二维码内容是官方给的一个 URL,我们本地画成二维码(web/vendor/qrcode.js)。
283
+ * 密钥(bindKey)留在服务端,前端只拿到 taskId。
284
+ */
285
+ let qrTaskId = '';
286
+ let qrTimer = null;
287
+
288
+ function stopQrPoll() {
289
+ if (qrTimer) {
290
+ clearInterval(qrTimer);
291
+ qrTimer = null;
292
+ }
293
+ }
294
+
295
+ function resetQr() {
296
+ stopQrPoll();
297
+ qrTaskId = '';
298
+ const box = $('#nbQr');
299
+ if (box) box.hidden = true;
300
+ const code = $('#nbQrCode');
301
+ if (code) code.innerHTML = '';
302
+ const tip = $('#nbQrTip');
303
+ if (tip) tip.textContent = '';
304
+ }
305
+
306
+ function drawQr(url) {
307
+ const code = $('#nbQrCode');
308
+ if (!code || typeof qrcode !== 'function') return false;
309
+ const qr = qrcode(0, 'M'); // 0 = 自动选版本
310
+ qr.addData(url);
311
+ qr.make();
312
+ code.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 6 });
313
+ return true;
314
+ }
315
+
316
+ async function startQr() {
317
+ const btn = $('#nbQrStart');
318
+ const box = $('#nbQr');
319
+ const tip = $('#nbQrTip');
320
+ box.hidden = false;
321
+ tip.textContent = '正在生成二维码…';
322
+ btn.disabled = true;
323
+ try {
324
+ const r = await api('/api/bots/qr/start', { method: 'POST', body: '{}' });
325
+ qrTaskId = r.taskId;
326
+ if (!drawQr(r.qrUrl)) {
327
+ tip.textContent = '二维码库没加载上,刷新页面再试一次';
328
+ return;
329
+ }
330
+ tip.textContent = '用手机 QQ 扫码,确认后会自动填好 AppID / AppSecret';
331
+ stopQrPoll();
332
+ qrTimer = setInterval(pollQr, 2000);
333
+ } catch (e) {
334
+ tip.textContent = e.message;
335
+ } finally {
336
+ btn.disabled = false;
337
+ }
338
+ }
339
+
340
+ async function pollQr() {
341
+ if (!qrTaskId) return stopQrPoll();
342
+ const tip = $('#nbQrTip');
343
+ try {
344
+ const r = await api('/api/bots/qr/poll', {
345
+ method: 'POST',
346
+ body: JSON.stringify({ taskId: qrTaskId }),
347
+ });
348
+ if (r.status === 'pending') return;
349
+ stopQrPoll();
350
+ if (r.status === 'done') {
351
+ $('#nbAppId').value = r.appId || '';
352
+ $('#nbSecret').value = r.appSecret || '';
353
+ tip.textContent = `✓ 已拿到 AppID ${r.appId},填个 ID 就能创建`;
354
+ $('#nbId').focus();
355
+ } else {
356
+ tip.textContent = r.message || '二维码失效了,重新生成一个';
357
+ }
358
+ } catch (e) {
359
+ stopQrPoll();
360
+ if (tip) tip.textContent = e.message;
361
+ }
362
+ }
363
+
276
364
  /* ---------------- 新建机器人 ---------------- */
277
365
 
278
366
  function openNewBot() {
@@ -282,11 +370,13 @@ function openNewBot() {
282
370
  $('#nbAppId').value = '';
283
371
  $('#nbSecret').value = '';
284
372
  $('#nbErr').hidden = true;
373
+ resetQr();
285
374
  $('#newBotMask').hidden = false;
286
375
  $('#nbId').focus();
287
376
  }
288
377
 
289
378
  function closeNewBot() {
379
+ resetQr();
290
380
  $('#newBotMask').hidden = true;
291
381
  }
292
382
 
@@ -326,6 +416,8 @@ async function submitNewBot() {
326
416
  }
327
417
  }
328
418
 
419
+ $('#nbQrStart')?.addEventListener('click', startQr);
420
+
329
421
  $('#botTrigger')?.addEventListener('click', () => {
330
422
  if ($('#botMenu').hidden) openBotMenu();
331
423
  else closeBotMenu();