@karinjs/adapter-qqbot 3.0.0 → 3.0.2

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.
@@ -1,4 +1,4 @@
1
- import { t as getMessageStore } from "../message-BTGL3TSn.js";
1
+ import { t as getMessageStore } from "../message-CVJVFAGR.js";
2
2
  import karin from "node-karin";
3
3
 
4
4
  //#region src/apps/cleanup.ts
@@ -1,18 +1,4 @@
1
1
  //#region src/apps/login.d.ts
2
- /**
3
- * #QQ登录 指令
4
- *
5
- * 仅主人可用。匹配 `#QQ登录` 或 `#qqlogin`(大小写均可)。
6
- * 必须带 `#` 前缀。
7
- *
8
- * 流程:
9
- * 1. 立即回复"开始扫码"
10
- * 2. 触发扫码授权流程
11
- * 3. 每次生成新二维码 → 把 PNG 图片回复给触发者
12
- * 4. 授权成功 → 回复结果,watch 回调自动初始化 bot
13
- *
14
- * ⚠ 注意:扫码授权会刷新该 appId 的 secret,旧 secret 立即失效
15
- */
16
- declare const qqLogin: import("node-karin").Command<keyof import("node-karin").MessageEventMap>;
2
+ declare const QQBotLogin: import("node-karin").Command<keyof import("node-karin").MessageEventMap>;
17
3
  //#endregion
18
- export { qqLogin };
4
+ export { QQBotLogin };
@@ -1,106 +1,159 @@
1
- import { m as log } from "../config-BUuU6FnQ.js";
2
- import { n as runQrOnboard } from "../onboard-vIEoe9dB.js";
1
+ import { i as getDefaultConfig, n as config } from "../config-D1_LqrcJ.js";
3
2
  import karin, { segment } from "node-karin";
3
+ import qrcode from "qrcode";
4
+ import axios from "node-karin/axios";
5
+ import crypto from "node:crypto";
4
6
 
5
- //#region src/apps/login.ts
7
+ //#region src/core/qrcode/session.ts
8
+ const BASEURL = "q.qq.com";
9
+ const headers = {
10
+ "Content-Type": "application/json",
11
+ Accept: "application/json"
12
+ };
13
+ /** 生成随机密钥 */
14
+ const RandomKey = () => {
15
+ return crypto.randomBytes(32).toString("base64");
16
+ };
6
17
  /**
7
- * #QQ登录 指令
8
- *
9
- * 仅主人可用。匹配 `#QQ登录` 或 `#qqlogin`(大小写均可)。
10
- * 必须带 `#` 前缀。
11
- *
12
- * 流程:
13
- * 1. 立即回复"开始扫码"
14
- * 2. 触发扫码授权流程
15
- * 3. 每次生成新二维码 → 把 PNG 图片回复给触发者
16
- * 4. 授权成功 → 回复结果,watch 回调自动初始化 bot
17
- *
18
- * ⚠ 注意:扫码授权会刷新该 appId 的 secret,旧 secret 立即失效
18
+ * 解密密钥
19
+ * @param encryptedBase64
20
+ * @param keyBase64
21
+ * @returns
19
22
  */
20
- /** 互斥锁:避免并发扫码 */
21
- let busy = false;
23
+ const decryptSecret = (encryptedBase64, keyBase64) => {
24
+ const key = Buffer.from(keyBase64, "base64");
25
+ const data = Buffer.from(encryptedBase64, "base64");
26
+ const IV = data.subarray(0, 12);
27
+ const authtag = data.subarray(data.length - 16);
28
+ const ciphertext = data.subarray(12, data.length - 16);
29
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, IV);
30
+ decipher.setAuthTag(authtag);
31
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
32
+ };
22
33
  /**
23
- * 判断触发 #QQ登录 的上游是否为 Karin 控制台适配器。
24
- * Console 适配器无法直接在聊天窗口展示二维码图片,因此需要额外打印终端二维码。
34
+ * 创建绑定任务
35
+ * @param timeout 超时时间
25
36
  */
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";
37
+ const createBindTask = async (timeout = 1e4) => {
38
+ const url = `https://${BASEURL}/lite/create_bind_task`;
39
+ const key = RandomKey();
40
+ const { data } = await axios.post(url, { key }, {
41
+ headers,
42
+ timeout
43
+ });
44
+ if (data.retcode !== 0) throw new Error(data.msg ?? "创建绑定任务失败");
45
+ if (!data.data?.task_id) throw new Error("创建绑定任务失败: task_id 缺失");
46
+ return {
47
+ taskId: data.data.task_id,
48
+ aesKey: key
49
+ };
31
50
  };
