@karinjs/adapter-qqbot 2.2.1 → 3.0.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.
@@ -1,5 +1,9 @@
1
1
  //#region src/apps/cleanup.d.ts
2
- /** 每 10 分钟清理一次过期的 getMsg/getHistoryMsg 本地缓存。 */
3
- declare const clearMessageCache: import("node-karin").Task | undefined;
2
+ /**
3
+ * 每 10 分钟清理一次过期的 getMsg/getHistoryMsg 本地缓存与 file_info 上传缓存。
4
+ *
5
+ * file_info 上传缓存不依赖 messageCache 开关,因此任务始终注册。
6
+ */
7
+ declare const clearMessageCache: import("node-karin").Task;
4
8
  //#endregion
5
9
  export { clearMessageCache };
@@ -1,22 +1,17 @@
1
- import { n as config } from "../config-B_Gc9JKZ.js";
2
- import { t as getMessageStore } from "../message-Dt4QkDYn.js";
1
+ import { t as getMessageStore } from "../message-CVJVFAGR.js";
3
2
  import karin from "node-karin";
4
3
 
5
4
  //#region src/apps/cleanup.ts
6
5
  /**
7
- * 判断是否有任意 QQBot 配置开启消息缓存。
6
+ * 每 10 分钟清理一次过期的 getMsg/getHistoryMsg 本地缓存与 file_info 上传缓存。
8
7
  *
9
- * @returns true 表示需要注册消息缓存清理任务。
8
+ * file_info 上传缓存不依赖 messageCache 开关,因此任务始终注册。
10
9
  */
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(), {
10
+ const clearMessageCache = karin.task("qqbot 适配器消息缓存清理", "*/10 * * * *", async () => await getMessageStore().cleanupExpired(), {
16
11
  name: "qqbot 适配器消息缓存清理任务",
17
12
  log: false,
18
13
  type: "skip"
19
- }) : void 0;
14
+ });
20
15
 
21
16
  //#endregion
22
17
  export { clearMessageCache };
