@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,9 +1,8 @@
1
- import { a as readConfigFile, f as pkg, i as getDefaultConfig, n as config, o as writeConfig, s as buildFallbackWsUrlFromApi, u as normalizeProxyConfig } from "./config-BUuU6FnQ.js";
2
- import "./utils-N9PNiHjE.js";
1
+ import { a as readConfigFile, n as config, o as writeConfig, s as pkg } from "./config-D1_LqrcJ.js";
2
+ import "./utils-QpIUJhxK.js";
3
3
  import { components, defineConfig } from "node-karin";
4
4
 
5
5
  //#region src/web.config.ts
6
- const defaultProxy = () => getDefaultConfig()[0].proxy;
7
6
  const unwrapValue = (value) => {
8
7
  if (value && typeof value === "object" && "value" in value) return value.value;
9
8
  return value;
@@ -42,51 +41,6 @@ const toStringList = (value) => {
42
41
  if (!Array.isArray(raw)) return [];
43
42
  return raw.map((item) => toStringValue(item)).filter(Boolean);
44
43
  };
45
- const proxyKeys = (sandbox) => sandbox ? {
46
- api: "sandboxApi",
47
- ws: "sandboxWs"
48
- } : {
49
- api: "prodApi",
50
- ws: "prodWs"
51
- };
52
- const getBotProxyInput = (item) => {
53
- const keys = proxyKeys(!!item.sandbox);
54
- const proxy = item.proxy || defaultProxy();
55
- return {
56
- "proxy:api": proxy[keys.api] || defaultProxy()[keys.api],
57
- "proxy:ws": proxy[keys.ws] || defaultProxy()[keys.ws]
58
- };
59
- };
60
- const proxyFromInput = (item, previous) => {
61
- const def = defaultProxy();
62
- const sandbox = toBool(item.sandbox, previous?.sandbox ?? false);
63
- const previousSandbox = previous?.sandbox ?? sandbox;
64
- const keys = proxyKeys(sandbox);
65
- const previousVisibleKeys = proxyKeys(previousSandbox);
66
- const previousProxy = previous?.proxy;
67
- const base = {
68
- ...def,
69
- ...previousProxy
70
- };
71
- const envChanged = !!previous && previousSandbox !== sandbox;
72
- const rawApi = toStringValue(item["proxy:api"]).trim();
73
- const rawWs = toStringValue(item["proxy:ws"]).trim();
74
- const previousVisibleApi = previousProxy?.[previousVisibleKeys.api] || def[previousVisibleKeys.api];
75
- const previousVisibleWs = previousProxy?.[previousVisibleKeys.ws] || def[previousVisibleKeys.ws];
76
- const api = envChanged && rawApi === previousVisibleApi ? base[keys.api] || def[keys.api] : rawApi || base[keys.api] || def[keys.api];
77
- const previousApi = previousProxy?.[keys.api] || def[keys.api];
78
- const previousWs = previousProxy?.[keys.ws] || def[keys.ws];
79
- const wsInput = envChanged && rawWs === previousVisibleWs ? base[keys.ws] || def[keys.ws] : rawWs;
80
- let ws = wsInput || previousWs || def[keys.ws];
81
- const previousAutoWs = buildFallbackWsUrlFromApi(previousApi);
82
- if (!wsInput || wsInput === previousWs && previousWs === previousAutoWs && api !== previousApi) ws = buildFallbackWsUrlFromApi(api);
83
- return normalizeProxyConfig({
84
- ...base,
85
- [keys.api]: api,
86
- tokenApi: base.tokenApi || def.tokenApi,
87
- [keys.ws]: ws
88
- });
89
- };
90
44
  var web_config_default = defineConfig({
91
45
  info: {
92
46
  id: pkg().name,
@@ -102,25 +56,21 @@ var web_config_default = defineConfig({
102
56
  const data = [];
103
57
  config().forEach((item) => {
104
58
  data.push({
105
- title: item.name || item.appId,
59
+ title: item.appId,
106
60
  subtitle: item.appId,
107
- name: item.name,
108
61
  appId: item.appId,
109
62
  secret: item.secret,
110
63
  "event:type": String(item.event?.type ?? 2),
111
- sandbox: item.sandbox,
112
64
  qqEnable: item.qqEnable,
113
65
  guildEnable: item.guildEnable,
114
66
  guildMode: item.guildMode === 1,
115
67
  regex: item.regex.map((r) => `${r.reg} ${r.rep}`),
116
- "keyboard:enable": item.keyboard?.enable !== false,
117
68
  "markdown:enable": item.markdown?.enable !== false,
118
69
  "messageCache:enable": item.messageCache?.enable === true,
119
70
  "messageCache:self": item.messageCache?.self === true,
120
71
  "messageCache:level": item.messageCache?.level || "standard",
121
72
  "messageCache:ttlHours": String(item.messageCache?.ttlHours ?? 24),
122
- "messageCache:maxRows": String(item.messageCache?.maxRows ?? 2e5),
123
- ...getBotProxyInput(item)
73
+ "messageCache:maxRows": String(item.messageCache?.maxRows ?? 2e5)
124
74
  });
125
75
  });
126
76
  return [components.divider.horizontal("bot-section", {
@@ -158,10 +108,6 @@ var web_config_default = defineConfig({
158
108
  description: "基础信息",
159
109
  descPosition: 5
160
110
  }),
161
- components.input.create("name", {
162
- label: "机器人显示名称",
163
- description: "只用于 Karin 配置页和日志里识别这个机器人,不影响 QQ 平台资料。扫码登录写入配置时通常会自动填充。"
164
- }),
165
111
  components.input.create("appId", {
166
112
  label: "机器人 AppID",
167
113
  description: "QQ 开放平台机器人应用的 AppID,是识别机器人账号的唯一编号。填错会导致鉴权和收发消息失败。",
@@ -176,11 +122,6 @@ var web_config_default = defineConfig({
176
122
  description: "场景能力",
177
123
  descPosition: 5
178
124
  }),
179
- components.switch.create("sandbox", {
180
- label: "使用沙盒环境",
181
- description: "开启后这个机器人会使用沙盒环境的 OpenAPI 和 WebSocket 地址;关闭时使用正式环境地址。正式上线机器人请关闭。",
182
- defaultSelected: false
183
- }),
184
125
  components.switch.create("qqEnable", {
185
126
  label: "启用 QQ 私聊 / 群聊",
186
127
  description: "开启后会处理 QQ 单聊和群聊里的消息事件;如果这个机器人只用于频道,可以关闭。",
@@ -209,11 +150,6 @@ var web_config_default = defineConfig({
209
150
  placeholder: "^/ #"
210
151
  })
211
152
  }),
212
- components.switch.create("keyboard:enable", {
213
- label: "把链接自动转成按钮",
214
- description: "开启后,发送文本里出现的 http(s) 链接会尽量转成 QQ keyboard 按钮,用户可以直接点按钮打开链接。该能力依赖 Markdown 通道。",
215
- defaultSelected: true
216
- }),
217
153
  components.switch.create("markdown:enable", {
218
154
  label: "启用 Markdown 发送通道",
219
155
  description: "开启后普通文本和图片优先合并为 QQ Markdown 消息,Markdown 里的图片会以内嵌图片形式发送;关闭后普通图片会按官方富媒体单独上传发送。插件显式传入的 segment.markdown 不受此开关影响。",
@@ -262,18 +198,6 @@ var web_config_default = defineConfig({
262
198
  components.input.create("messageCache:maxRows", {
263
199
  label: "缓存最大消息条数",
264
200
  description: "数据库消息行数硬上限,超出后从最旧的消息开始删除,保证磁盘占用可预期。范围 1000~5000000,默认 200000。"
265
- }),
266
- components.divider.horizontal("bot-proxy-section", {
267
- description: "连接代理(高级设置:通常只在使用 Webhook 转 WebSocket 服务时填写,除非你知道你在做什么,否则不建议更改)",
268
- descPosition: 5
269
- }),
270
- components.input.create("proxy:ws", {
271
- label: "WebSocket 接入地址",
272
- description: "事件接收方式为 WebSocket 时连接的地址。常见用途是填写第三方 Webhook 转 WebSocket 服务地址,因为 QQ 后台开启 Webhook 后通常无法再直连官方 WebSocket。不填会使用当前环境的官方默认网关。"
273
- }),
274
- components.input.create("proxy:api", {
275
- label: "OpenAPI 反代地址",
276
- description: "用于发送消息、上传媒体、查询网关和获取机器人资料的 QQ OpenAPI 根地址。多数 Webhook 转 WebSocket 场景不用改这里;只有 OpenAPI 请求也需要走反代时才填写。"
277
201
  })
278
202
  ]
279
203
  }
@@ -289,12 +213,10 @@ var web_config_default = defineConfig({
289
213
  try {
290
214
  data = input.qqbot.map((item) => {
291
215
  const previous = prevConfig.find((cfg) => cfg.appId === toStringValue(item.appId));
292
- const sandbox = toBool(item.sandbox, previous?.sandbox ?? false);
293
216
  const qqEnable = toBool(item.qqEnable, previous?.qqEnable ?? true);
294
217
  const guildEnable = toBool(item.guildEnable, previous?.guildEnable ?? true);
295
218
  const guildMode = toBool(item.guildMode, previous?.guildMode === 1) ? 1 : 0;
296
219
  const eventType = Number(toStringValue(item["event:type"], String(previous?.event?.type ?? 0)));
297
- const keyboardEnable = toBool(item["keyboard:enable"], previous?.keyboard?.enable ?? true);
298
220
  const markdownEnable = toBool(item["markdown:enable"], previous?.markdown?.enable ?? true);
299
221
  const messageCacheEnable = toBool(item["messageCache:enable"], previous?.messageCache?.enable ?? false);
300
222
  const messageCacheSelf = toBool(item["messageCache:self"], previous?.messageCache?.self ?? false);
@@ -312,18 +234,13 @@ var web_config_default = defineConfig({
312
234
  rep: parts[1]?.replace(/^<|>$/g, "") || ""
313
235
  };
314
236
  });
315
- const proxy = proxyFromInput(item, previous);
316
237
  return {
317
- name: toStringValue(item.name),
318
238
  appId: toStringValue(item.appId),
319
239
  secret: toStringValue(item.secret),
320
- proxy,
321
- sandbox,
322
240
  qqEnable,
323
241
  guildEnable,
324
242
  guildMode,
325
243
  regex,
326
- keyboard: { enable: keyboardEnable },
327
244
  markdown: { enable: markdownEnable },
328
245
  messageCache: {
329
246
  enable: messageCacheEnable,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karinjs/adapter-qqbot",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "karin adapter for QQ Official Bot (2.0)",
5
5
  "homepage": "https://github.com/KarinJS/karin-plugin-adapter-qqbot",
6
6
  "bugs": {
@@ -25,7 +25,8 @@
25
25
  "lint:fix": "eslint src --fix",
26
26
  "pub": "npm publish --access public",
27
27
  "sort": "npx sort-package-json",
28
- "test": "node tests/schema-sql.test.mjs && node tests/codec.test.mjs && node tests/store.test.mjs"
28
+ "test": "node tests/schema-sql.test.mjs && node tests/codec.test.mjs && node tests/store.test.mjs",
29
+ "typecheck": "npx tsc --noEmit"
29
30
  },
30
31
  "dependencies": {
31
32
  "@karinjs/geturls": "^1.0.2",
@@ -1,49 +0,0 @@
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 };
@@ -1,252 +0,0 @@
1
- import { i as getDefaultConfig, m as log, n as config, o as writeConfig } from "./config-BUuU6FnQ.js";
2
- import { a as sleep } from "./common-by1GpV0b.js";
3
- import qrcode from "qrcode";
4
- import axios from "node-karin/axios";
5
- import crypto from "node:crypto";
6
-
7
- //#region src/core/onboard/crypto.ts
8
- /**
9
- * 生成 256-bit 随机 AES 密钥(base64)
10
- */
11
- const generateBindKey = () => crypto.randomBytes(32).toString("base64");
12
- /**
13
- * 解密 bot_encrypt_secret(AES-256-GCM)
14
- *
15
- * 密文布局:IV (12 bytes) || ciphertext (N bytes) || AuthTag (16 bytes)
16
- */
17
- const decryptSecret = (encryptedBase64, keyBase64) => {
18
- const key = Buffer.from(keyBase64, "base64");
19
- if (key.length !== 32) throw new Error(`AES key length must be 32 bytes, got ${key.length}`);
20
- const raw = Buffer.from(encryptedBase64, "base64");
21
- const iv = raw.subarray(0, 12);
22
- const ciphertext = raw.subarray(12, raw.length - 16);
23
- const authTag = raw.subarray(raw.length - 16);
24
- const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
25
- decipher.setAuthTag(authTag);
26
- let plaintext = decipher.update(ciphertext, void 0, "utf8");
27
- plaintext += decipher.final("utf8");
28
- return plaintext;
29
- };
30
-
31
- //#endregion
32
- //#region src/core/onboard/portal.ts
33
- /** QQ 开放平台扫码绑定服务域名 */
34
- const PORTAL_HOST = "q.qq.com";
35
- const CREATE_PATH = "/lite/create_bind_task";
36
- const POLL_PATH = "/lite/poll_bind_result";
37
- /** 扫码页面模板 */
38
- const CONNECT_URL = "https://q.qq.com/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=karin";
39
- const headers = {
40
- "Content-Type": "application/json",
41
- Accept: "application/json"
42
- };
43
- /** 绑定状态 */
44
- const BindStatus = {
45
- NONE: 0,
46
- PENDING: 1,
47
- COMPLETED: 2,
48
- EXPIRED: 3
49
- };
50
- /**
51
- * 创建绑定任务
52
- * @returns task_id 与本地生成的 AES key
53
- */
54
- const createBindTask = async () => {
55
- const key = generateBindKey();
56
- const { data } = await axios.post(`https://${PORTAL_HOST}${CREATE_PATH}`, { key }, { headers });
57
- if (data.retcode !== 0) throw new Error(`create_bind_task: ${data.msg || "unknown error"}`);
58
- const taskId = data.data?.task_id;
59
- if (!taskId) throw new Error("create_bind_task: missing task_id");
60
- return {
61
- taskId,
62
- aesKey: key
63
- };
64
- };
65
- /**
66
- * 轮询绑定结果
67
- */
68
- const pollBindResult = async (taskId) => {
69
- const { data } = await axios.post(`https://${PORTAL_HOST}${POLL_PATH}`, { task_id: taskId }, { headers });
70
- if (data.retcode !== 0) throw new Error(`poll_bind_result: ${data.msg || "unknown error"}`);
71
- const d = data.data ?? {};
72
- return {
73
- status: d.status ?? BindStatus.NONE,
74
- appId: String(d.bot_appid ?? ""),
75
- encryptedSecret: d.bot_encrypt_secret ?? "",
76
- userOpenid: d.user_openid ?? ""
77
- };
78
- };
79
- /**
80
- * 构造扫码 URL
81
- */
82
- const buildConnectUrl = (taskId) => CONNECT_URL.replace("{task_id}", encodeURIComponent(taskId));
83
- /**
84
- * 通过 appId + secret 直接调一次 @me,拿到 bot 昵称
85
- * 用于扫码完成后的展示与写入配置
86
- */
87
- const fetchBotName = async (appId, secret) => {
88
- try {
89
- const accessToken = (await axios.post("https://bots.qq.com/app/getAppAccessToken", {
90
- appId,
91
- clientSecret: secret
92
- })).data?.access_token;
93
- if (!accessToken) return "";
94
- return (await axios.get("https://api.sgroup.qq.com/users/@me", { headers: { Authorization: `QQBot ${accessToken}` } })).data?.username || "";
95
- } catch {
96
- return "";
97
- }
98
- };
99
-
100
- //#endregion
101
- //#region src/core/onboard/qr.ts
102
- /** 默认轮询间隔(毫秒) */
103
- const POLL_INTERVAL = 2e3;
104
- /** 默认总超时(秒) */
105
- const DEFAULT_TIMEOUT_S = 600;
106
- /** 单张二维码硬编码有效期:60s,到时主动判定过期触发刷新 */
107
- const QR_TTL_MS = 6e4;
108
- /** 二维码过期最大刷新次数 */
109
- const MAX_REFRESHES = 3;
110
- /**
111
- * 生成二维码(同时拿到 ascii / base64)
112
- */
113
- const renderQr = async (url) => {
114
- return {
115
- ascii: await qrcode.toString(url, {
116
- type: "terminal",
117
- small: true
118
- }).catch(() => ""),
119
- base64: `base64://${(await qrcode.toDataURL(url, {
120
- width: 320,
121
- margin: 2
122
- })).split(",")[1]}`
123
- };
124
- };
125
- /**
126
- * 扫码登录主流程:
127
- * 1. 创建绑定任务,调用 onQr 暴露二维码
128
- * 2. 轮询直到 COMPLETED / 二维码过期刷新 / 超时
129
- * 3. 解密返回 secret
130
- */
131
- const qrRegister = async (opts = {}) => {
132
- const { timeoutSeconds = DEFAULT_TIMEOUT_S, printTerminal = false, onQr } = opts;
133
- const deadline = Date.now() + timeoutSeconds * 1e3;
134
- for (let refresh = 0; refresh <= MAX_REFRESHES; refresh++) {
135
- let task;
136
- try {
137
- task = await createBindTask();
138
- } catch (err) {
139
- log("error", "[扫码登录] 创建绑定任务失败:", err);
140
- return null;
141
- }
142
- const url = buildConnectUrl(task.taskId);
143
- const { ascii, base64 } = await renderQr(url);
144
- const qrDeadline = Date.now() + QR_TTL_MS;
145
- if (printTerminal) {
146
- if (ascii) {
147
- console.log();
148
- console.log(ascii);
149
- }
150
- console.log(` 请使用 QQ 扫码(60s 内有效),或在手机 QQ 打开:\n ${url}\n`);
151
- }
152
- if (onQr) try {
153
- await onQr({
154
- url,
155
- ascii,
156
- base64,
157
- refresh
158
- });
159
- } catch (err) {
160
- log("warn", "[扫码登录] onQr 回调异常:", err);
161
- }
162
- let expired = false;
163
- while (Date.now() < deadline) {
164
- if (Date.now() >= qrDeadline) {
165
- expired = true;
166
- break;
167
- }
168
- let result;
169
- try {
170
- result = await pollBindResult(task.taskId);
171
- } catch {
172
- await sleep(POLL_INTERVAL);
173
- continue;
174
- }
175
- if (result.status === BindStatus.COMPLETED) {
176
- if (!result.encryptedSecret) {
177
- log("error", "[扫码登录] 绑定结果缺少加密密钥");
178
- return null;
179
- }
180
- let secret;
181
- try {
182
- secret = decryptSecret(result.encryptedSecret, task.aesKey);
183
- } catch (err) {
184
- log("error", "[扫码登录] 解密 secret 失败:", err);
185
- return null;
186
- }
187
- log("info", `[扫码登录] 授权成功,App ID: ${result.appId}`);
188
- return {
189
- appId: result.appId,
190
- secret,
191
- userOpenid: result.userOpenid
192
- };
193
- }
194
- if (result.status === BindStatus.EXPIRED) {
195
- expired = true;
196
- break;
197
- }
198
- await sleep(POLL_INTERVAL);
199
- }
200
- if (expired) {
201
- if (refresh >= MAX_REFRESHES) {
202
- log("error", `[扫码登录] 二维码已过期 4 次,终止流程`);
203
- return null;
204
- }
205
- log("info", `[扫码登录] 二维码 60s 内未完成扫码,正在刷新 (${refresh + 1}/${MAX_REFRESHES})`);
206
- continue;
207
- }
208
- log("error", `[扫码登录] 等待扫码总超时(${timeoutSeconds}s)`);
209
- return null;
210
- }
211
- return null;
212
- };
213
-
214
- //#endregion
215
- //#region src/core/onboard/index.ts
216
- /** 是否需要触发扫码引导:完全没有 bot 配置时为 true */
217
- const needQrOnboard = () => config().length === 0;
218
- /**
219
- * 执行扫码登录并把结果写入 config.json
220
- *
221
- * - 同 appId 已存在 → 覆盖 secret / name
222
- * - 不存在 → 追加新配置
223
- *
224
- * 支持 `opts.onQr` 把新二维码透出给调用方(如 #QQ登录 指令把图片回给用户)
225
- */
226
- const runQrOnboard = async (opts = {}) => {
227
- const result = await qrRegister(opts);
228
- if (!result) return false;
229
- const name = await fetchBotName(result.appId, result.secret);
230
- if (name) log("info", `[扫码登录] Bot 名称: ${name}`);
231
- const list = config();
232
- const idx = list.findIndex((item) => item.appId === result.appId);
233
- if (idx >= 0) {
234
- list[idx].secret = result.secret;
235
- if (name) list[idx].name = name;
236
- log("info", `[扫码登录] 已更新已有配置: ${result.appId}`);
237
- } else {
238
- const defaults = getDefaultConfig()[0];
239
- list.push({
240
- ...defaults,
241
- name: name || "",
242
- appId: result.appId,
243
- secret: result.secret
244
- });
245
- log("info", `[扫码登录] 已添加新配置: ${result.appId}`);
246
- }
247
- writeConfig(list);
248
- return true;
249
- };
250
-
251
- //#endregion
252
- export { runQrOnboard as n, needQrOnboard as t };
@@ -1,4 +0,0 @@
1
- import "./config-BUuU6FnQ.js";
2
- import "./common-by1GpV0b.js";
3
-
4
- export { };