32
51
  /**
33
- * 记录一次回复返回的消息 ID,便于扫码流程结束后清理二维码、链接等临时信息。
34
- * @param result 回复返回值。
35
- * @param messageIds 已登记的消息 ID 集合。
52
+ * 查询绑定结果
53
+ * @param taskId 任务id
54
+ * @param timeout 超时时间
36
55
  */
37
- const trackReply = (result, messageIds) => {
38
- const messageId = result?.messageId;
39
- if (messageId && !messageIds.includes(messageId)) messageIds.push(messageId);
56
+ const checkBindResult = async (taskId, timeout = 1e4) => {
57
+ const url = `https://${BASEURL}/lite/poll_bind_result`;
58
+ const { data } = await axios.post(url, { task_id: taskId }, {
59
+ headers,
60
+ timeout
61
+ });
62
+ if (data.retcode !== 0) throw new Error(data.msg ?? "查询绑定结果失败");
63
+ return {
64
+ status: data.data?.status ?? 0,
65
+ appId: String(data.data?.bot_appid ?? ""),
66
+ encryptedSecret: data.data?.bot_encrypt_secret ?? "",
67
+ userOpenid: data.data?.user_openid ?? void 0
68
+ };
40
69
  };
70
+ /** 构建连接URL */
71
+ const buildConnectUrl = (taskId) => `https://${BASEURL}/qqbot/openclaw/connect.html?task_id=${encodeURIComponent(taskId)}&source=karin&_wv=2`;
72
+
73
+ //#endregion
74
+ //#region src/core/qrcode/qr.ts
75
+ const WaitingMS = 2e3;
41
76
  /**
42
- * 撤回扫码流程中发出的临时消息。
43
- * @param bot 触发登录的机器人实例。
44
- * @param contact 回复所在会话。
45
- * @param messageIds 需要撤回的消息 ID。
77
+ * 轮询绑定结果
78
+ * @param taskId 任务ID
79
+ * @param key 解密密钥
80
+ * @returns QQBot 的appid与secret
46
81
  */
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);
82
+ const PollBindResult = async (taskId, key) => {
83
+ while (true) {
84
+ let data;
85
+ try {
86
+ data = await checkBindResult(taskId);
87
+ } catch {
88
+ await new Promise((resolve) => setTimeout(resolve, WaitingMS));
89
+ continue;
90
+ }
91
+ if (data.status === 2) {
92
+ const secret = decryptSecret(data.encryptedSecret, key);
93
+ return {
94
+ status: data.status,
95
+ appId: data.appId,
96
+ secret
97
+ };
98
+ }
99
+ if (data.status === 3) return { status: data.status };
100
+ await new Promise((resolve) => setTimeout(resolve, WaitingMS));
52
101
  }
53
- messageIds.length = 0;
54
102
  };
