@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.
- package/dist/apps/cleanup.d.ts +6 -2
- package/dist/apps/cleanup.js +5 -10
- package/dist/apps/login.d.ts +2 -16
- package/dist/apps/login.js +138 -85
- package/dist/config-D1_LqrcJ.js +196 -0
- package/dist/index.d.ts +409 -234
- package/dist/index.js +347 -365
- package/dist/logger-sza6kuy3.js +27 -0
- package/dist/message-CVJVFAGR.js +1844 -0
- package/dist/utils-lb9ZdWay.js +32 -0
- package/dist/web.config.d.ts +3 -5
- package/dist/web.config.js +44 -84
- package/package.json +8 -5
- package/dist/common-by1GpV0b.js +0 -49
- package/dist/config-B_Gc9JKZ.js +0 -253
- package/dist/message-Dt4QkDYn.js +0 -1739
- package/dist/onboard-BXQ1cG8h.js +0 -252
- package/dist/utils-DIq0lGMt.js +0 -4
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import "./utils-
|
|
4
|
-
import { t as getMessageStore } from "./message-
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
1
|
+
import { l as basename, n as config, r as getConfig, s as pkg, t as bindHandlers } from "./config-D1_LqrcJ.js";
|
|
2
|
+
import { t as log } from "./logger-sza6kuy3.js";
|
|
3
|
+
import { i as random, n as fakeEvent, r as getImageSize, t as QQAPIBASEURL } from "./utils-lb9ZdWay.js";
|
|
4
|
+
import { t as getMessageStore } from "./message-CVJVFAGR.js";
|
|
5
|
+
import karin, { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupApplyRequest, createGroupInviteRequest, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, senderGroup, unregisterBot } from "node-karin";
|
|
6
|
+
import path from "path";
|
|
7
7
|
import { URL } from "url";
|
|
8
8
|
import lodash from "node-karin/lodash";
|
|
9
9
|
import { createReadStream } from "node:fs";
|
|
@@ -11,7 +11,7 @@ import FormData from "form-data";
|
|
|
11
11
|
import axios from "node-karin/axios";
|
|
12
12
|
import crypto from "node:crypto";
|
|
13
13
|
import { open, readFile, stat } from "node:fs/promises";
|
|
14
|
-
import path from "node:path";
|
|
14
|
+
import path$1 from "node:path";
|
|
15
15
|
import { fileURLToPath as fileURLToPath$1 } from "node:url";
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import EventEmitter from "node:events";
|
|
@@ -22,7 +22,7 @@ import nacl from "tweetnacl";
|
|
|
22
22
|
//#region src/core/api/error.ts
|
|
23
23
|
/**
|
|
24
24
|
* OpenAPI 错误码映射表
|
|
25
|
-
* 来源: api-
|
|
25
|
+
* 来源: api-docs/openapi/error/error.md
|
|
26
26
|
*
|
|
27
27
|
* 规则:
|
|
28
28
|
* - 区间码 (如 301000~301099) 用范围匹配
|
|
@@ -41,7 +41,7 @@ const httpStatusMap = {
|
|
|
41
41
|
500: "处理失败",
|
|
42
42
|
504: "处理失败"
|
|
43
43
|
};
|
|
44
|
-
/** 业务错误码(
|
|
44
|
+
/** 业务错误码(err_code)映射 */
|
|
45
45
|
const errorCodeMap = {
|
|
46
46
|
10001: "UnknownAccount 账号异常",
|
|
47
47
|
10003: "UnknownChannel 子频道异常",
|
|
@@ -359,15 +359,16 @@ const formatOpenAPIError = (status, code, message) => {
|
|
|
359
359
|
//#region src/core/api/http.ts
|
|
360
360
|
/**
|
|
361
361
|
* 包装 axios 错误为可读多行 message
|
|
362
|
-
* 如果响应中包含 QQ 官方错误码(
|
|
362
|
+
* 如果响应中包含 QQ 官方错误码(err_code),会自动映射为中文描述
|
|
363
363
|
*/
|
|
364
364
|
const formatError = (path, options, err) => {
|
|
365
365
|
if (axios.isAxiosError(err)) {
|
|
366
366
|
const response = err.response;
|
|
367
367
|
const status = response?.status ?? 0;
|
|
368
368
|
const data = response?.data;
|
|
369
|
-
const code = typeof data?.code === "number" ? data.code : void 0;
|
|
369
|
+
const code = typeof data?.err_code === "number" ? data.err_code : typeof data?.code === "number" ? data.code : void 0;
|
|
370
370
|
const msg = typeof data?.message === "string" ? data.message : void 0;
|
|
371
|
+
const traceId = typeof data?.trace_id === "string" ? data.trace_id : void 0;
|
|
371
372
|
const lines = [];
|
|
372
373
|
lines.push("[axios] 请求失败");
|
|
373
374
|
lines.push(`请求路径: ${path}`);
|
|
@@ -377,6 +378,7 @@ const formatError = (path, options, err) => {
|
|
|
377
378
|
if (reason) lines.push(`请求错误: ${reason}`);
|
|
378
379
|
}
|
|
379
380
|
if (code !== void 0 || status > 0) lines.push(`错误详情: ${formatOpenAPIError(status, code, msg)}`);
|
|
381
|
+
if (traceId) lines.push(`TraceID: ${traceId}`);
|
|
380
382
|
lines.push(`响应数据: ${JSON.stringify(data)}`);
|
|
381
383
|
return new Error(lines.join("\n"));
|
|
382
384
|
}
|
|
@@ -447,6 +449,8 @@ var Http = class {
|
|
|
447
449
|
|
|
448
450
|
//#endregion
|
|
449
451
|
//#region src/core/api/messages.ts
|
|
452
|
+
/** 输入中状态默认展示时长(秒),平台 typing 窗口约 60s */
|
|
453
|
+
const INPUT_NOTIFY_DEFAULT_SECOND = 60;
|
|
450
454
|
/**
|
|
451
455
|
* 消息发送 + 撤回
|
|
452
456
|
*/
|
|
@@ -459,6 +463,33 @@ var MessagesApi = class extends Http {
|
|
|
459
463
|
sendGroupMsg(groupOpenid, body) {
|
|
460
464
|
return this.post(`/v2/groups/${groupOpenid}/messages`, body);
|
|
461
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* 单聊输入中状态(msg_type=6,仅单聊)
|
|
468
|
+
* @param openid 用户 openid
|
|
469
|
+
* @param inputSecond 展示时长(秒),默认 60
|
|
470
|
+
* @param passive 被动消息参数(msg_id / event_id / msg_seq),可选
|
|
471
|
+
*/
|
|
472
|
+
sendFriendInputNotify(openid, inputSecond = INPUT_NOTIFY_DEFAULT_SECOND, passive) {
|
|
473
|
+
return this.post(`/v2/users/${openid}/messages`, {
|
|
474
|
+
msg_type: 6,
|
|
475
|
+
input_notify: {
|
|
476
|
+
input_type: 1,
|
|
477
|
+
input_second: inputSecond
|
|
478
|
+
},
|
|
479
|
+
...passive
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* 单聊流式消息(仅单聊)
|
|
484
|
+
*
|
|
485
|
+
* 首帧不带 stream_msg_id,响应的 id 作为后续帧的 stream_msg_id 回传;
|
|
486
|
+
* 同一次流 index 从 0 递增、msg_seq 保持一致;建议帧间隔 300~500ms。
|
|
487
|
+
* @param openid 用户 openid
|
|
488
|
+
* @param body 流式消息帧
|
|
489
|
+
*/
|
|
490
|
+
sendFriendStreamMsg(openid, body) {
|
|
491
|
+
return this.post(`/v2/users/${openid}/stream_messages`, body);
|
|
492
|
+
}
|
|
462
493
|
/** 文字子频道消息 */
|
|
463
494
|
sendChannelMsg(channelId, body) {
|
|
464
495
|
const headers = body instanceof FormData ? body.getHeaders() : void 0;
|
|
@@ -485,13 +516,10 @@ var MessagesApi = class extends Http {
|
|
|
485
516
|
|
|
486
517
|
//#endregion
|
|
487
518
|
//#region src/core/api/upload-cache.ts
|
|
488
|
-
/** 单进程内最多缓存的 file_info 数量,避免长期运行时内存无限增长。 */
|
|
489
|
-
const MAX_CACHE_SIZE = 500;
|
|
490
519
|
/** 比 QQ 返回的 ttl 提前失效,避免临界点使用到刚过期的 file_info。 */
|
|
491
520
|
const TTL_SAFETY_MARGIN_SECONDS = 60;
|
|
492
521
|
/** QQ 返回极短 ttl 时仍保留一个最小可用窗口。 */
|
|
493
522
|
const MIN_EFFECTIVE_TTL_SECONDS = 10;
|
|
494
|
-
const cache = /* @__PURE__ */ new Map();
|
|
495
523
|
/**
|
|
496
524
|
* 构建 file_info 缓存键。
|
|
497
525
|
*
|
|
@@ -511,41 +539,25 @@ const cacheKey = (scene, peer, type, contentHash) => [
|
|
|
511
539
|
contentHash
|
|
512
540
|
].join("");
|
|
513
541
|
/**
|
|
514
|
-
* 惰性清理过期缓存。
|
|
515
|
-
* @param now 当前时间戳。
|
|
516
|
-
*/
|
|
517
|
-
const cleanupExpired = (now = Date.now()) => {
|
|
518
|
-
for (const [key, entry] of cache) if (entry.expiresAt <= now) cache.delete(key);
|
|
519
|
-
};
|
|
520
|
-
/**
|
|
521
|
-
* 缓存满时淘汰最旧的一批条目。
|
|
522
|
-
*/
|
|
523
|
-
const evictOldest = () => {
|
|
524
|
-
if (cache.size < MAX_CACHE_SIZE) return;
|
|
525
|
-
cleanupExpired();
|
|
526
|
-
if (cache.size < MAX_CACHE_SIZE) return;
|
|
527
|
-
const entries = [...cache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
528
|
-
const removeCount = Math.max(1, Math.ceil(MAX_CACHE_SIZE / 2));
|
|
529
|
-
for (const [key] of entries.slice(0, removeCount)) cache.delete(key);
|
|
530
|
-
};
|
|
531
|
-
/**
|
|
532
542
|
* 读取可复用的上传结果。
|
|
533
543
|
*
|
|
544
|
+
* 缓存持久化在 SQLite(file_info ttl 可达数天),进程重启后同一份媒体
|
|
545
|
+
* 不需要重新上传。缓存层异常一律按未命中处理,不影响上传主链路。
|
|
546
|
+
*
|
|
534
547
|
* @param scene 上传场景。
|
|
535
548
|
* @param peer openid 或 group_openid。
|
|
536
549
|
* @param type 富媒体类型。
|
|
537
550
|
* @param contentHash 文件内容 hash。
|
|
538
551
|
* @returns 未命中或过期时返回 null。
|
|
539
552
|
*/
|
|
540
|
-
const getCachedUpload = (scene, peer, type, contentHash) => {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
553
|
+
const getCachedUpload = async (scene, peer, type, contentHash) => {
|
|
554
|
+
try {
|
|
555
|
+
const raw = await getMessageStore().getUploadCache(cacheKey(scene, peer, type, contentHash));
|
|
556
|
+
if (!raw) return null;
|
|
557
|
+
return JSON.parse(raw);
|
|
558
|
+
} catch {
|
|
546
559
|
return null;
|
|
547
560
|
}
|
|
548
|
-
return { ...entry.response };
|
|
549
561
|
};
|
|
550
562
|
/**
|
|
551
563
|
* 写入上传结果缓存。
|
|
@@ -556,17 +568,13 @@ const getCachedUpload = (scene, peer, type, contentHash) => {
|
|
|
556
568
|
* @param contentHash 文件内容 hash。
|
|
557
569
|
* @param response 上传接口返回。
|
|
558
570
|
*/
|
|
559
|
-
const setCachedUpload = (scene, peer, type, contentHash, response) => {
|
|
571
|
+
const setCachedUpload = async (scene, peer, type, contentHash, response) => {
|
|
560
572
|
if (!response.file_info) return;
|
|
561
|
-
evictOldest();
|
|
562
|
-
const now = Date.now();
|
|
563
573
|
const ttl = Number(response.ttl);
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
createdAt: now
|
|
569
|
-
});
|
|
574
|
+
const expiresAt = ttl > 0 ? Date.now() + Math.max(ttl - TTL_SAFETY_MARGIN_SECONDS, MIN_EFFECTIVE_TTL_SECONDS) * 1e3 : 0;
|
|
575
|
+
try {
|
|
576
|
+
await getMessageStore().setUploadCache(cacheKey(scene, peer, type, contentHash), JSON.stringify(response), expiresAt);
|
|
577
|
+
} catch {}
|
|
570
578
|
};
|
|
571
579
|
|
|
572
580
|
//#endregion
|
|
@@ -633,7 +641,7 @@ const DEFAULT_PART_CONCURRENCY = 1;
|
|
|
633
641
|
/** 本地限制的最大分片并发,避免服务端返回过大值。 */
|
|
634
642
|
const MAX_PART_CONCURRENCY = 10;
|
|
635
643
|
/** HTTP(S) URL 可直接交给 QQ 平台拉取。 */
|
|
636
|
-
const HTTP_URL_RE$
|
|
644
|
+
const HTTP_URL_RE$2 = /^https?:\/\//i;
|
|
637
645
|
/** Karin 常见 base64 图片/媒体前缀。 */
|
|
638
646
|
const BASE64_PREFIX_RE = /^base64:\/\//i;
|
|
639
647
|
/** 标准 data URL 的 base64 前缀。 */
|
|
@@ -680,7 +688,7 @@ const formatBytes = (bytes) => {
|
|
|
680
688
|
const normalizeUploadFileName = (type, name) => {
|
|
681
689
|
const value = name?.trim();
|
|
682
690
|
if (!value) return DEFAULT_FILENAME$1[type];
|
|
683
|
-
return path.basename(value) || DEFAULT_FILENAME$1[type];
|
|
691
|
+
return path$1.basename(value) || DEFAULT_FILENAME$1[type];
|
|
684
692
|
};
|
|
685
693
|
/**
|
|
686
694
|
* 检查 fallback 资源大小是否超过 QQ 对应类型上限。
|
|
@@ -729,7 +737,7 @@ const downloadRemoteAsBase64 = async (url) => {
|
|
|
729
737
|
*/
|
|
730
738
|
const buildUploadSource = async (source) => {
|
|
731
739
|
const value = source.trim();
|
|
732
|
-
if (HTTP_URL_RE$
|
|
740
|
+
if (HTTP_URL_RE$2.test(value)) return { url: value };
|
|
733
741
|
if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
|
|
734
742
|
if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
|
|
735
743
|
return { file_data: value };
|
|
@@ -745,7 +753,7 @@ const buildUploadSource = async (source) => {
|
|
|
745
753
|
const buildFallbackSource = async (type, source, fileName) => {
|
|
746
754
|
const value = source.trim();
|
|
747
755
|
const normalizedName = normalizeUploadFileName(type, fileName);
|
|
748
|
-
if (HTTP_URL_RE$
|
|
756
|
+
if (HTTP_URL_RE$2.test(value)) {
|
|
749
757
|
const buffer = await downloadRemoteAsBuffer(value);
|
|
750
758
|
return {
|
|
751
759
|
kind: "buffer",
|
|
@@ -766,7 +774,7 @@ const buildFallbackSource = async (type, source, fileName) => {
|
|
|
766
774
|
if (isLocalPathSource(value)) {
|
|
767
775
|
const file = normalizeLocalPath(value);
|
|
768
776
|
const info = await stat(file);
|
|
769
|
-
const name = normalizeUploadFileName(type, fileName || path.basename(file));
|
|
777
|
+
const name = normalizeUploadFileName(type, fileName || path$1.basename(file));
|
|
770
778
|
return {
|
|
771
779
|
kind: "local",
|
|
772
780
|
file,
|
|
@@ -983,12 +991,12 @@ var MediaApi = class extends Http {
|
|
|
983
991
|
assertUploadSize(type, fallbackSource.size);
|
|
984
992
|
const hashes = !srvSendMsg ? await computeUploadHashes(fallbackSource) : void 0;
|
|
985
993
|
if (hashes) {
|
|
986
|
-
const cached = getCachedUpload(scene, peer, type, hashes.md5);
|
|
994
|
+
const cached = await getCachedUpload(scene, peer, type, hashes.md5);
|
|
987
995
|
if (cached) return cached;
|
|
988
996
|
}
|
|
989
997
|
if (!srvSendMsg && fallbackSource.size >= LARGE_UPLOAD_THRESHOLD) {
|
|
990
998
|
const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
|
|
991
|
-
if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
999
|
+
if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
992
1000
|
return response;
|
|
993
1001
|
}
|
|
994
1002
|
const body = {
|
|
@@ -998,7 +1006,7 @@ var MediaApi = class extends Http {
|
|
|
998
1006
|
};
|
|
999
1007
|
if (type === "file") body.file_name = fallbackSource.fileName;
|
|
1000
1008
|
const response = await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
1001
|
-
if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1009
|
+
if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1002
1010
|
return response;
|
|
1003
1011
|
}
|
|
1004
1012
|
/**
|
|
@@ -1100,9 +1108,10 @@ var MediaApi = class extends Http {
|
|
|
1100
1108
|
const concurrency = Math.min(prepare.concurrency || DEFAULT_PART_CONCURRENCY, MAX_PART_CONCURRENCY, prepare.parts.length);
|
|
1101
1109
|
const handle = source.kind === "local" ? await open(source.file, "r") : void 0;
|
|
1102
1110
|
try {
|
|
1103
|
-
|
|
1111
|
+
const tasks = prepare.parts.map((part) => async () => {
|
|
1104
1112
|
const offset = (part.index - 1) * prepare.block_size;
|
|
1105
|
-
const
|
|
1113
|
+
const length = Math.min(prepare.block_size, source.size - offset);
|
|
1114
|
+
const data = await readPart(source, handle, offset, length);
|
|
1106
1115
|
const md5 = crypto.createHash("md5").update(data).digest("hex");
|
|
1107
1116
|
await putPart(part.presigned_url, data, part.index, prepare.parts.length);
|
|
1108
1117
|
await this.finishUploadPart(scene, peer, {
|
|
@@ -1111,7 +1120,8 @@ var MediaApi = class extends Http {
|
|
|
1111
1120
|
block_size: data.length,
|
|
1112
1121
|
md5
|
|
1113
1122
|
}, prepare.retry_timeout);
|
|
1114
|
-
})
|
|
1123
|
+
});
|
|
1124
|
+
await runWithConcurrency(tasks, concurrency);
|
|
1115
1125
|
} finally {
|
|
1116
1126
|
await handle?.close().catch(() => void 0);
|
|
1117
1127
|
}
|
|
@@ -1159,6 +1169,54 @@ var MetaApi = class extends Http {
|
|
|
1159
1169
|
}
|
|
1160
1170
|
};
|
|
1161
1171
|
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region src/core/api/groups.ts
|
|
1174
|
+
/**
|
|
1175
|
+
* QQ 群信息接口
|
|
1176
|
+
*
|
|
1177
|
+
* 注意:以下接口均为白名单接口,未开通权限时平台返回错误码 11253,
|
|
1178
|
+
* 需要向平台运营申请后可用。
|
|
1179
|
+
*/
|
|
1180
|
+
var GroupsApi = class extends Http {
|
|
1181
|
+
/**
|
|
1182
|
+
* 获取群基础信息
|
|
1183
|
+
* @param groupOpenid 群 openid
|
|
1184
|
+
*/
|
|
1185
|
+
getGroupInfo(groupOpenid) {
|
|
1186
|
+
return this.get(`/v2/groups/${groupOpenid}/info`);
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* 获取机器人在群内的状态(角色、入群时间、主动消息开关等)
|
|
1190
|
+
* @param groupOpenid 群 openid
|
|
1191
|
+
*/
|
|
1192
|
+
getBotState(groupOpenid) {
|
|
1193
|
+
return this.get(`/v2/groups/${groupOpenid}/bot_state`);
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* 获取群成员详情
|
|
1197
|
+
*
|
|
1198
|
+
* @param groupOpenid 群 openid
|
|
1199
|
+
* @param memberOpenid 群成员 openid
|
|
1200
|
+
*/
|
|
1201
|
+
getGroupMember(groupOpenid, memberOpenid) {
|
|
1202
|
+
return this.get(`/v2/groups/${groupOpenid}/members/${memberOpenid}`);
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* 设置群成员禁言
|
|
1206
|
+
* @param groupOpenid 群 openid
|
|
1207
|
+
* @param memberOpenid 群成员 openid
|
|
1208
|
+
* @param op 操作: add 增加禁言,update 更新禁言到期时间,del 解除禁言
|
|
1209
|
+
* @param duration 禁言时间使用RFC3339 格式
|
|
1210
|
+
*/
|
|
1211
|
+
setGroupMetu(groupOpenid, memberOpenid, op, duration) {
|
|
1212
|
+
return this.post(`/v2/groups/${groupOpenid}/restrict_chat_setting`, { members: [{
|
|
1213
|
+
op,
|
|
1214
|
+
member_openid: memberOpenid,
|
|
1215
|
+
mute_expire_at: duration
|
|
1216
|
+
}] });
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1162
1220
|
//#endregion
|
|
1163
1221
|
//#region src/core/api/builders.ts
|
|
1164
1222
|
/**
|
|
@@ -1240,6 +1298,7 @@ const buildGuildMsg = {
|
|
|
1240
1298
|
* - media:富媒体上传
|
|
1241
1299
|
* - interaction:按钮回调 ack
|
|
1242
1300
|
* - meta:@me / dms / gateway
|
|
1301
|
+
* - groups:群基础信息 / 机器人群内状态(白名单接口)
|
|
1243
1302
|
*
|
|
1244
1303
|
* 请求体构造器 {@link buildQQMsg} / {@link buildGuildMsg} 独立导出,避免与 HTTP 调用耦合
|
|
1245
1304
|
*/
|
|
@@ -1249,6 +1308,7 @@ var QQBotApi = class {
|
|
|
1249
1308
|
media;
|
|
1250
1309
|
interaction;
|
|
1251
1310
|
meta;
|
|
1311
|
+
groups;
|
|
1252
1312
|
/** 请求体构造器(静态便利) */
|
|
1253
1313
|
qq = buildQQMsg;
|
|
1254
1314
|
guild = buildGuildMsg;
|
|
@@ -1258,6 +1318,7 @@ var QQBotApi = class {
|
|
|
1258
1318
|
this.media = new MediaApi(axios);
|
|
1259
1319
|
this.interaction = new InteractionApi(axios);
|
|
1260
1320
|
this.meta = new MetaApi(axios);
|
|
1321
|
+
this.groups = new GroupsApi(axios);
|
|
1261
1322
|
}
|
|
1262
1323
|
};
|
|
1263
1324
|
|
|
@@ -1279,11 +1340,11 @@ const tokens = /* @__PURE__ */ new Map();
|
|
|
1279
1340
|
* - 提前 50s 刷新,防止边界过期
|
|
1280
1341
|
* - 同一 appId 旧定时器会被清理,避免并发刷新链
|
|
1281
1342
|
*/
|
|
1282
|
-
const getAccessToken = async (
|
|
1283
|
-
const tokenUrl = normalizeHttpUrl(url);
|
|
1343
|
+
const getAccessToken = async (appId, secret) => {
|
|
1284
1344
|
let res;
|
|
1285
1345
|
try {
|
|
1286
|
-
|
|
1346
|
+
const tokenurl = path.join(QQAPIBASEURL, "/app/getAppAccessToken");
|
|
1347
|
+
res = await axios.post(tokenurl, {
|
|
1287
1348
|
appId: String(appId),
|
|
1288
1349
|
clientSecret: secret
|
|
1289
1350
|
}, { headers: { "User-Agent": getUserAgent() } });
|
|
@@ -1305,7 +1366,7 @@ const getAccessToken = async (url, appId, secret) => {
|
|
|
1305
1366
|
tokens.set(appId, state);
|
|
1306
1367
|
const delay = Math.max(5e3, expiresIn * 1e3 - 5e4);
|
|
1307
1368
|
state.refreshTimer = setTimeout(() => {
|
|
1308
|
-
getAccessToken(
|
|
1369
|
+
getAccessToken(appId, secret).catch(() => {});
|
|
1309
1370
|
}, delay);
|
|
1310
1371
|
return {
|
|
1311
1372
|
accessToken,
|
|
@@ -1330,9 +1391,9 @@ const stopTokenRefresh = (appId) => {
|
|
|
1330
1391
|
/**
|
|
1331
1392
|
* 创建 axios 实例,自动从 tokens map 取最新 access_token
|
|
1332
1393
|
*/
|
|
1333
|
-
const createAxiosInstance = (
|
|
1394
|
+
const createAxiosInstance = (appId) => {
|
|
1334
1395
|
const instance = axios.create({
|
|
1335
|
-
baseURL:
|
|
1396
|
+
baseURL: QQAPIBASEURL,
|
|
1336
1397
|
headers: {
|
|
1337
1398
|
"Content-Type": "application/json",
|
|
1338
1399
|
"User-Agent": getUserAgent()
|
|
@@ -1522,7 +1583,7 @@ const groupElements = (scene, elements) => {
|
|
|
1522
1583
|
//#endregion
|
|
1523
1584
|
//#region src/core/adapter/media-source.ts
|
|
1524
1585
|
/** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
|
|
1525
|
-
const HTTP_URL_RE = /^https?:\/\//i;
|
|
1586
|
+
const HTTP_URL_RE$1 = /^https?:\/\//i;
|
|
1526
1587
|
/** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
|
|
1527
1588
|
const DEFAULT_FILENAME = {
|
|
1528
1589
|
image: "image.jpg",
|
|
@@ -1540,7 +1601,7 @@ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
|
|
|
1540
1601
|
* @param source Karin 消息段里的 file 字段。
|
|
1541
1602
|
* @returns 是否为 HTTP(S) URL。
|
|
1542
1603
|
*/
|
|
1543
|
-
const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
|
|
1604
|
+
const isHttpMediaSource = (source) => HTTP_URL_RE$1.test(source);
|
|
1544
1605
|
/**
|
|
1545
1606
|
* 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
|
|
1546
1607
|
*
|
|
@@ -1576,189 +1637,10 @@ const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
|
|
|
1576
1637
|
}
|
|
1577
1638
|
};
|
|
1578
1639
|
|
|
1579
|
-
//#endregion
|
|
1580
|
-
//#region src/core/adapter/message-id-map.ts
|
|
1581
|
-
/** QQ 消息 ID 与 REFIDX 映射的短期缓存时长,和本地消息缓存窗口保持一致。 */
|
|
1582
|
-
const MESSAGE_ID_LINK_CACHE_TTL = 1440 * 60 * 1e3;
|
|
1583
|
-
/** QQ 消息 ID 映射表清理间隔。 */
|
|
1584
|
-
const MESSAGE_ID_LINK_CLEANUP_INTERVAL = 60 * 1e3;
|
|
1585
|
-
/** 按适配器实例隔离的消息 ID 映射状态。 */
|
|
1586
|
-
const states = /* @__PURE__ */ new WeakMap();
|
|
1587
|
-
/**
|
|
1588
|
-
* 获取适配器实例对应的消息 ID 映射状态。
|
|
1589
|
-
*
|
|
1590
|
-
* @param ctx 当前 QQBot 适配器实例。
|
|
1591
|
-
* @returns 该实例的映射状态。
|
|
1592
|
-
*/
|
|
1593
|
-
const getState = (ctx) => {
|
|
1594
|
-
const existing = states.get(ctx);
|
|
1595
|
-
if (existing) return existing;
|
|
1596
|
-
const created = {
|
|
1597
|
-
apiMessageIdCache: /* @__PURE__ */ new Map(),
|
|
1598
|
-
referenceMessageIdCache: /* @__PURE__ */ new Map(),
|
|
1599
|
-
ownMessageIdCache: /* @__PURE__ */ new Map(),
|
|
1600
|
-
lastCleanup: 0
|
|
1601
|
-
};
|
|
1602
|
-
states.set(ctx, created);
|
|
1603
|
-
return created;
|
|
1604
|
-
};
|
|
1605
|
-
/**
|
|
1606
|
-
* 构造 REFIDX/API ID 映射的会话级 key。
|
|
1607
|
-
*
|
|
1608
|
-
* @param contact 消息所在会话。
|
|
1609
|
-
* @param messageId 消息 ID。
|
|
1610
|
-
* @returns 可用于 Map 的稳定 key。
|
|
1611
|
-
*/
|
|
1612
|
-
const messageIdKey = (contact, messageId) => {
|
|
1613
|
-
return [
|
|
1614
|
-
contact.scene,
|
|
1615
|
-
contact.peer,
|
|
1616
|
-
contact.subPeer || "",
|
|
1617
|
-
messageId
|
|
1618
|
-
].join("");
|
|
1619
|
-
};
|
|
1620
|
-
/**
|
|
1621
|
-
* 清理过期的消息 ID 映射。
|
|
1622
|
-
*
|
|
1623
|
-
* @param ctx 当前 QQBot 适配器实例。
|
|
1624
|
-
*/
|
|
1625
|
-
const cleanup = (ctx) => {
|
|
1626
|
-
const state = getState(ctx);
|
|
1627
|
-
const now = Date.now();
|
|
1628
|
-
if (now - state.lastCleanup < MESSAGE_ID_LINK_CLEANUP_INTERVAL) return;
|
|
1629
|
-
state.lastCleanup = now;
|
|
1630
|
-
for (const [key, entry] of state.apiMessageIdCache) if (entry.expiresAt <= now) state.apiMessageIdCache.delete(key);
|
|
1631
|
-
for (const [key, entry] of state.referenceMessageIdCache) if (entry.expiresAt <= now) state.referenceMessageIdCache.delete(key);
|
|
1632
|
-
for (const [key, entry] of state.ownMessageIdCache) if (entry.expiresAt <= now) state.ownMessageIdCache.delete(key);
|
|
1633
|
-
};
|
|
1634
|
-
/**
|
|
1635
|
-
* 记住机器人自己发送的 QQ API 消息 ID。
|
|
1636
|
-
*
|
|
1637
|
-
* 单聊场景只允许撤回机器人自己发送的消息。收到用户消息时的 `e.messageId`
|
|
1638
|
-
* 也是 API 消息 ID,但不能用于单聊撤回,因此需要独立标记来源。
|
|
1639
|
-
*
|
|
1640
|
-
* @param ctx 当前 QQBot 适配器实例。
|
|
1641
|
-
* @param contact 消息所在会话。
|
|
1642
|
-
* @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
|
|
1643
|
-
*/
|
|
1644
|
-
const rememberOwnMessageId = (ctx, contact, apiMessageId) => {
|
|
1645
|
-
if (!apiMessageId) return;
|
|
1646
|
-
cleanup(ctx);
|
|
1647
|
-
getState(ctx).ownMessageIdCache.set(messageIdKey(contact, apiMessageId), { expiresAt: Date.now() + MESSAGE_ID_LINK_CACHE_TTL });
|
|
1648
|
-
};
|
|
1649
|
-
/**
|
|
1650
|
-
* 判断给定 QQ API 消息 ID 是否由机器人自己发送。
|
|
1651
|
-
*
|
|
1652
|
-
* @param ctx 当前 QQBot 适配器实例。
|
|
1653
|
-
* @param contact 消息所在会话。
|
|
1654
|
-
* @param apiMessageId QQ API 消息 ID。
|
|
1655
|
-
* @returns true 表示这条消息是当前适配器实例发送的。
|
|
1656
|
-
*/
|
|
1657
|
-
const isOwnMessageId = (ctx, contact, apiMessageId) => {
|
|
1658
|
-
cleanup(ctx);
|
|
1659
|
-
const state = getState(ctx);
|
|
1660
|
-
const key = messageIdKey(contact, apiMessageId);
|
|
1661
|
-
const cached = state.ownMessageIdCache.get(key);
|
|
1662
|
-
if (!cached) return false;
|
|
1663
|
-
if (cached.expiresAt > Date.now()) return true;
|
|
1664
|
-
state.ownMessageIdCache.delete(key);
|
|
1665
|
-
return false;
|
|
1666
|
-
};
|
|
1667
|
-
/**
|
|
1668
|
-
* 记住 QQ 引用索引对应的 API 消息 ID。
|
|
1669
|
-
*
|
|
1670
|
-
* QQ 群聊/单聊的 `message_reference.message_id` 使用 REFIDX,但撤回等 API 路径使用
|
|
1671
|
-
* ROBOT1.0 开头的消息 ID。映射按适配器实例和会话隔离,避免不同群里的 REFIDX 冲突。
|
|
1672
|
-
*
|
|
1673
|
-
* @param ctx 当前 QQBot 适配器实例。
|
|
1674
|
-
* @param contact 消息所在会话。
|
|
1675
|
-
* @param referenceMessageId QQ 引用索引,通常为 REFIDX_...。
|
|
1676
|
-
* @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
|
|
1677
|
-
*/
|
|
1678
|
-
const rememberApiMessageId = (ctx, contact, referenceMessageId, apiMessageId) => {
|
|
1679
|
-
if (!referenceMessageId || !apiMessageId) return;
|
|
1680
|
-
if (referenceMessageId === apiMessageId) return;
|
|
1681
|
-
cleanup(ctx);
|
|
1682
|
-
const state = getState(ctx);
|
|
1683
|
-
const expiresAt = Date.now() + MESSAGE_ID_LINK_CACHE_TTL;
|
|
1684
|
-
state.apiMessageIdCache.set(messageIdKey(contact, referenceMessageId), {
|
|
1685
|
-
apiMessageId,
|
|
1686
|
-
expiresAt
|
|
1687
|
-
});
|
|
1688
|
-
state.referenceMessageIdCache.set(messageIdKey(contact, apiMessageId), {
|
|
1689
|
-
referenceMessageId,
|
|
1690
|
-
expiresAt
|
|
1691
|
-
});
|
|
1692
|
-
};
|
|
1693
|
-
/**
|
|
1694
|
-
* 将 QQ API 消息 ID 解析为客户端可见引用使用的 REFIDX。
|
|
1695
|
-
*
|
|
1696
|
-
* @param ctx 当前 QQBot 适配器实例。
|
|
1697
|
-
* @param contact 消息所在会话。
|
|
1698
|
-
* @param messageId Karin 侧传入的消息 ID,可能是 ROBOT1.0 或 REFIDX。
|
|
1699
|
-
* @returns 可用于 `message_reference.message_id` 的 ID;没有映射时返回原值。
|
|
1700
|
-
*/
|
|
1701
|
-
const resolveReferenceMessageId = (ctx, contact, messageId) => {
|
|
1702
|
-
cleanup(ctx);
|
|
1703
|
-
const state = getState(ctx);
|
|
1704
|
-
const key = messageIdKey(contact, messageId);
|
|
1705
|
-
const cached = state.referenceMessageIdCache.get(key);
|
|
1706
|
-
if (!cached) return messageId;
|
|
1707
|
-
if (cached.expiresAt > Date.now()) return cached.referenceMessageId;
|
|
1708
|
-
state.referenceMessageIdCache.delete(key);
|
|
1709
|
-
return messageId;
|
|
1710
|
-
};
|
|
1711
|
-
/**
|
|
1712
|
-
* 将 Karin 侧传入的消息 ID 解析为 QQ API 可用的消息 ID。
|
|
1713
|
-
*
|
|
1714
|
-
* @param ctx 当前 QQBot 适配器实例。
|
|
1715
|
-
* @param contact 消息所在会话。
|
|
1716
|
-
* @param messageId Karin 侧传入的消息 ID,可能是 REFIDX。
|
|
1717
|
-
* @returns QQ API 路径可用的消息 ID;没有映射时返回原值。
|
|
1718
|
-
*/
|
|
1719
|
-
const resolveApiMessageId = (ctx, contact, messageId) => {
|
|
1720
|
-
cleanup(ctx);
|
|
1721
|
-
const state = getState(ctx);
|
|
1722
|
-
const key = messageIdKey(contact, messageId);
|
|
1723
|
-
const cached = state.apiMessageIdCache.get(key);
|
|
1724
|
-
if (!cached) return messageId;
|
|
1725
|
-
if (cached.expiresAt > Date.now()) return cached.apiMessageId;
|
|
1726
|
-
state.apiMessageIdCache.delete(key);
|
|
1727
|
-
return messageId;
|
|
1728
|
-
};
|
|
1729
|
-
|
|
1730
1640
|
//#endregion
|
|
1731
1641
|
//#region src/core/adapter/text-to-md.ts
|
|
1732
|
-
const MAX_BUTTONS = 5;
|
|
1733
1642
|
const MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
1734
1643
|
/**
|
|
1735
|
-
* 提取文本中的 URL,将其替换为 `[按钮 N]` 占位文本并返回 button 列表
|
|
1736
|
-
*
|
|
1737
|
-
* @param text 文本
|
|
1738
|
-
* @param friendly 是否为单聊(true 时清除残余的 at 标签)
|
|
1739
|
-
*/
|
|
1740
|
-
const extractUrlButtons = (text, friendly = false) => {
|
|
1741
|
-
let str = text.replace(/@everyone/g, "everyone").replace(/\n/g, "\r");
|
|
1742
|
-
if (friendly) str = str.replace(/<qqbot-at-user id="[^"]+"\s*\/?>/g, "").replace(/<@!\d+>/g, "");
|
|
1743
|
-
const urls = handleUrl(str);
|
|
1744
|
-
if (urls.length === 0) return {
|
|
1745
|
-
text: str,
|
|
1746
|
-
buttons: []
|
|
1747
|
-
};
|
|
1748
|
-
const buttons = [];
|
|
1749
|
-
urls.forEach((url, i) => {
|
|
1750
|
-
str = str.replace(new RegExp(url, "g"), `[请点击 按钮${i} 查看]`);
|
|
1751
|
-
buttons.push(segment.button({
|
|
1752
|
-
text: `${i}. ${url}`,
|
|
1753
|
-
link: url
|
|
1754
|
-
}));
|
|
1755
|
-
});
|
|
1756
|
-
return {
|
|
1757
|
-
text: str,
|
|
1758
|
-
buttons: buttons.slice(0, MAX_BUTTONS)
|
|
1759
|
-
};
|
|
1760
|
-
};
|
|
1761
|
-
/**
|
|
1762
1644
|
* 将图片列表合并到一段 markdown 文本内(每张图占一行)
|
|
1763
1645
|
*/
|
|
1764
1646
|
const imagesToMarkdown = async (urls) => {
|
|
@@ -1833,11 +1715,7 @@ const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
|
1833
1715
|
const sendQQ = async (ctx, contact, elements) => {
|
|
1834
1716
|
const target = contact.scene === "friend" ? "user" : "group";
|
|
1835
1717
|
const grouping = groupElements(contact.scene, elements);
|
|
1836
|
-
|
|
1837
|
-
const { text, buttons } = extractUrlButtons(grouping.text.join(""), contact.scene === "friend");
|
|
1838
|
-
grouping.text = [text];
|
|
1839
|
-
grouping.buttons.push(...buttons);
|
|
1840
|
-
}
|
|
1718
|
+
await resolveOutgoingReferenceQQ(ctx, contact, grouping);
|
|
1841
1719
|
/**
|
|
1842
1720
|
* 自动 Markdown 通道:
|
|
1843
1721
|
* text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
|
|
@@ -1863,7 +1741,7 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
|
|
|
1863
1741
|
warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
1864
1742
|
warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
|
|
1865
1743
|
}
|
|
1866
|
-
if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(
|
|
1744
|
+
if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
|
|
1867
1745
|
else {
|
|
1868
1746
|
const keyboard = buildKeyboard$1(grouping);
|
|
1869
1747
|
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
|
|
@@ -1986,8 +1864,8 @@ const appendImageUrlLine = async (lines, url) => {
|
|
|
1986
1864
|
* @param grouping 已归类的消息段。
|
|
1987
1865
|
* @returns 是否使用普通文本发送主消息。
|
|
1988
1866
|
*/
|
|
1989
|
-
const shouldUseTextForReference = (
|
|
1990
|
-
return isQQReferenceMessageId(
|
|
1867
|
+
const shouldUseTextForReference = (grouping) => {
|
|
1868
|
+
return isQQReferenceMessageId(grouping.reply.messageId) && !grouping.qqImages.length && !grouping.buttons.length && !grouping.keyboards.length && !grouping.markdowns.length;
|
|
1991
1869
|
};
|
|
1992
1870
|
/**
|
|
1993
1871
|
* 判断给定 ID 是否是 QQ 客户端引用使用的 REFIDX。
|
|
@@ -2000,6 +1878,23 @@ const shouldUseTextForReference = (ctx, contact, grouping) => {
|
|
|
2000
1878
|
*/
|
|
2001
1879
|
const isQQReferenceMessageId = (messageId) => messageId.startsWith("REFIDX_");
|
|
2002
1880
|
/**
|
|
1881
|
+
* 发送前把显式引用的 API 消息 ID 解析为 REFIDX。
|
|
1882
|
+
*
|
|
1883
|
+
* 映射只存在于 SQLite 消息缓存(ID 映射行不受缓存开关影响,始终落库)。
|
|
1884
|
+
* 解析结果直接写回 grouping,后续同步路径(降级判断、message_reference
|
|
1885
|
+
* 附加)无需再查库。
|
|
1886
|
+
*
|
|
1887
|
+
* @param ctx 适配器实例。
|
|
1888
|
+
* @param contact 消息目标会话。
|
|
1889
|
+
* @param grouping 已归类的消息段。
|
|
1890
|
+
*/
|
|
1891
|
+
const resolveOutgoingReferenceQQ = async (ctx, contact, grouping) => {
|
|
1892
|
+
const original = grouping.reply.messageId;
|
|
1893
|
+
if (!original || isQQReferenceMessageId(original)) return;
|
|
1894
|
+
const fromStore = await ctx.messageStore.resolveRefIdx(String(ctx.cfg.appId), contact, original).catch(() => null);
|
|
1895
|
+
if (fromStore) grouping.reply.messageId = fromStore;
|
|
1896
|
+
};
|
|
1897
|
+
/**
|
|
2003
1898
|
* 构造 keyboard 字段(buttons + keyboards 合并)
|
|
2004
1899
|
*/
|
|
2005
1900
|
const buildKeyboard$1 = (grouping) => {
|
|
@@ -2053,10 +1948,8 @@ const flushQQ = async (ctx, contact, grouping, items) => {
|
|
|
2053
1948
|
let referenceHandled = false;
|
|
2054
1949
|
for (const item of items.slice(0, maxItems)) {
|
|
2055
1950
|
passive(item);
|
|
2056
|
-
if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(
|
|
1951
|
+
if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(item, grouping);
|
|
2057
1952
|
const res = await sendQQWithEventFallback(ctx, contact, send, item);
|
|
2058
|
-
rememberOwnMessageId(ctx, contact, res.id);
|
|
2059
|
-
if (res.ext_info?.ref_idx) rememberApiMessageId(ctx, contact, res.ext_info.ref_idx, res.id);
|
|
2060
1953
|
result.rawData.push(res);
|
|
2061
1954
|
}
|
|
2062
1955
|
return ctx.handleResponse(result);
|
|
@@ -2115,14 +2008,14 @@ const resolvePassiveQQ = (grouping) => {
|
|
|
2115
2008
|
* 附加 QQ 客户端可见的引用对象。
|
|
2116
2009
|
*
|
|
2117
2010
|
* 群聊/单聊里真正发给 QQ 的值必须是 `message_scene.ext` 的 `msg_idx=REFIDX_xxx`。
|
|
2118
|
-
*
|
|
2011
|
+
* API 消息 ID 到 REFIDX 的转换已由 `resolveOutgoingReferenceQQ` 在发送前完成。
|
|
2119
2012
|
*
|
|
2120
2013
|
* @param item 即将发送的 QQ 消息体。
|
|
2121
2014
|
* @param grouping 已归类的消息段。
|
|
2122
2015
|
* @returns 是否已经消费一次显式引用。
|
|
2123
2016
|
*/
|
|
2124
|
-
const attachVisibleReferenceQQ = (
|
|
2125
|
-
const messageId =
|
|
2017
|
+
const attachVisibleReferenceQQ = (item, grouping) => {
|
|
2018
|
+
const messageId = grouping.reply.messageId;
|
|
2126
2019
|
if (!isQQReferenceMessageId(messageId)) return false;
|
|
2127
2020
|
item.message_reference = { message_id: messageId };
|
|
2128
2021
|
return true;
|
|
@@ -2176,11 +2069,6 @@ const BUTTON_ONLY_MARKDOWN = "";
|
|
|
2176
2069
|
*/
|
|
2177
2070
|
const sendGuild = async (ctx, contact, elements) => {
|
|
2178
2071
|
const grouping = groupElements(contact.scene, elements);
|
|
2179
|
-
if (ctx.cfg.markdown.enable && ctx.cfg.keyboard.enable && grouping.text.length) {
|
|
2180
|
-
const { text, buttons } = extractUrlButtons(grouping.text.join(""), false);
|
|
2181
|
-
grouping.text = [text];
|
|
2182
|
-
grouping.buttons.push(...buttons);
|
|
2183
|
-
}
|
|
2184
2072
|
if (ctx.cfg.markdown.enable) return sendGuildMarkdown(ctx, contact, grouping);
|
|
2185
2073
|
return sendGuildClassic(ctx, contact, grouping);
|
|
2186
2074
|
};
|
|
@@ -2263,7 +2151,7 @@ const buildGuildImageItem = async (source) => {
|
|
|
2263
2151
|
};
|
|
2264
2152
|
const resolveImageFilename = (source) => {
|
|
2265
2153
|
const file = source.split(/[?#]/)[0] || "";
|
|
2266
|
-
const name = path.basename(file);
|
|
2154
|
+
const name = path$1.basename(file);
|
|
2267
2155
|
return name.includes(".") ? name : "image.jpg";
|
|
2268
2156
|
};
|
|
2269
2157
|
const buildKeyboard = (grouping) => {
|
|
@@ -2307,7 +2195,7 @@ const warnUnsupportedCommandEnterMarkdowns = (ctx, scene, markdowns) => {
|
|
|
2307
2195
|
const flushGuild = async (ctx, contact, grouping, items) => {
|
|
2308
2196
|
const result = ctx.initSendMsgResults();
|
|
2309
2197
|
const passive = buildPassiveGuild(grouping);
|
|
2310
|
-
const send = contact.scene === "guild" ? (
|
|
2198
|
+
const send = contact.scene === "guild" ? (_peer, subPeer, item) => ctx.super.messages.sendChannelMsg(subPeer, item) : (peer, _subPeer, item) => ctx.super.messages.sendDmsMsg(peer, item);
|
|
2311
2199
|
for (const item of items) {
|
|
2312
2200
|
passive(item);
|
|
2313
2201
|
const res = await send(contact.peer, contact.subPeer, item);
|
|
@@ -2377,31 +2265,38 @@ const prepareSelfMessageCache = async (ctx, elements) => {
|
|
|
2377
2265
|
};
|
|
2378
2266
|
};
|
|
2379
2267
|
/**
|
|
2380
|
-
*
|
|
2268
|
+
* 发送成功后记录机器人自己的消息。
|
|
2269
|
+
*
|
|
2270
|
+
* 无论缓存开关如何,都会落库最小 ID 映射行(msg_id/ref_idx/is_self)——
|
|
2271
|
+
* 单聊撤回判定和引用解析没有任何内存映射,完全依赖数据库。
|
|
2272
|
+
* 只有 self 缓存开启时才附带消息正文,供 bot.getMsg(messageId) 查询。
|
|
2381
2273
|
*/
|
|
2382
2274
|
const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
|
|
2383
|
-
if (!shouldCacheSelfMessage(ctx)) return;
|
|
2384
|
-
if (!cacheElements.length) return;
|
|
2385
2275
|
const responses = (Array.isArray(result.rawData) ? result.rawData : [result.rawData]).filter((item) => item?.id);
|
|
2386
2276
|
if (!responses.length) return;
|
|
2277
|
+
const withContent = shouldCacheSelfMessage(ctx) && cacheElements.length > 0;
|
|
2278
|
+
const elements = withContent ? cacheElements : [];
|
|
2279
|
+
const level = withContent ? ctx.cfg.messageCache.level : "minimal";
|
|
2387
2280
|
const sender = selfSender(ctx, contact);
|
|
2388
2281
|
const seen = /* @__PURE__ */ new Set();
|
|
2389
2282
|
for (const response of responses) {
|
|
2390
2283
|
const apiMessageId = String(response.id);
|
|
2391
2284
|
const referenceMessageId = typeof response.ext_info?.ref_idx === "string" ? response.ext_info.ref_idx : "";
|
|
2392
2285
|
const messageId = apiMessageId;
|
|
2393
|
-
const aliases = [referenceMessageId].filter((id) => id && id !== messageId);
|
|
2394
2286
|
if (!messageId || seen.has(messageId)) continue;
|
|
2395
2287
|
seen.add(messageId);
|
|
2396
|
-
if (referenceMessageId) rememberApiMessageId(ctx, contact, referenceMessageId, apiMessageId);
|
|
2397
2288
|
ctx.messageStore.save(String(ctx.cfg.appId), {
|
|
2398
2289
|
messageId,
|
|
2399
2290
|
messageSeq: 0,
|
|
2400
2291
|
time: responseTime(response.timestamp),
|
|
2401
2292
|
contact,
|
|
2402
2293
|
sender,
|
|
2403
|
-
elements
|
|
2404
|
-
},
|
|
2294
|
+
elements
|
|
2295
|
+
}, {
|
|
2296
|
+
refIdx: referenceMessageId && referenceMessageId !== messageId ? referenceMessageId : void 0,
|
|
2297
|
+
isSelf: true,
|
|
2298
|
+
level
|
|
2299
|
+
}).catch((err) => ctx.logger("warn", `[getMsg] 写入自己消息缓存失败: ${messageId}`, err));
|
|
2405
2300
|
}
|
|
2406
2301
|
};
|
|
2407
2302
|
/**
|
|
@@ -2504,7 +2399,7 @@ const prepareStaticElement = async (ctx, element) => {
|
|
|
2504
2399
|
*/
|
|
2505
2400
|
const selfSender = (ctx, contact) => {
|
|
2506
2401
|
const userId = ctx.selfSubId("id") || ctx.selfId;
|
|
2507
|
-
const name = ctx.selfName ||
|
|
2402
|
+
const name = ctx.selfName || "";
|
|
2508
2403
|
if (contact.scene === "friend" || contact.scene === "direct") return karin$1.friendSender(userId, name);
|
|
2509
2404
|
return karin$1.groupSender(userId, "member", name);
|
|
2510
2405
|
};
|
|
@@ -2523,6 +2418,8 @@ const responseTime = (timestamp) => {
|
|
|
2523
2418
|
//#endregion
|
|
2524
2419
|
//#region src/core/adapter/base.ts
|
|
2525
2420
|
const adapterConfigStore = /* @__PURE__ */ new WeakMap();
|
|
2421
|
+
/** HTTP(S) URL 可直接交给 QQ 平台拉取上传 */
|
|
2422
|
+
const HTTP_URL_RE = /^https?:\/\//i;
|
|
2526
2423
|
/**
|
|
2527
2424
|
* QQ Official Bot 适配器
|
|
2528
2425
|
*
|
|
@@ -2567,18 +2464,17 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2567
2464
|
* 发送消息
|
|
2568
2465
|
*/
|
|
2569
2466
|
async sendMsg(contact, elements, _retryCount) {
|
|
2467
|
+
/** 发送成功后始终记录消息 ID 映射;正文是否入库由 cacheSelfMessage 内部按开关决定。 */
|
|
2570
2468
|
if (contact.scene === "direct" || contact.scene === "guild") {
|
|
2571
|
-
|
|
2572
|
-
const
|
|
2573
|
-
|
|
2574
|
-
cacheSelfMessage(this, contact, prepared.cacheElements, result);
|
|
2469
|
+
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, elements) : void 0;
|
|
2470
|
+
const result = await sendGuild(this, contact, prepared?.sendElements ?? elements);
|
|
2471
|
+
cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
|
|
2575
2472
|
return result;
|
|
2576
2473
|
}
|
|
2577
2474
|
if (contact.scene === "group" || contact.scene === "friend") {
|
|
2578
|
-
|
|
2579
|
-
const
|
|
2580
|
-
|
|
2581
|
-
cacheSelfMessage(this, contact, prepared.cacheElements, result);
|
|
2475
|
+
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, elements) : void 0;
|
|
2476
|
+
const result = await sendQQ(this, contact, prepared?.sendElements ?? elements);
|
|
2477
|
+
cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
|
|
2582
2478
|
return result;
|
|
2583
2479
|
}
|
|
2584
2480
|
throw new Error(`不支持的消息场景: ${contact.scene}`);
|
|
@@ -2587,12 +2483,18 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2587
2483
|
* 撤回消息
|
|
2588
2484
|
*
|
|
2589
2485
|
* 群聊中机器人被群主设为管理员后,也可撤回成员消息;平台仍限制消息发送后两分钟内。
|
|
2486
|
+
* REFIDX 与 API 消息 ID 的映射、单聊"是否自己发送"的判定都直接查询
|
|
2487
|
+
* SQLite 消息缓存(ID 映射行不受缓存开关影响,始终落库)。
|
|
2590
2488
|
*/
|
|
2591
2489
|
async recallMsg(contact, messageId) {
|
|
2592
|
-
|
|
2490
|
+
let apiMessageId = messageId;
|
|
2491
|
+
if (apiMessageId.startsWith("REFIDX_")) {
|
|
2492
|
+
const resolved = await this.messageStore.resolveApiMessageId(String(this.cfg.appId), contact, apiMessageId).catch(() => null);
|
|
2493
|
+
if (resolved) apiMessageId = resolved;
|
|
2494
|
+
}
|
|
2593
2495
|
try {
|
|
2594
2496
|
if (contact.scene === "friend") {
|
|
2595
|
-
if (!
|
|
2497
|
+
if (!await this.messageStore.isSelfMessage(String(this.cfg.appId), contact, apiMessageId).catch(() => false)) {
|
|
2596
2498
|
this.logger("warn", `[recallMsg] QQ 单聊只能撤回机器人自己发送的消息,已跳过撤回: ${messageId}`);
|
|
2597
2499
|
return;
|
|
2598
2500
|
}
|
|
@@ -2829,8 +2731,13 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2829
2731
|
/**
|
|
2830
2732
|
* 禁言群成员
|
|
2831
2733
|
*/
|
|
2832
|
-
async setGroupMute(_groupId, _targetId,
|
|
2833
|
-
|
|
2734
|
+
async setGroupMute(_groupId, _targetId, duration) {
|
|
2735
|
+
let rfc3339 = "";
|
|
2736
|
+
if (duration > 0) {
|
|
2737
|
+
const time = Date.now() + duration * 1e3;
|
|
2738
|
+
rfc3339 = new Date(time).toISOString();
|
|
2739
|
+
}
|
|
2740
|
+
this.super.groups.setGroupMetu(_groupId, _targetId, rfc3339 ? "add" : "del", rfc3339);
|
|
2834
2741
|
}
|
|
2835
2742
|
/**
|
|
2836
2743
|
* 群全员禁言
|
|
@@ -2887,10 +2794,12 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2887
2794
|
}
|
|
2888
2795
|
/**
|
|
2889
2796
|
* 获取群信息
|
|
2797
|
+
*
|
|
2798
|
+
* 走官方 `GET /v2/groups/{group_openid}/info`(白名单接口)。
|
|
2799
|
+
* 未开通权限(11253)或查询失败时降级为空结构并告警。
|
|
2890
2800
|
*/
|
|
2891
2801
|
async getGroupInfo(_groupId, _noCache) {
|
|
2892
|
-
|
|
2893
|
-
return {
|
|
2802
|
+
const empty = {
|
|
2894
2803
|
groupId: _groupId,
|
|
2895
2804
|
groupName: "",
|
|
2896
2805
|
owner: "",
|
|
@@ -2901,6 +2810,19 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2901
2810
|
groupDesc: "",
|
|
2902
2811
|
avatar: ""
|
|
2903
2812
|
};
|
|
2813
|
+
try {
|
|
2814
|
+
const info = await this.super.groups.getGroupInfo(_groupId);
|
|
2815
|
+
return {
|
|
2816
|
+
...empty,
|
|
2817
|
+
groupName: info.group_name || "",
|
|
2818
|
+
memberCount: info.group_member_num ?? 0,
|
|
2819
|
+
groupDesc: info.group_finger_memo || "",
|
|
2820
|
+
avatar: await this.getGroupAvatarUrl(_groupId)
|
|
2821
|
+
};
|
|
2822
|
+
} catch (err) {
|
|
2823
|
+
this.logger("warn", `[getGroupInfo] 获取群信息失败: ${err instanceof Error ? err.message : err}`);
|
|
2824
|
+
return empty;
|
|
2825
|
+
}
|
|
2904
2826
|
}
|
|
2905
2827
|
/**
|
|
2906
2828
|
* 获取群列表
|
|
@@ -2911,13 +2833,43 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2911
2833
|
}
|
|
2912
2834
|
/**
|
|
2913
2835
|
* 获取群成员信息
|
|
2836
|
+
*
|
|
2837
|
+
* - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state`(白名单)。
|
|
2838
|
+
* - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}`,
|
|
2839
|
+
* 该接口未公开文档,来自腾讯官方 openclaw 插件 v2.0.0,返回结构以平台实际为准。
|
|
2840
|
+
* 接口不可用(如白名单未开通返回 11253)时降级为占位结构并告警。
|
|
2914
2841
|
*/
|
|
2915
2842
|
async getGroupMemberInfo(_groupId, _targetId, _refresh) {
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
userId
|
|
2919
|
-
role
|
|
2920
|
-
|
|
2843
|
+
/** 按 karin 接口约定构造返回值,sender 由 getter 即时构建 */
|
|
2844
|
+
const build = (userId, role, nick, joinTime) => ({
|
|
2845
|
+
userId,
|
|
2846
|
+
role,
|
|
2847
|
+
nick,
|
|
2848
|
+
joinTime,
|
|
2849
|
+
get sender() {
|
|
2850
|
+
return senderGroup({
|
|
2851
|
+
userId,
|
|
2852
|
+
nick: nick || "",
|
|
2853
|
+
name: nick || "",
|
|
2854
|
+
role
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
if (_targetId === this.selfId) try {
|
|
2859
|
+
const state = await this.super.groups.getBotState(_groupId);
|
|
2860
|
+
return build(state.member_openid || _targetId, state.member_role || "member", void 0, state.joined_at ? new Date(state.joined_at).getTime() : void 0);
|
|
2861
|
+
} catch (err) {
|
|
2862
|
+
this.logger("warn", `[getGroupMemberInfo] 获取机器人群内状态失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
|
|
2863
|
+
return build(_targetId, "member");
|
|
2864
|
+
}
|
|
2865
|
+
try {
|
|
2866
|
+
const member = await this.super.groups.getGroupMember(_groupId, _targetId);
|
|
2867
|
+
if (member.nick) this.nicknameCache.set(_targetId, member.nick);
|
|
2868
|
+
return build(member.member_openid || _targetId, member.member_role || "member", member.nick, member.joined_at ? new Date(member.joined_at).getTime() : void 0);
|
|
2869
|
+
} catch (err) {
|
|
2870
|
+
this.logger("warn", `[getGroupMemberInfo] 获取群成员详情失败(该接口未公开文档,可能不可用): ${err instanceof Error ? err.message : err}`);
|
|
2871
|
+
return build(_targetId, "member");
|
|
2872
|
+
}
|
|
2921
2873
|
}
|
|
2922
2874
|
/**
|
|
2923
2875
|
* 获取群成员列表
|
|
@@ -2959,9 +2911,19 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2959
2911
|
}
|
|
2960
2912
|
/**
|
|
2961
2913
|
* 上传群文件、私聊文件
|
|
2914
|
+
*
|
|
2915
|
+
* QQ 官方 Bot API 没有群文件系统,上传走 `POST /v2/{users|groups}/{openid}/files`
|
|
2916
|
+
* (file_type=4 + srv_send_msg=true),由服务端直接发送文件消息。
|
|
2917
|
+
* 频道 / 频道私信场景官方无文件接口,暂不支持。
|
|
2962
2918
|
*/
|
|
2963
2919
|
async uploadFile(_contact, _file, _name, _folder) {
|
|
2964
|
-
|
|
2920
|
+
if (_contact.scene !== "friend" && _contact.scene !== "group") {
|
|
2921
|
+
this.logger("warn", "[uploadFile] 频道场景 QQ Official Bot API 暂不支持上传文件");
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
const scene = _contact.scene === "friend" ? "user" : "group";
|
|
2925
|
+
if (HTTP_URL_RE.test(_file)) await this.super.media.upload(scene, _contact.peer, "file", _file, true, _name);
|
|
2926
|
+
else await this.super.media.uploadFallback(scene, _contact.peer, "file", _file, true, _name);
|
|
2965
2927
|
}
|
|
2966
2928
|
/**
|
|
2967
2929
|
* 下载文件到协议端本地
|
|
@@ -2996,10 +2958,18 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2996
2958
|
}
|
|
2997
2959
|
/**
|
|
2998
2960
|
* 上传群文件
|
|
2961
|
+
*
|
|
2962
|
+
* 实现同 {@link uploadFile}:官方无群文件系统,走 files 接口由服务端直发文件消息。
|
|
2999
2963
|
*/
|
|
3000
2964
|
async uploadGroupFile(_groupId, _file, _name) {
|
|
3001
|
-
|
|
3002
|
-
|
|
2965
|
+
try {
|
|
2966
|
+
if (HTTP_URL_RE.test(_file)) await this.super.media.upload("group", _groupId, "file", _file, true, _name);
|
|
2967
|
+
else await this.super.media.uploadFallback("group", _groupId, "file", _file, true, _name);
|
|
2968
|
+
return true;
|
|
2969
|
+
} catch (err) {
|
|
2970
|
+
this.logger("warn", `[uploadGroupFile] 上传群文件失败: ${err instanceof Error ? err.message : err}`);
|
|
2971
|
+
return false;
|
|
2972
|
+
}
|
|
3003
2973
|
}
|
|
3004
2974
|
/**
|
|
3005
2975
|
* 获取文件 url
|
|
@@ -3363,7 +3333,7 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
|
3363
3333
|
* QQ 不会提供引用消息的正式消息 ID;调用方应以 `msg_idx` 作为缓存键。
|
|
3364
3334
|
*/
|
|
3365
3335
|
const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
|
|
3366
|
-
|
|
3336
|
+
const referenceEvent = {
|
|
3367
3337
|
...ev,
|
|
3368
3338
|
d: {
|
|
3369
3339
|
...ev.d,
|
|
@@ -3374,26 +3344,33 @@ const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
|
|
|
3374
3344
|
msg_elements: void 0,
|
|
3375
3345
|
message_type: reference.message_type
|
|
3376
3346
|
}
|
|
3377
|
-
}
|
|
3347
|
+
};
|
|
3348
|
+
return convertToKarin(appid, referenceEvent, selfSubId);
|
|
3378
3349
|
};
|
|
3379
3350
|
|
|
3380
3351
|
//#endregion
|
|
3381
3352
|
//#region src/core/event/message.ts
|
|
3382
3353
|
/**
|
|
3383
|
-
*
|
|
3354
|
+
* 把消息投递 SQLite 后台写入队列。
|
|
3384
3355
|
*
|
|
3385
3356
|
* 不等待数据库落库,避免缓存特性拖慢 Karin 消息事件下发。
|
|
3386
3357
|
*
|
|
3358
|
+
* 关闭消息缓存时仍按 minimal 级落库最小 ID 映射行(msg_id/ref_idx)——
|
|
3359
|
+
* 撤回和引用解析没有任何内存映射,完全依赖数据库。
|
|
3360
|
+
*
|
|
3387
3361
|
* @param client 当前 QQBot 适配器实例。
|
|
3388
3362
|
* @param message 需要缓存的消息快照。
|
|
3389
|
-
* @param
|
|
3363
|
+
* @param refIdx QQ `msg_idx` 引用索引,作为该消息的第二查询索引。
|
|
3390
3364
|
*/
|
|
3391
|
-
const cacheMessage = (client, message,
|
|
3392
|
-
|
|
3365
|
+
const cacheMessage = (client, message, refIdx) => {
|
|
3366
|
+
const { enable, level } = client.cfg.messageCache;
|
|
3393
3367
|
client.messageStore.save(String(client.cfg.appId), {
|
|
3394
3368
|
...message,
|
|
3395
3369
|
messageSeq: 0
|
|
3396
|
-
},
|
|
3370
|
+
}, {
|
|
3371
|
+
refIdx,
|
|
3372
|
+
level: enable ? level : "minimal"
|
|
3373
|
+
}).catch((err) => log("warn", `[getMsg] 写入消息缓存失败: ${message.messageId}`, err));
|
|
3397
3374
|
};
|
|
3398
3375
|
/**
|
|
3399
3376
|
* 写入引用消息上下文缓存。
|
|
@@ -3408,7 +3385,7 @@ const cacheReference = (client, message) => {
|
|
|
3408
3385
|
client.messageStore.saveReferenceIfAbsent(String(client.cfg.appId), {
|
|
3409
3386
|
...message,
|
|
3410
3387
|
messageSeq: 0
|
|
3411
|
-
}).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
|
|
3388
|
+
}, client.cfg.messageCache.level).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
|
|
3412
3389
|
};
|
|
3413
3390
|
/**
|
|
3414
3391
|
* 将 Karin 快速回复自动生成的当前消息引用 ID 转成 QQ 客户端可见引用 ID。
|
|
@@ -3446,7 +3423,6 @@ const onGroupMsg = (client, ev, opts) => {
|
|
|
3446
3423
|
const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
|
|
3447
3424
|
const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
|
|
3448
3425
|
const karinMessageId = ev.d.id;
|
|
3449
|
-
if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
|
|
3450
3426
|
if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
|
|
3451
3427
|
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3452
3428
|
cacheMessage(client, {
|
|
@@ -3455,7 +3431,7 @@ const onGroupMsg = (client, ev, opts) => {
|
|
|
3455
3431
|
contact,
|
|
3456
3432
|
sender,
|
|
3457
3433
|
elements
|
|
3458
|
-
},
|
|
3434
|
+
}, messageIndex && messageIndex !== referenceIndex ? messageIndex : void 0);
|
|
3459
3435
|
const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
|
|
3460
3436
|
if (reference && referenceIndex) cacheReference(client, {
|
|
3461
3437
|
messageId: referenceIndex,
|
|
@@ -3492,7 +3468,6 @@ const onFriendMsg = (client, ev) => {
|
|
|
3492
3468
|
const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
|
|
3493
3469
|
const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
|
|
3494
3470
|
const karinMessageId = ev.d.id;
|
|
3495
|
-
if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
|
|
3496
3471
|
if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
|
|
3497
3472
|
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3498
3473
|
cacheMessage(client, {
|
|
@@ -3501,7 +3476,7 @@ const onFriendMsg = (client, ev) => {
|
|
|
3501
3476
|
contact,
|
|
3502
3477
|
sender,
|
|
3503
3478
|
elements
|
|
3504
|
-
},
|
|
3479
|
+
}, messageIndex && messageIndex !== referenceIndex ? messageIndex : void 0);
|
|
3505
3480
|
const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
|
|
3506
3481
|
if (reference && referenceIndex) cacheReference(client, {
|
|
3507
3482
|
messageId: referenceIndex,
|
|
@@ -3788,6 +3763,50 @@ const onC2CMsgReject = (client, event) => {
|
|
|
3788
3763
|
const userId = event.d.openid;
|
|
3789
3764
|
client.logger("mark", `好友 ${userId} 已拒绝机器人主动发送消息`);
|
|
3790
3765
|
};
|
|
3766
|
+
/** 用户申请加群事件 */
|
|
3767
|
+
const onGroupJoinRequest = (client, event) => {
|
|
3768
|
+
const eventId = event.id;
|
|
3769
|
+
const contact = karin$1.contactGroup(event.d.group_openid);
|
|
3770
|
+
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
3771
|
+
const time = Date.parse(event.d.apply_at);
|
|
3772
|
+
if (event.d.member_openid === client.selfId) createGroupInviteRequest({
|
|
3773
|
+
bot: client,
|
|
3774
|
+
time,
|
|
3775
|
+
eventId,
|
|
3776
|
+
contact,
|
|
3777
|
+
rawEvent: event,
|
|
3778
|
+
subEvent: "groupInvite",
|
|
3779
|
+
sender: karin$1.groupSender(event.d.invited_by, "unknown"),
|
|
3780
|
+
srcReply,
|
|
3781
|
+
content: {
|
|
3782
|
+
inviterId: event.d.invited_by,
|
|
3783
|
+
flag: event.d.join_request_id
|
|
3784
|
+
}
|
|
3785
|
+
});
|
|
3786
|
+
else createGroupApplyRequest({
|
|
3787
|
+
bot: client,
|
|
3788
|
+
time,
|
|
3789
|
+
eventId,
|
|
3790
|
+
contact,
|
|
3791
|
+
rawEvent: event,
|
|
3792
|
+
subEvent: "groupApply",
|
|
3793
|
+
sender: karin$1.groupSender(event.d.member_openid, "unknown"),
|
|
3794
|
+
srcReply,
|
|
3795
|
+
content: {
|
|
3796
|
+
inviterId: event.d.invited_by ?? "",
|
|
3797
|
+
flag: event.d.join_request_id,
|
|
3798
|
+
groupId: event.d.group_openid,
|
|
3799
|
+
applierId: event.d.member_openid,
|
|
3800
|
+
reason: (() => {
|
|
3801
|
+
if (event.d.verify_info) if (event.d.verify_info.method === "admin_review_qa") {
|
|
3802
|
+
const reason = event.d.verify_info.review_qa_list[0];
|
|
3803
|
+
if (reason) return `问题: ${reason.question}\n答案: ${reason.answer}`;
|
|
3804
|
+
} else return event.d.verify_info.verify_message;
|
|
3805
|
+
return "";
|
|
3806
|
+
})()
|
|
3807
|
+
}
|
|
3808
|
+
});
|
|
3809
|
+
};
|
|
3791
3810
|
|
|
3792
3811
|
//#endregion
|
|
3793
3812
|
//#region src/core/event/interaction.ts
|
|
@@ -4022,6 +4041,9 @@ const dispatch$1 = (client, ev) => {
|
|
|
4022
4041
|
return;
|
|
4023
4042
|
case "MESSAGE_AUDIT_PASS": return client.logger("debug", `消息审核通过: audit_id=${ev.d.audit_id}`);
|
|
4024
4043
|
case "MESSAGE_AUDIT_REJECT": return client.logger("debug", `消息审核不通过: audit_id=${ev.d.audit_id}`);
|
|
4044
|
+
case "GROUP_JOIN_REQUEST":
|
|
4045
|
+
onGroupJoinRequest(client, ev);
|
|
4046
|
+
return;
|
|
4025
4047
|
default: client.logger("debug", `[QQ Official Bot] 未处理事件: ${ev.t}`);
|
|
4026
4048
|
}
|
|
4027
4049
|
};
|
|
@@ -4424,34 +4446,17 @@ const QUICK_DISCONNECT_THRESHOLD_MS = 5e3;
|
|
|
4424
4446
|
const QUICK_DISCONNECT_LIMIT = 3;
|
|
4425
4447
|
const QUICK_DISCONNECT_DELAY_MS = 6e4;
|
|
4426
4448
|
/**
|
|
4427
|
-
* 从 proxy.prodApi / proxy.sandboxApi 推导兜底的 WS 地址
|
|
4428
|
-
* 官方实际长期返回的也是 wss://{host}/websocket/,不依赖 /gateway 也能直连
|
|
4429
|
-
*/
|
|
4430
|
-
const buildFallbackWsUrl = (cfg) => {
|
|
4431
|
-
return buildFallbackWsUrlFromApi(cfg.sandbox ? cfg.proxy.sandboxApi : cfg.proxy.prodApi);
|
|
4432
|
-
};
|
|
4433
|
-
/**
|
|
4434
4449
|
* 获取网关地址:优先调 /gateway;失败 / 超时 / 限频 → 直接 fallback 到硬编码
|
|
4435
4450
|
*/
|
|
4436
4451
|
const fetchGateway = async (cfg) => {
|
|
4437
|
-
const apiUrl =
|
|
4438
|
-
const fallback = buildFallbackWsUrl(cfg);
|
|
4439
|
-
const proxyWsValue = cfg.sandbox ? cfg.proxy.sandboxWs : cfg.proxy.prodWs;
|
|
4440
|
-
if (proxyWsValue.trim()) try {
|
|
4441
|
-
const proxyWsUrl = normalizeWsGatewayUrl(proxyWsValue);
|
|
4442
|
-
log("debug", `${cfg.appId}: 使用自定义 WS ${proxyWsUrl}`);
|
|
4443
|
-
return proxyWsUrl;
|
|
4444
|
-
} catch (err) {
|
|
4445
|
-
const reason = err?.message || "unknown";
|
|
4446
|
-
log("warn", `${cfg.appId}: 自定义 WS 地址无效 (${reason}),尝试获取 /gateway`);
|
|
4447
|
-
}
|
|
4452
|
+
const apiUrl = "https://api.bot.qq.com";
|
|
4448
4453
|
const accessToken = getBotAccessToken(cfg.appId);
|
|
4449
4454
|
if (!accessToken) {
|
|
4450
|
-
log("warn", `${cfg.appId}: 无 access_token,使用硬编码 WS
|
|
4451
|
-
return
|
|
4455
|
+
log("warn", `${cfg.appId}: 无 access_token,使用硬编码 WS`);
|
|
4456
|
+
return "wss://api.bot.qq.com/websocket";
|
|
4452
4457
|
}
|
|
4453
4458
|
try {
|
|
4454
|
-
const { data } = await axios.get(
|
|
4459
|
+
const { data } = await axios.get(path$1.join(apiUrl, "gateway"), {
|
|
4455
4460
|
headers: {
|
|
4456
4461
|
Authorization: `QQBot ${accessToken}`,
|
|
4457
4462
|
"User-Agent": getUserAgent()
|
|
@@ -4459,20 +4464,20 @@ const fetchGateway = async (cfg) => {
|
|
|
4459
4464
|
timeout: GATEWAY_FETCH_TIMEOUT_MS
|
|
4460
4465
|
});
|
|
4461
4466
|
if (data?.url) return data.url;
|
|
4462
|
-
log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码 WS
|
|
4463
|
-
return
|
|
4467
|
+
log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码 WS`);
|
|
4468
|
+
return "wss://api.bot.qq.com/websocket";
|
|
4464
4469
|
} catch (err) {
|
|
4465
4470
|
if (axios.isAxiosError(err)) {
|
|
4466
4471
|
const response = err.response;
|
|
4467
4472
|
const status = response?.status ?? 0;
|
|
4468
4473
|
const data = response?.data;
|
|
4469
|
-
const detail = formatOpenAPIError(status, typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0);
|
|
4470
|
-
log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码 WS
|
|
4474
|
+
const detail = formatOpenAPIError(status, typeof data?.err_code === "number" ? data.err_code : typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0);
|
|
4475
|
+
log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码 WS`);
|
|
4471
4476
|
} else {
|
|
4472
4477
|
const reason = err?.message || "unknown";
|
|
4473
|
-
log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码 WS
|
|
4478
|
+
log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码 WS`);
|
|
4474
4479
|
}
|
|
4475
|
-
return
|
|
4480
|
+
return "wss://api.bot.qq.com/websocket";
|
|
4476
4481
|
}
|
|
4477
4482
|
};
|
|
4478
4483
|
/** 重连退避(毫秒) */
|
|
@@ -4602,7 +4607,7 @@ const onClose = (cfg, intents, attempt, event, client) => {
|
|
|
4602
4607
|
if (code === 4004) {
|
|
4603
4608
|
log("warn", `${cfg.appId}: access_token 已失效,刷新 token 后重连...`);
|
|
4604
4609
|
schedule(cfg.appId, 1e3, () => {
|
|
4605
|
-
getAccessToken(cfg.
|
|
4610
|
+
getAccessToken(cfg.appId, cfg.secret).then(() => connect(cfg, intents, 0)).catch(() => {
|
|
4606
4611
|
log("error", `${cfg.appId}: access_token 刷新失败,${RATE_LIMIT_DELAY_MS}ms 后重试连接...`);
|
|
4607
4612
|
schedule(cfg.appId, RATE_LIMIT_DELAY_MS, () => connect(cfg, intents, attempt + 1));
|
|
4608
4613
|
});
|
|
@@ -4683,10 +4688,7 @@ const initState = /* @__PURE__ */ new Map();
|
|
|
4683
4688
|
const hashConfig = (bot) => JSON.stringify({
|
|
4684
4689
|
appId: bot.appId,
|
|
4685
4690
|
secret: bot.secret,
|
|
4686
|
-
proxy: bot.proxy,
|
|
4687
|
-
sandbox: bot.sandbox,
|
|
4688
4691
|
event: bot.event,
|
|
4689
|
-
keyboard: bot.keyboard,
|
|
4690
4692
|
markdown: bot.markdown,
|
|
4691
4693
|
messageCache: bot.messageCache
|
|
4692
4694
|
});
|
|
@@ -4694,10 +4696,7 @@ const hashConfig = (bot) => JSON.stringify({
|
|
|
4694
4696
|
* 创建单个 Bot 实例
|
|
4695
4697
|
*/
|
|
4696
4698
|
const createBot = async (input) => {
|
|
4697
|
-
const bot = {
|
|
4698
|
-
...input,
|
|
4699
|
-
proxy: normalizeProxyConfig(input.proxy)
|
|
4700
|
-
};
|
|
4699
|
+
const bot = { ...input };
|
|
4701
4700
|
const appId = String(bot.appId);
|
|
4702
4701
|
if (!appId) {
|
|
4703
4702
|
logger.warn("[QQ Official Bot] 配置缺少 appId,跳过");
|
|
@@ -4714,14 +4713,13 @@ const createBot = async (input) => {
|
|
|
4714
4713
|
await existing.promise.catch(() => {});
|
|
4715
4714
|
}
|
|
4716
4715
|
const promise = (async () => {
|
|
4717
|
-
unregisterBot("selfId", appId);
|
|
4716
|
+
if (karin.getBot(appId)) unregisterBot("selfId", appId);
|
|
4718
4717
|
stop(appId);
|
|
4719
4718
|
offAll(appId);
|
|
4720
4719
|
stopTokenRefresh(appId);
|
|
4721
4720
|
logger.info(`[QQ Official Bot][${appId}] 获取 access_token...`);
|
|
4722
|
-
await getAccessToken(
|
|
4723
|
-
const
|
|
4724
|
-
const api = new QQBotApi(createAxiosInstance(baseUrl, appId));
|
|
4721
|
+
await getAccessToken(appId, bot.secret);
|
|
4722
|
+
const api = new QQBotApi(createAxiosInstance(appId));
|
|
4725
4723
|
const me = await api.meta.getMe();
|
|
4726
4724
|
const client = new AdapterQQBot(bot, api);
|
|
4727
4725
|
client.account.name = me.username;
|
|
@@ -4735,7 +4733,7 @@ const createBot = async (input) => {
|
|
|
4735
4733
|
bus.on(appId, (ev) => {
|
|
4736
4734
|
dispatch$1(client, ev);
|
|
4737
4735
|
});
|
|
4738
|
-
client.adapter.address =
|
|
4736
|
+
client.adapter.address = QQAPIBASEURL;
|
|
4739
4737
|
client.adapter.secret = "*********";
|
|
4740
4738
|
client.adapter.version = pkg().version;
|
|
4741
4739
|
if (bot.event.type === 2) {
|
|
@@ -4903,23 +4901,7 @@ const createRouting = () => {
|
|
|
4903
4901
|
//#region src/index.ts
|
|
4904
4902
|
logger.info(`${logger.violet(`[插件:${pkg().version}]`)} ${logger.green(basename)} 初始化完成~`);
|
|
4905
4903
|
createRouting();
|
|
4906
|
-
const printOnboardBanner = () => {
|
|
4907
|
-
console.log();
|
|
4908
|
-
console.log(" ==========================================");
|
|
4909
|
-
console.log(" 未检测到 QQBot 配置");
|
|
4910
|
-
console.log(" 请使用任意可用 bot(如 OneBot / Console 适配器)");
|
|
4911
|
-
console.log(" 对机器人发送指令: #QQ登录");
|
|
4912
|
-
console.log(" 扫码授权完成后会自动写入配置并完成初始化");
|
|
4913
|
-
console.log();
|
|
4914
|
-
console.log(" ⚠ 注意:扫码授权会刷新该机器人的 secret,旧 secret 立即失效");
|
|
4915
|
-
console.log(" ==========================================");
|
|
4916
|
-
console.log();
|
|
4917
|
-
};
|
|
4918
4904
|
const bootstrap = async () => {
|
|
4919
|
-
if (needQrOnboard()) {
|
|
4920
|
-
printOnboardBanner();
|
|
4921
|
-
return;
|
|
4922
|
-
}
|
|
4923
4905
|
try {
|
|
4924
4906
|
await initQQBotAdapter();
|
|
4925
4907
|
} catch (err) {
|