@@ -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-B_Gc9JKZ.js";
2
- import { n as runQrOnboard } from "../onboard-BXQ1cG8h.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 };
@@ -0,0 +1,196 @@
1
+ import { common, karinPathBase, logger, requireFileSync, watch } from "node-karin";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import fs from "node:fs";
5
+
6
+ //#region src/root.ts
7
+ /** 当前文件的绝对路径 */
8
+ const filePath = fileURLToPath(import.meta.url);
9
+ /** 插件包绝对路径 */
10
+ const dirPath = path.resolve(filePath, "../..");
11
+ /** 插件包的名称 */
12
+ const basename = path.basename(dirPath);
13
+
14
+ //#endregion
15
+ //#region src/utils/plugin.ts
16
+ /**
17
+ * 读取当前插件的 package.json。
18
+ *
19
+ * @returns package.json 内容。
20
+ */
21
+ const pkg = () => requireFileSync(`${dirPath}/package.json`);
22
+ /** 当前插件 npm 包名,如 @karinjs/adapter-qqbot。 */
23
+ const pluginName = pkg().name;
24
+ /** 文件系统目录名:把 npm 包名里的 `/` 规范化为 `-`,与 Karin 框架对齐。 */
25
+ const pluginDirName = pluginName.replace(/\//g, "-");
26
+
27
+ //#endregion
28
+ //#region src/utils/config.ts
29
+ let cache;
30
+ const cacheMap = {};
31
+ /** 延迟注入,避免与 core/index.ts 循环依赖 */
32
+ let _createBot;
33
+ let _destroyBot;
34
+ const bindHandlers = (createBot, destroyBot) => {
35
+ _createBot = createBot;
36
+ _destroyBot = destroyBot;
37
+ };
38
+ /** 配置目录:${karinPathBase}/@karinjs-adapter-qqbot/config */
39
+ const dirConfig = `${karinPathBase}/${pluginDirName}/config`;
40
+ const configPath = `${dirConfig}/config.json`;
41
+ const syncCache = (data) => {
42
+ cache = data;
43
+ Object.keys(cacheMap).forEach((key) => delete cacheMap[key]);
44
+ data.forEach((v) => {
45
+ cacheMap[v.appId] = v;
46
+ });
47
+ };
48
+ /**
49
+ * 读取配置
50
+ */
51
+ const config = () => {
52
+ if (!fs.existsSync(configPath)) {
53
+ fs.mkdirSync(dirConfig, { recursive: true });
54
+ fs.writeFileSync(configPath, JSON.stringify([], null, 2));
55
+ }
56
+ if (cache) return cache;
57
+ const user = requireFileSync(configPath);
58
+ const result = formatConfig(user);
59
+ syncCache(result);
60
+ return result;
61
+ };
62
+ /**
63
+ * 直接从磁盘读取配置,不使用内存缓存。
64
+ */
65
+ const readConfigFile = () => {
66
+ if (!fs.existsSync(configPath)) return config();
67
+ return formatConfig(requireFileSync(configPath));
68
+ };
69
+ /**
70
+ * 获取单 bot 配置
71
+ */
72
+ const getConfig = (appid) => cacheMap[appid];
73
+ /**
74
+ * 写入配置
75
+ */
76
+ const writeConfig = (data) => {
77
+ fs.mkdirSync(dirConfig, { recursive: true });
78
+ const normalized = formatConfig(data);
79
+ fs.writeFileSync(configPath, JSON.stringify(normalized, null, 2));
80
+ syncCache(normalized);
81
+ };
82
+ /**
83
+ * 用默认值补齐用户配置
84
+ */
85
+ const formatConfig = (user) => {
86
+ const def = getDefaultConfig()[0];
87
+ return user.map((item) => {
88
+ const { markdown, messageCache, event, ...rest } = item;
89
+ return {
90
+ ...def,
91
+ ...rest,
92
+ markdown: {
93
+ ...def.markdown,
94
+ ...markdown
95
+ },
96
+ messageCache: normalizeMessageCache(def.messageCache, messageCache),
97
+ event: {
98
+ ...def.event,
99
+ ...event
100
+ }
101
+ };
102
+ });
103
+ };
104
+ const MESSAGE_CACHE_LEVELS = [
105
+ "minimal",
106
+ "standard",
107
+ "full"
108
+ ];
109
+ /**
110
+ * 规范化消息缓存配置,非法枚举与越界数值回落默认值。
111
+ *
112
+ * @param def 默认消息缓存配置。
113
+ * @param user 用户消息缓存配置。
114
+ * @returns 补齐并夹取后的消息缓存配置。
115
+ */
116
+ const normalizeMessageCache = (def, user) => {
117
+ const merged = {
118
+ ...def,
119
+ ...user
120
+ };
121
+ return {
122
+ enable: !!merged.enable,
123
+ self: !!merged.self,
124
+ level: MESSAGE_CACHE_LEVELS.includes(merged.level) ? merged.level : def.level,
125
+ ttlHours: clampNumber(merged.ttlHours, 1, 720, def.ttlHours),
126
+ maxRows: clampNumber(merged.maxRows, 1e3, 5e6, def.maxRows)
127
+ };
128
+ };
129
+ /**
130
+ * 将数值夹取到闭区间;非法值返回 fallback。
131
+ *
132
+ * @param value 用户输入值。
133
+ * @param min 最小值。
134
+ * @param max 最大值。
135
+ * @param fallback 非法时使用的默认值。
136
+ * @returns 合法数值。
137
+ */
138
+ const clampNumber = (value, min, max, fallback) => {
139
+ const num = Number(value);
140
+ if (!Number.isFinite(num)) return fallback;
141
+ return Math.min(Math.max(Math.trunc(num), min), max);
142
+ };
143
+ /**
144
+ * 默认配置
145
+ */
146
+ const getDefaultConfig = () => [{
147
+ appId: "",
148
+ secret: "",
149
+ qqEnable: true,
150
+ guildEnable: true,
151
+ guildMode: 0,
152
+ regex: [{
153
+ reg: "^/",
154
+ rep: "#"
155
+ }],
156
+ markdown: { enable: true },
157
+ messageCache: {
158
+ enable: false,
159
+ self: false,
160
+ level: "standard",
161
+ ttlHours: 24,
162
+ maxRows: 2e5
163
+ },
164
+ event: { type: 2 }
165
+ }];
166
+ /**
167
+ * 监听配置文件,比较前后差异决定重建 / 销毁 bot
168
+ */
169
+ setTimeout(() => {
170
+ watch(`${dirConfig}/config.json`, (old, now) => {
171
+ const oldConfig = formatConfig(old);
172
+ const nowConfig = formatConfig(now);
173
+ syncCache(nowConfig);
174
+ const diff = common.diffArray(oldConfig, nowConfig);
175
+ diff.removed.forEach((v) => _destroyBot?.(v.appId));
176
+ if (diff.added.length > 0) {
177
+ const ids = new Set(diff.added.map((v) => v.appId));
178
+ nowConfig.forEach((v) => {
179
+ if (!ids.has(v.appId)) return;
180
+ if (v.event.type === 0) return;
181
+ _createBot?.(v);
182
+ });
183
+ }
184
+ diff.common.forEach((curr) => {
185
+ const prev = oldConfig.find((c) => c.appId === curr.appId);
186
+ if (!prev) return;
187
+ if (JSON.stringify(prev) === JSON.stringify(curr)) return;
188
+ logger.info(`[QQ Official Bot][配置监听] 配置已变更: ${curr.appId},重新初始化`);
189
+ _destroyBot?.(prev.appId);
190
+ if (curr.event.type !== 0) _createBot?.(curr);
191
+ });
192
+ });
193
+ }, 2e3);
194
+
195
+ //#endregion
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 };