55
- const qqLogin = karin.command(/^#(?:qq登录|qqlogin)$/i, async (e) => {
56
- if (busy) {
57
- await e.reply("已有扫码授权在进行中,请稍候再试");
58
- return;
59
- }
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;
103
+
104
+ //#endregion
105
+ //#region src/apps/login.ts
106
+ let busy = false;
107
+ const QQBotLogin = karin.command(/^#qqbot(登录|login)$/i, async (e) => {
108
+ const msgIds = [];
109
+ const reply = async (elements) => {
110
+ const res = await e.reply(elements, { at: true });
111
+ msgIds.push(res.messageId);
70
112
  };
71
- try {
72
- await replyAndTrack([segment.text("已开始 QQ 扫码登录授权\n⚠ 扫码完成会刷新该机器人的 secret,旧 secret 立即失效")]);
73
- log("info", `[扫码登录] 由用户 ${e.userId} 触发`);
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
- }
87
- }
113
+ if (busy) return await e.reply("已有扫码授权在进行中,请稍候再试", { reply: true });
114
+ busy = true;
115
+ await reply("注意: 确认登录后将刷新 QQBot 的 AppSecret,旧的 AppSecret 将失效");
116
+ const task = await createBindTask();
117
+ const url = buildConnectUrl(task.taskId);
118
+ const msg = [segment.text("请使用 QQ 扫码登录")];
119
+ if (e.bot.adapter.protocol === "console") {
120
+ const qrcode$1 = await qrcode.toString(url, {
121
+ type: "terminal",
122
+ small: true
88
123
  });
89
- await recallTrackedReplies(e.bot, e.contact, messageIds);
90
- if (ok) await e.reply("扫码授权成功,配置已保存,QQBot 实例正在初始化…");
91
- else await e.reply("扫码授权失败或超时,请重试 #QQ登录");
92
- } catch (err) {
93
- log("error", "[扫码登录] 异常:", err);
94
- await recallTrackedReplies(e.bot, e.contact, messageIds);
95
- await e.reply("扫码授权过程中发生异常,详情见服务端日志");
96
- } finally {
97
- busy = false;
124
+ msg.push(segment.text(`\n${qrcode$1}`));
125
+ } else {
126
+ const qrcode$2 = await qrcode.toDataURL(url);
127
+ msg.push(segment.image(qrcode$2));
128
+ }
129
+ msg.push(segment.text(`或使用手机 QQ 打开链接登录: ${url}`));
130
+ await reply(msg);
131
+ const ok = await PollBindResult(task.taskId, task.aesKey);
132
+ msgIds.forEach(async (id) => {
133
+ await e.bot.recallMsg(e.contact, id).catch(() => {});
134
+ });
135
+ if (ok.status === 2) {
136
+ const list = config();
137
+ const idx = list.findIndex((item) => item.appId === ok.appId);
138
+ if (idx >= 0) {
139
+ list[idx].secret = ok.secret;
140
+ e.reply(`Bot存在,已更新[${ok.appId}]配置`, { reply: true });
141
+ } else {
142
+ const defaults = getDefaultConfig()[0];
143
+ list.push({
144
+ ...defaults,
145
+ appId: ok.appId,
146
+ secret: ok.secret
147
+ });
148
+ e.reply(`[${ok.appId}] 扫码登录成功`, { reply: true });
149
+ }
150
+ return;
98
151
  }
152
+ return await e.reply("二维码过期,请重新触发指令", { reply: true });
99
153
  }, {
100
- name: "qqbot:login",
101
- permission: "master",
102
- authFailMsg: "#QQ登录 仅限主人使用"
154
+ perm: "master",
155
+ name: "QQBot 扫码登录"
103
156
  });
104
157
 
105
158
  //#endregion
106
- export { qqLogin };
159
+ export { QQBotLogin };
@@ -11,30 +11,6 @@ const dirPath = path.resolve(filePath, "../..");
11
11
  /** 插件包的名称 */
12
12
  const basename = path.basename(dirPath);
13
13
 
14
- //#endregion
15
- //#region src/utils/logger.ts
16
- const PREFIX = "[QQ Official Bot]";
17
- const m = {
18
- trace: (...a) => logger.trace(PREFIX, ...a),
19
- debug: (...a) => logger.debug(PREFIX, ...a),
20
- info: (...a) => logger.info(PREFIX, ...a),
21
- warn: (...a) => logger.warn(PREFIX, ...a),
22
- error: (...a) => logger.error(PREFIX, ...a),
23
- fatal: (...a) => logger.fatal(PREFIX, ...a),
24
- mark: (...a) => logger.mark(PREFIX, ...a)
25
- };
26
- /**
27
- * QQBot 日志函数
28
- * - log('info', '消息')
29
- * - log.info('消息')
30
- *
31
- * 日志级别由 karin 框架的 log.level 控制
32
- */
33
- function log(level, ...args) {
34
- m[level](...args);
35
- }
36
- Object.assign(log, m);
37
-
38
14
  //#endregion
39
15
  //#region src/utils/plugin.ts
40
16
  /**
@@ -48,56 +24,6 @@ const pluginName = pkg().name;
48
24
  /** 文件系统目录名:把 npm 包名里的 `/` 规范化为 `-`,与 Karin 框架对齐。 */
49
25
  const pluginDirName = pluginName.replace(/\//g, "-");
50
26
 
51
- //#endregion
52
- //#region src/utils/proxy-url.ts
53
- /**
54
- * HTTP / token 地址只清理首尾空白。完整接口地址是否带尾斜杠可能有路由含义,不能替用户改。
55
- */
56
- const normalizeHttpUrl = (raw) => raw.trim();
57
- /**
58
- * 拼接 OpenAPI 路径时再处理斜杠,避免配置值本身被改写。
59
- */
60
- const joinHttpPath = (base, path) => {
61
- return `${normalizeHttpUrl(base).replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
62
- };
63
- /**
64
- * WebSocket 网关地址:
65
- * - ws:// / wss:// 原样保留协议
66
- * - http:// / https:// 映射为 ws:// / wss://
67
- * - 未写协议时按 wss:// 处理
68
- *
69
- * 这里只做最小处理,不改 path / query / hash。自定义网关的尾斜杠和查询参数都可能有路由含义。
70
- */
71
- const normalizeWsGatewayUrl = (raw) => {
72
- const value = raw.trim();
73
- if (!value) return "";
74
- let normalized = value;
75
- const scheme = value.match(/^([a-z][a-z\d+.-]*):\/\//i)?.[1]?.toLowerCase();
76
- if (scheme === "http" || scheme === "https") normalized = value.replace(/^http/i, "ws");
77
- else if (scheme && scheme !== "ws" && scheme !== "wss") throw new Error(`unsupported websocket protocol: ${scheme}:`);
78
- else if (!scheme) normalized = `wss://${value}`;
79
- const parsed = new URL(normalized);
80
- if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") throw new Error(`unsupported websocket protocol: ${parsed.protocol}`);
81
- return normalized;
82
- };
83
- /**
84
- * 只有从 OpenAPI 根地址推导官方 fallback 网关时,才主动拼 `/websocket/`。
85
- */
86
- const buildFallbackWsUrlFromApi = (apiUrl) => {
87
- const parsed = new URL(normalizeWsGatewayUrl(apiUrl));
88
- parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}/websocket/`;
89
- parsed.search = "";
90
- parsed.hash = "";
91
- return parsed.toString();
92
- };
93
- const normalizeProxyConfig = (proxy) => ({
94
- prodApi: normalizeHttpUrl(proxy.prodApi),
95
- sandboxApi: normalizeHttpUrl(proxy.sandboxApi),
96
- tokenApi: normalizeHttpUrl(proxy.tokenApi),
97
- prodWs: normalizeWsGatewayUrl(proxy.prodWs),
98
- sandboxWs: normalizeWsGatewayUrl(proxy.sandboxWs)
99
- });
100
-
101
27
  //#endregion
102
28
  //#region src/utils/config.ts
103
29
  let cache;
@@ -159,23 +85,10 @@ const writeConfig = (data) => {
159
85
  const formatConfig = (user) => {
160
86
  const def = getDefaultConfig()[0];
161
87
  return user.map((item) => {
162
- const { prodApi, sandboxApi, tokenApi, wsUrl, proxy, keyboard, markdown, messageCache, event, ...rest } = item;
88
+ const { markdown, messageCache, event, ...rest } = item;
163
89
  return {
164
90
  ...def,
165
91
  ...rest,
166
- proxy: normalizeProxyConfig({
167
- ...def.proxy,
168
- ...proxy,
169
- prodApi: proxy?.prodApi || prodApi || def.proxy.prodApi,
170
- sandboxApi: proxy?.sandboxApi || sandboxApi || def.proxy.sandboxApi,
171
- tokenApi: proxy?.tokenApi || tokenApi || def.proxy.tokenApi,
172
- prodWs: proxy?.prodWs || proxy?.wsUrl || wsUrl || def.proxy.prodWs,
173
- sandboxWs: proxy?.sandboxWs || proxy?.wsUrl || wsUrl || def.proxy.sandboxWs
174
- }),
175
- keyboard: {
176
- ...def.keyboard,
177
- ...keyboard
178
- },
179
92
  markdown: {
180
93
  ...def.markdown,
181
94
  ...markdown
@@ -231,17 +144,8 @@ const clampNumber = (value, min, max, fallback) => {
231
144
  * 默认配置
232
145
  */
233
146
  const getDefaultConfig = () => [{
234
- name: "",
235
147
  appId: "",
236
148
  secret: "",
237
- proxy: {
238
- prodApi: "https://api.sgroup.qq.com",
239
- sandboxApi: "https://sandbox.api.sgroup.qq.com",
240
- tokenApi: "https://bots.qq.com/app/getAppAccessToken",
241
- prodWs: "wss://api.sgroup.qq.com/websocket/",
242
- sandboxWs: "wss://sandbox.api.sgroup.qq.com/websocket/"
243
- },
244
- sandbox: false,
245
149
  qqEnable: true,
246
150
  guildEnable: true,
247
151
  guildMode: 0,
@@ -249,7 +153,6 @@ const getDefaultConfig = () => [{
249
153
  reg: "^/",
250
154
  rep: "#"
251
155
  }],
252
- keyboard: { enable: true },
253
156
  markdown: { enable: true },
254
157
  messageCache: {
255
158
  enable: false,
@@ -290,4 +193,4 @@ setTimeout(() => {
290
193
  }, 2e3);
291
194
 
292
195
  //#endregion
293
- export { readConfigFile as a, joinHttpPath as c, normalizeWsGatewayUrl as d, pkg as f, basename as h, getDefaultConfig as i, normalizeHttpUrl as l, log as m, config as n, writeConfig as o, pluginDirName as p, getConfig as r, buildFallbackWsUrlFromApi as s, bindHandlers as t, normalizeProxyConfig as u };
196
+ export { readConfigFile as a, pluginDirName as c, getDefaultConfig as i, basename as l, config as n, writeConfig as o, getConfig as r, pkg as s, bindHandlers as t };
package/dist/index.d.ts CHANGED
@@ -334,7 +334,7 @@ interface UploadMediaResponse {
334
334
  /**
335
335
  * 创建 axios 实例,自动从 tokens map 取最新 access_token
336
336
  */
337
- declare const createAxiosInstance: (baseURL: string, appId: string) => import("node-karin/axios").AxiosInstance;
337
+ declare const createAxiosInstance: (appId: string) => import("node-karin/axios").AxiosInstance;
338
338
  //#endregion
339
339
  //#region src/core/api/http.d.ts
340
340
  type AxiosInstance = ReturnType<typeof createAxiosInstance>;
@@ -517,11 +517,18 @@ declare class GroupsApi extends Http {
517
517
  /**
518
518
  * 获取群成员详情
519
519
  *
520
- * 未公开文档,来自腾讯官方 openclaw 插件(v2.0.0)的接口速查。
521
520
  * @param groupOpenid 群 openid
522
521
  * @param memberOpenid 群成员 openid
523
522
  */
524
523
  getGroupMember(groupOpenid: string, memberOpenid: string): Promise<QQGroupMemberResponse>;
524
+ /**
525
+ * 设置群成员禁言
526
+ * @param groupOpenid 群 openid
527
+ * @param memberOpenid 群成员 openid
528
+ * @param op 操作: add 增加禁言,update 更新禁言到期时间,del 解除禁言
529
+ * @param duration 禁言时间使用RFC3339 格式
530
+ */
531
+ setGroupMetu(groupOpenid: string, memberOpenid: string, op: 'add' | 'update' | 'del', duration: string): Promise<{}>;
525
532
  }
526
533
  //#endregion
527
534
  //#region src/core/api/index.d.ts
@@ -562,21 +569,6 @@ declare class QQBotApi {
562
569
  }
563
570
  //#endregion
564
571
  //#region src/types/config.d.ts
565
- /**
566
- * 自定义连接代理配置
567
- */
568
- interface QQBotProxyConfig {
569
- /** 正式环境 API */
570
- prodApi: string;
571
- /** 测试环境 API */
572
- sandboxApi: string;
573
- /** 调用凭证 API */
574
- tokenApi: string;
575
- /** 正式环境 WebSocket 网关 */
576
- prodWs: string;
577
- /** 测试环境 WebSocket 网关 */
578
- sandboxWs: string;
579
- }
580
572
  /**
581
573
  * 消息缓存存储分级。
582
574
  *
@@ -589,16 +581,10 @@ type MessageCacheLevel = 'minimal' | 'standard' | 'full';
589
581
  * 单个 QQBot 配置
590
582
  */
591
583
  interface QQBotConfig {
592
- /** 机器人名称 */
593
- name: string;
594
584
  /** 机器人 ID */
595
585
  appId: string;
596
586
  /** 机器人密钥 */
597
587
  secret: string;
598
- /** 自定义连接代理 */
599
- proxy: QQBotProxyConfig;
600
- /** 开启沙盒环境 */
601
- sandbox: boolean;
602
588
  /** 开启 QQ 场景能力(单聊 / 群聊) */
603
589
  qqEnable: boolean;
604
590
  /** 开启频道场景能力 */
@@ -610,11 +596,6 @@ interface QQBotConfig {
610
596
  reg: string | RegExp;
611
597
  rep: string;
612
598
  }[];
613
- /** 按钮自动化 */
614
- keyboard: {
615
- /** 是否将文本中的 URL 自动转换为 keyboard 按钮 */
616
- enable: boolean;
617
- };
618
599
  /** Markdown 通道 */
619
600
  markdown: {
620
601
  /** 是否启用 Markdown 通道发送消息 */
@@ -1165,7 +1146,7 @@ declare class AdapterQQBot extends AdapterBase implements AdapterType {
1165
1146
  /**
1166
1147
  * 禁言群成员
1167
1148
  */
1168
- setGroupMute(_groupId: string, _targetId: string, _duration: number): Promise<void>;
1149
+ setGroupMute(_groupId: string, _targetId: string, duration: number): Promise<void>;
1169
1150
  /**
1170
1151
  * 群全员禁言
1171
1152
  */