@karinjs/adapter-qqbot 2.0.0 → 2.2.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/README.md +112 -255
- package/dist/apps/cleanup.d.ts +5 -0
- package/dist/apps/cleanup.js +22 -0
- package/dist/apps/login.d.ts +1 -3
- package/dist/apps/login.js +65 -16
- package/dist/common-by1GpV0b.js +49 -0
- package/dist/config-B_Gc9JKZ.js +253 -0
- package/dist/index.d.ts +511 -25
- package/dist/index.js +2327 -242
- package/dist/message-Dt4QkDYn.js +1739 -0
- package/dist/{onboard-BX8g8Wik.js → onboard-BXQ1cG8h.js} +10 -7
- package/dist/utils-DIq0lGMt.js +4 -0
- package/dist/web.config.d.ts +21 -19
- package/dist/web.config.js +232 -88
- package/package.json +6 -11
- package/dist/config-H7LrvlNU.js +0 -197
- package/dist/utils-DO4gEkVN.js +0 -3
package/dist/index.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import "./
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
1
|
+
import { c as joinHttpPath, d as normalizeWsGatewayUrl, f as pkg, h as basename, l as normalizeHttpUrl, m as log, n as config, r as getConfig, s as buildFallbackWsUrlFromApi, t as bindHandlers, u as normalizeProxyConfig } from "./config-B_Gc9JKZ.js";
|
|
2
|
+
import { i as random, n as getImageSize, r as handleUrl, t as fakeEvent } from "./common-by1GpV0b.js";
|
|
3
|
+
import "./utils-DIq0lGMt.js";
|
|
4
|
+
import { t as getMessageStore } from "./message-Dt4QkDYn.js";
|
|
5
|
+
import { t as needQrOnboard } from "./onboard-BXQ1cG8h.js";
|
|
6
|
+
import { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, unregisterBot } from "node-karin";
|
|
5
7
|
import { URL } from "url";
|
|
6
8
|
import lodash from "node-karin/lodash";
|
|
9
|
+
import { createReadStream } from "node:fs";
|
|
7
10
|
import FormData from "form-data";
|
|
8
11
|
import axios from "node-karin/axios";
|
|
9
|
-
import
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { open, readFile, stat } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath as fileURLToPath$1 } from "node:url";
|
|
10
16
|
import os from "node:os";
|
|
17
|
+
import EventEmitter from "node:events";
|
|
11
18
|
import { WebSocket } from "node-karin/ws";
|
|
12
19
|
import express from "node-karin/express";
|
|
13
20
|
import nacl from "tweetnacl";
|
|
@@ -364,7 +371,11 @@ const formatError = (path, options, err) => {
|
|
|
364
371
|
const lines = [];
|
|
365
372
|
lines.push("[axios] 请求失败");
|
|
366
373
|
lines.push(`请求路径: ${path}`);
|
|
367
|
-
lines.push(`请求数据: ${lodash.truncate(JSON.stringify(options), { length: 500 })}`);
|
|
374
|
+
lines.push(`请求数据: ${lodash.truncate(JSON.stringify(redactRequestData(options)), { length: 500 })}`);
|
|
375
|
+
if (!response) {
|
|
376
|
+
const reason = [err.code, err.message].filter(Boolean).join(" | ");
|
|
377
|
+
if (reason) lines.push(`请求错误: ${reason}`);
|
|
378
|
+
}
|
|
368
379
|
if (code !== void 0 || status > 0) lines.push(`错误详情: ${formatOpenAPIError(status, code, msg)}`);
|
|
369
380
|
lines.push(`响应数据: ${JSON.stringify(data)}`);
|
|
370
381
|
return new Error(lines.join("\n"));
|
|
@@ -373,6 +384,20 @@ const formatError = (path, options, err) => {
|
|
|
373
384
|
return new Error(typeof err === "string" ? err : JSON.stringify(err));
|
|
374
385
|
};
|
|
375
386
|
/**
|
|
387
|
+
* 请求失败日志脱敏,避免富媒体 file_data/base64 被完整 stringify。
|
|
388
|
+
* @param value 请求数据。
|
|
389
|
+
* @returns 脱敏后的请求数据。
|
|
390
|
+
*/
|
|
391
|
+
const redactRequestData = (value) => {
|
|
392
|
+
if (!value || typeof value !== "object") return value;
|
|
393
|
+
if (Array.isArray(value)) return value.map((item) => redactRequestData(item));
|
|
394
|
+
const source = value;
|
|
395
|
+
const result = {};
|
|
396
|
+
for (const [key, item] of Object.entries(source)) if (key === "file_data" && typeof item === "string") result[key] = `<redacted ${item.length} chars>`;
|
|
397
|
+
else result[key] = redactRequestData(item);
|
|
398
|
+
return result;
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
376
401
|
* Http 基础类,子模块继承此类共享 axios 实例与错误格式化
|
|
377
402
|
*/
|
|
378
403
|
var Http = class {
|
|
@@ -388,17 +413,23 @@ var Http = class {
|
|
|
388
413
|
throw formatError(path, void 0, err);
|
|
389
414
|
}
|
|
390
415
|
}
|
|
391
|
-
async post(path, body = {}, headers) {
|
|
416
|
+
async post(path, body = {}, headers, timeout) {
|
|
392
417
|
try {
|
|
393
|
-
const { data } = await this.axios.post(path, body, {
|
|
418
|
+
const { data } = await this.axios.post(path, body, {
|
|
419
|
+
headers,
|
|
420
|
+
timeout
|
|
421
|
+
});
|
|
394
422
|
return data;
|
|
395
423
|
} catch (err) {
|
|
396
424
|
throw formatError(path, body, err);
|
|
397
425
|
}
|
|
398
426
|
}
|
|
399
|
-
async put(path, body = {}, headers) {
|
|
427
|
+
async put(path, body = {}, headers, timeout) {
|
|
400
428
|
try {
|
|
401
|
-
const { data } = await this.axios.put(path, body, {
|
|
429
|
+
const { data } = await this.axios.put(path, body, {
|
|
430
|
+
headers,
|
|
431
|
+
timeout
|
|
432
|
+
});
|
|
402
433
|
return data;
|
|
403
434
|
} catch (err) {
|
|
404
435
|
throw formatError(path, body, err);
|
|
@@ -452,6 +483,92 @@ var MessagesApi = class extends Http {
|
|
|
452
483
|
}
|
|
453
484
|
};
|
|
454
485
|
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/core/api/upload-cache.ts
|
|
488
|
+
/** 单进程内最多缓存的 file_info 数量,避免长期运行时内存无限增长。 */
|
|
489
|
+
const MAX_CACHE_SIZE = 500;
|
|
490
|
+
/** 比 QQ 返回的 ttl 提前失效,避免临界点使用到刚过期的 file_info。 */
|
|
491
|
+
const TTL_SAFETY_MARGIN_SECONDS = 60;
|
|
492
|
+
/** QQ 返回极短 ttl 时仍保留一个最小可用窗口。 */
|
|
493
|
+
const MIN_EFFECTIVE_TTL_SECONDS = 10;
|
|
494
|
+
const cache = /* @__PURE__ */ new Map();
|
|
495
|
+
/**
|
|
496
|
+
* 构建 file_info 缓存键。
|
|
497
|
+
*
|
|
498
|
+
* 虽然 QQ 文档说同一类会话内 file_info 可复用,但这里仍按 peer 隔离,
|
|
499
|
+
* 以避开未文档化的服务端限制。
|
|
500
|
+
*
|
|
501
|
+
* @param scene 上传场景。
|
|
502
|
+
* @param peer openid 或 group_openid。
|
|
503
|
+
* @param type 富媒体类型。
|
|
504
|
+
* @param contentHash 文件内容 hash。
|
|
505
|
+
* @returns 缓存键。
|
|
506
|
+
*/
|
|
507
|
+
const cacheKey = (scene, peer, type, contentHash) => [
|
|
508
|
+
scene,
|
|
509
|
+
peer,
|
|
510
|
+
type,
|
|
511
|
+
contentHash
|
|
512
|
+
].join("");
|
|
513
|
+
/**
|
|
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
|
+
* 读取可复用的上传结果。
|
|
533
|
+
*
|
|
534
|
+
* @param scene 上传场景。
|
|
535
|
+
* @param peer openid 或 group_openid。
|
|
536
|
+
* @param type 富媒体类型。
|
|
537
|
+
* @param contentHash 文件内容 hash。
|
|
538
|
+
* @returns 未命中或过期时返回 null。
|
|
539
|
+
*/
|
|
540
|
+
const getCachedUpload = (scene, peer, type, contentHash) => {
|
|
541
|
+
const key = cacheKey(scene, peer, type, contentHash);
|
|
542
|
+
const entry = cache.get(key);
|
|
543
|
+
if (!entry) return null;
|
|
544
|
+
if (entry.expiresAt <= Date.now()) {
|
|
545
|
+
cache.delete(key);
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
return { ...entry.response };
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* 写入上传结果缓存。
|
|
552
|
+
*
|
|
553
|
+
* @param scene 上传场景。
|
|
554
|
+
* @param peer openid 或 group_openid。
|
|
555
|
+
* @param type 富媒体类型。
|
|
556
|
+
* @param contentHash 文件内容 hash。
|
|
557
|
+
* @param response 上传接口返回。
|
|
558
|
+
*/
|
|
559
|
+
const setCachedUpload = (scene, peer, type, contentHash, response) => {
|
|
560
|
+
if (!response.file_info) return;
|
|
561
|
+
evictOldest();
|
|
562
|
+
const now = Date.now();
|
|
563
|
+
const ttl = Number(response.ttl);
|
|
564
|
+
const effectiveTtl = ttl > 0 ? Math.max(ttl - TTL_SAFETY_MARGIN_SECONDS, MIN_EFFECTIVE_TTL_SECONDS) * 1e3 : Number.POSITIVE_INFINITY;
|
|
565
|
+
cache.set(cacheKey(scene, peer, type, contentHash), {
|
|
566
|
+
response: { ...response },
|
|
567
|
+
expiresAt: ttl > 0 ? now + effectiveTtl : Number.POSITIVE_INFINITY,
|
|
568
|
+
createdAt: now
|
|
569
|
+
});
|
|
570
|
+
};
|
|
571
|
+
|
|
455
572
|
//#endregion
|
|
456
573
|
//#region src/core/api/media.ts
|
|
457
574
|
const FILE_TYPE = {
|
|
@@ -460,6 +577,360 @@ const FILE_TYPE = {
|
|
|
460
577
|
record: 3,
|
|
461
578
|
file: 4
|
|
462
579
|
};
|
|
580
|
+
/** 各类资源的 QQ 上传上限。 */
|
|
581
|
+
const MAX_UPLOAD_SIZE = {
|
|
582
|
+
image: 30 * 1024 * 1024,
|
|
583
|
+
video: 100 * 1024 * 1024,
|
|
584
|
+
record: 20 * 1024 * 1024,
|
|
585
|
+
file: 100 * 1024 * 1024
|
|
586
|
+
};
|
|
587
|
+
/** 上传错误中展示的资源类型名称。 */
|
|
588
|
+
const MEDIA_TYPE_LABEL = {
|
|
589
|
+
image: "图片",
|
|
590
|
+
video: "视频",
|
|
591
|
+
record: "语音",
|
|
592
|
+
file: "文件"
|
|
593
|
+
};
|
|
594
|
+
/** fallback 上传超过该大小时走 QQ 分片上传。 */
|
|
595
|
+
const LARGE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
|
|
596
|
+
/** QQ upload_prepare 需要的前 10,002,432 字节 MD5。 */
|
|
597
|
+
const MD5_10M_SIZE = 10002432;
|
|
598
|
+
/** 分片上传时没有业务文件名的默认值。 */
|
|
599
|
+
const DEFAULT_FILENAME$1 = {
|
|
600
|
+
image: "image.jpg",
|
|
601
|
+
video: "video.mp4",
|
|
602
|
+
record: "record.mp3",
|
|
603
|
+
file: "file.bin"
|
|
604
|
+
};
|
|
605
|
+
/** 富媒体上传比普通接口慢,尤其本地视频会走 file_data。 */
|
|
606
|
+
const MEDIA_UPLOAD_TIMEOUT = 12e4;
|
|
607
|
+
/** URL 回源下载兜底超时。 */
|
|
608
|
+
const MEDIA_DOWNLOAD_TIMEOUT = 12e4;
|
|
609
|
+
/** 每个分片 PUT 到 QQ 临时地址的超时时间。 */
|
|
610
|
+
const PART_UPLOAD_TIMEOUT = 3e5;
|
|
611
|
+
/** 每个分片 PUT 失败后的重试次数。 */
|
|
612
|
+
const PART_UPLOAD_RETRIES = 2;
|
|
613
|
+
/** upload_part_finish 普通失败重试次数。 */
|
|
614
|
+
const PART_FINISH_RETRIES = 2;
|
|
615
|
+
/** upload_part_finish 普通失败重试基础间隔。 */
|
|
616
|
+
const PART_FINISH_BASE_DELAY = 1e3;
|
|
617
|
+
/** upload_part_finish 命中这些业务码时,需要按服务端给出的窗口持续重试。 */
|
|
618
|
+
const PART_FINISH_RETRYABLE_CODES = /* @__PURE__ */ new Set([40093001]);
|
|
619
|
+
/** 服务端未返回 retry_timeout 时,持续重试的默认时间。 */
|
|
620
|
+
const PART_FINISH_DEFAULT_RETRY_TIMEOUT = 120 * 1e3;
|
|
621
|
+
/** 持续重试窗口上限,避免服务端返回异常值导致任务长时间占用。 */
|
|
622
|
+
const PART_FINISH_MAX_RETRY_TIMEOUT = 600 * 1e3;
|
|
623
|
+
/** 持续重试时的固定间隔。 */
|
|
624
|
+
const PART_FINISH_RETRY_INTERVAL = 1e3;
|
|
625
|
+
/** 分片完成接口失败重试次数。 */
|
|
626
|
+
const COMPLETE_UPLOAD_RETRIES = 2;
|
|
627
|
+
/** 分片完成接口失败重试基础间隔。 */
|
|
628
|
+
const COMPLETE_UPLOAD_BASE_DELAY = 2e3;
|
|
629
|
+
/** upload_prepare 返回该业务码时表示大文件上传额度受限。 */
|
|
630
|
+
const UPLOAD_PREPARE_DAILY_LIMIT_CODE = 40093002;
|
|
631
|
+
/** QQ 没返回建议并发数时的默认并发。 */
|
|
632
|
+
const DEFAULT_PART_CONCURRENCY = 1;
|
|
633
|
+
/** 本地限制的最大分片并发,避免服务端返回过大值。 */
|
|
634
|
+
const MAX_PART_CONCURRENCY = 10;
|
|
635
|
+
/** HTTP(S) URL 可直接交给 QQ 平台拉取。 */
|
|
636
|
+
const HTTP_URL_RE$1 = /^https?:\/\//i;
|
|
637
|
+
/** Karin 常见 base64 图片/媒体前缀。 */
|
|
638
|
+
const BASE64_PREFIX_RE = /^base64:\/\//i;
|
|
639
|
+
/** 标准 data URL 的 base64 前缀。 */
|
|
640
|
+
const DATA_URL_BASE64_RE = /^data:[^;,]+;base64,/i;
|
|
641
|
+
/**
|
|
642
|
+
* 判断字符串是否明显是本地文件路径。
|
|
643
|
+
* 不明显的字符串按裸 base64 兼容处理,避免破坏旧调用方式。
|
|
644
|
+
*/
|
|
645
|
+
const isLocalPathSource = (source) => {
|
|
646
|
+
return source.startsWith("file://") || /^[a-zA-Z]:[\\/]/.test(source) || source.startsWith("/") || source.startsWith("\\") || source.startsWith("./") || source.startsWith("../") || source.startsWith(".\\") || source.startsWith("..\\");
|
|
647
|
+
};
|
|
648
|
+
/**
|
|
649
|
+
* 将 file:// 或普通路径统一为本地文件系统路径。
|
|
650
|
+
* @param source 本地路径或 file:// URL。
|
|
651
|
+
* @returns 可直接传给 fs 的本地路径。
|
|
652
|
+
*/
|
|
653
|
+
const normalizeLocalPath = (source) => {
|
|
654
|
+
return source.startsWith("file://") ? fileURLToPath$1(source) : source;
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* 去掉 Karin/标准 data URL 的 base64 前缀。
|
|
658
|
+
* @param source base64 字符串。
|
|
659
|
+
* @returns QQ 上传接口需要的纯 base64 数据。
|
|
660
|
+
*/
|
|
661
|
+
const stripBase64Prefix = (source) => {
|
|
662
|
+
return source.replace(DATA_URL_BASE64_RE, "").replace(BASE64_PREFIX_RE, "");
|
|
663
|
+
};
|
|
664
|
+
/**
|
|
665
|
+
* 将文件大小格式化为便于阅读的字符串。
|
|
666
|
+
* @param bytes 文件字节数。
|
|
667
|
+
* @returns 格式化后的文件大小。
|
|
668
|
+
*/
|
|
669
|
+
const formatBytes = (bytes) => {
|
|
670
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
671
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
672
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
673
|
+
};
|
|
674
|
+
/**
|
|
675
|
+
* 规范化上传文件名,避免把路径当成文件名交给 QQ。
|
|
676
|
+
* @param type 富媒体类型。
|
|
677
|
+
* @param name 候选文件名。
|
|
678
|
+
* @returns 安全的基础文件名。
|
|
679
|
+
*/
|
|
680
|
+
const normalizeUploadFileName = (type, name) => {
|
|
681
|
+
const value = name?.trim();
|
|
682
|
+
if (!value) return DEFAULT_FILENAME$1[type];
|
|
683
|
+
return path.basename(value) || DEFAULT_FILENAME$1[type];
|
|
684
|
+
};
|
|
685
|
+
/**
|
|
686
|
+
* 检查 fallback 资源大小是否超过 QQ 对应类型上限。
|
|
687
|
+
* @param type 富媒体类型。
|
|
688
|
+
* @param size 文件大小。
|
|
689
|
+
*/
|
|
690
|
+
const assertUploadSize = (type, size) => {
|
|
691
|
+
const limit = MAX_UPLOAD_SIZE[type];
|
|
692
|
+
if (size <= limit) return;
|
|
693
|
+
throw new Error(`${MEDIA_TYPE_LABEL[type]}过大:${formatBytes(size)},QQ 上传上限为 ${formatBytes(limit)}`);
|
|
694
|
+
};
|
|
695
|
+
/**
|
|
696
|
+
* 读取本地媒体文件并转为 QQ 上传接口需要的 base64。
|
|
697
|
+
* @param source 本地路径或 file:// URL。
|
|
698
|
+
* @returns 文件内容的 base64 字符串。
|
|
699
|
+
*/
|
|
700
|
+
const readLocalFileAsBase64 = async (source) => {
|
|
701
|
+
return readFile(normalizeLocalPath(source), "base64");
|
|
702
|
+
};
|
|
703
|
+
/**
|
|
704
|
+
* 下载远程媒体并返回 Buffer。
|
|
705
|
+
* @param url 远程媒体 URL。
|
|
706
|
+
* @returns 文件内容。
|
|
707
|
+
*/
|
|
708
|
+
const downloadRemoteAsBuffer = async (url) => {
|
|
709
|
+
const { data } = await axios.get(url, {
|
|
710
|
+
responseType: "arraybuffer",
|
|
711
|
+
timeout: MEDIA_DOWNLOAD_TIMEOUT,
|
|
712
|
+
maxBodyLength: Infinity,
|
|
713
|
+
maxContentLength: Infinity
|
|
714
|
+
});
|
|
715
|
+
return Buffer.from(data);
|
|
716
|
+
};
|
|
717
|
+
/**
|
|
718
|
+
* 下载远程媒体并转为 QQ 上传接口需要的 base64。
|
|
719
|
+
* @param url 远程媒体 URL。
|
|
720
|
+
* @returns 文件内容的 base64 字符串。
|
|
721
|
+
*/
|
|
722
|
+
const downloadRemoteAsBase64 = async (url) => {
|
|
723
|
+
return (await downloadRemoteAsBuffer(url)).toString("base64");
|
|
724
|
+
};
|
|
725
|
+
/**
|
|
726
|
+
* 将 Karin 的 file 字段归一为 QQ 上传接口支持的 url / file_data。
|
|
727
|
+
* @param source HTTP URL、本地路径、data URL、base64:// 或裸 base64。
|
|
728
|
+
* @returns 可合并进上传请求体的来源字段。
|
|
729
|
+
*/
|
|
730
|
+
const buildUploadSource = async (source) => {
|
|
731
|
+
const value = source.trim();
|
|
732
|
+
if (HTTP_URL_RE$1.test(value)) return { url: value };
|
|
733
|
+
if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
|
|
734
|
+
if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
|
|
735
|
+
return { file_data: value };
|
|
736
|
+
};
|
|
737
|
+
/**
|
|
738
|
+
* 将 fallback 来源归一为可判断大小、可分片读取的形式。
|
|
739
|
+
*
|
|
740
|
+
* @param type 富媒体类型。
|
|
741
|
+
* @param source Karin 消息段里的 file 字段。
|
|
742
|
+
* @param fileName 可选文件名。
|
|
743
|
+
* @returns 可用于普通上传或分片上传的来源。
|
|
744
|
+
*/
|
|
745
|
+
const buildFallbackSource = async (type, source, fileName) => {
|
|
746
|
+
const value = source.trim();
|
|
747
|
+
const normalizedName = normalizeUploadFileName(type, fileName);
|
|
748
|
+
if (HTTP_URL_RE$1.test(value)) {
|
|
749
|
+
const buffer = await downloadRemoteAsBuffer(value);
|
|
750
|
+
return {
|
|
751
|
+
kind: "buffer",
|
|
752
|
+
buffer,
|
|
753
|
+
size: buffer.length,
|
|
754
|
+
fileName: normalizedName
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) {
|
|
758
|
+
const buffer = Buffer.from(stripBase64Prefix(value), "base64");
|
|
759
|
+
return {
|
|
760
|
+
kind: "buffer",
|
|
761
|
+
buffer,
|
|
762
|
+
size: buffer.length,
|
|
763
|
+
fileName: normalizedName
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
if (isLocalPathSource(value)) {
|
|
767
|
+
const file = normalizeLocalPath(value);
|
|
768
|
+
const info = await stat(file);
|
|
769
|
+
const name = normalizeUploadFileName(type, fileName || path.basename(file));
|
|
770
|
+
return {
|
|
771
|
+
kind: "local",
|
|
772
|
+
file,
|
|
773
|
+
size: info.size,
|
|
774
|
+
fileName: name
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
const buffer = Buffer.from(value, "base64");
|
|
778
|
+
return {
|
|
779
|
+
kind: "buffer",
|
|
780
|
+
buffer,
|
|
781
|
+
size: buffer.length,
|
|
782
|
+
fileName: normalizedName
|
|
783
|
+
};
|
|
784
|
+
};
|
|
785
|
+
/**
|
|
786
|
+
* 读取 fallback 来源的小文件内容并转成 base64。
|
|
787
|
+
* @param source fallback 来源。
|
|
788
|
+
* @returns base64 字符串。
|
|
789
|
+
*/
|
|
790
|
+
const readFallbackSourceAsBase64 = async (source) => {
|
|
791
|
+
if (source.kind === "buffer") return source.buffer.toString("base64");
|
|
792
|
+
return readFile(source.file, "base64");
|
|
793
|
+
};
|
|
794
|
+
/**
|
|
795
|
+
* 计算 QQ upload_prepare 所需的 hash。
|
|
796
|
+
*
|
|
797
|
+
* @param source fallback 来源。
|
|
798
|
+
* @returns 完整 MD5、完整 SHA1 与前 10MB MD5。
|
|
799
|
+
*/
|
|
800
|
+
const computeUploadHashes = async (source) => {
|
|
801
|
+
if (source.kind === "buffer") {
|
|
802
|
+
const md5 = crypto.createHash("md5").update(source.buffer).digest("hex");
|
|
803
|
+
return {
|
|
804
|
+
md5,
|
|
805
|
+
sha1: crypto.createHash("sha1").update(source.buffer).digest("hex"),
|
|
806
|
+
md510m: source.size > MD5_10M_SIZE ? crypto.createHash("md5").update(source.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") : md5
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return new Promise((resolve, reject) => {
|
|
810
|
+
const md5 = crypto.createHash("md5");
|
|
811
|
+
const sha1 = crypto.createHash("sha1");
|
|
812
|
+
const md510m = crypto.createHash("md5");
|
|
813
|
+
const needsMd510m = source.size > MD5_10M_SIZE;
|
|
814
|
+
let consumed = 0;
|
|
815
|
+
const stream = createReadStream(source.file);
|
|
816
|
+
stream.on("data", (chunk) => {
|
|
817
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
818
|
+
md5.update(buffer);
|
|
819
|
+
sha1.update(buffer);
|
|
820
|
+
if (needsMd510m) {
|
|
821
|
+
const remaining = MD5_10M_SIZE - consumed;
|
|
822
|
+
if (remaining > 0) md510m.update(remaining >= buffer.length ? buffer : buffer.subarray(0, remaining));
|
|
823
|
+
}
|
|
824
|
+
consumed += buffer.length;
|
|
825
|
+
});
|
|
826
|
+
stream.on("end", () => {
|
|
827
|
+
const md5Hex = md5.digest("hex");
|
|
828
|
+
resolve({
|
|
829
|
+
md5: md5Hex,
|
|
830
|
+
sha1: sha1.digest("hex"),
|
|
831
|
+
md510m: needsMd510m ? md510m.digest("hex") : md5Hex
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
stream.on("error", reject);
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
/**
|
|
838
|
+
* 从 fallback 来源读取指定分片。
|
|
839
|
+
* @param source fallback 来源。
|
|
840
|
+
* @param handle 本地文件句柄,buffer 来源不需要。
|
|
841
|
+
* @param offset 分片起始位置。
|
|
842
|
+
* @param length 分片长度。
|
|
843
|
+
* @returns 分片内容。
|
|
844
|
+
*/
|
|
845
|
+
const readPart = async (source, handle, offset, length) => {
|
|
846
|
+
if (source.kind === "buffer") return source.buffer.subarray(offset, offset + length);
|
|
847
|
+
if (!handle) throw new Error("读取分片失败:文件句柄不存在");
|
|
848
|
+
const buffer = Buffer.alloc(length);
|
|
849
|
+
const { bytesRead } = await handle.read(buffer, 0, length, offset);
|
|
850
|
+
return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
851
|
+
};
|
|
852
|
+
/**
|
|
853
|
+
* 带有限重试地把单个分片 PUT 到 QQ 返回的临时地址。
|
|
854
|
+
*
|
|
855
|
+
* @param url QQ 返回的分片上传地址。
|
|
856
|
+
* @param data 分片内容。
|
|
857
|
+
* @param partIndex 分片序号。
|
|
858
|
+
* @param totalParts 总分片数。
|
|
859
|
+
*/
|
|
860
|
+
const putPart = async (url, data, partIndex, totalParts) => {
|
|
861
|
+
let lastError;
|
|
862
|
+
for (let attempt = 0; attempt <= PART_UPLOAD_RETRIES; attempt++) try {
|
|
863
|
+
await axios.put(url, data, {
|
|
864
|
+
headers: {
|
|
865
|
+
"Content-Type": "application/octet-stream",
|
|
866
|
+
"Content-Length": String(data.length)
|
|
867
|
+
},
|
|
868
|
+
timeout: PART_UPLOAD_TIMEOUT,
|
|
869
|
+
maxBodyLength: Infinity,
|
|
870
|
+
maxContentLength: Infinity
|
|
871
|
+
});
|
|
872
|
+
return;
|
|
873
|
+
} catch (err) {
|
|
874
|
+
lastError = err;
|
|
875
|
+
if (attempt < PART_UPLOAD_RETRIES) await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
|
|
876
|
+
}
|
|
877
|
+
throw new Error(`上传分片失败:${partIndex}/${totalParts},${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
878
|
+
};
|
|
879
|
+
/**
|
|
880
|
+
* 使用固定并发执行异步任务。
|
|
881
|
+
* @param tasks 待执行任务。
|
|
882
|
+
* @param concurrency 最大并发。
|
|
883
|
+
*/
|
|
884
|
+
const runWithConcurrency = async (tasks, concurrency) => {
|
|
885
|
+
let index = 0;
|
|
886
|
+
const workerCount = Math.max(1, Math.min(concurrency, tasks.length));
|
|
887
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
888
|
+
while (index < tasks.length) {
|
|
889
|
+
const current = index;
|
|
890
|
+
index += 1;
|
|
891
|
+
await tasks[current]();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
await Promise.all(workers);
|
|
895
|
+
};
|
|
896
|
+
/**
|
|
897
|
+
* 等待指定时间。
|
|
898
|
+
* @param ms 毫秒数。
|
|
899
|
+
*/
|
|
900
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
901
|
+
/**
|
|
902
|
+
* 从 axios 原始错误或已格式化错误文本里提取 QQ 业务错误码。
|
|
903
|
+
* @param err 待检查的错误。
|
|
904
|
+
* @returns 业务错误码。
|
|
905
|
+
*/
|
|
906
|
+
const openApiCode = (err) => {
|
|
907
|
+
if (axios.isAxiosError(err)) {
|
|
908
|
+
const data = err.response?.data;
|
|
909
|
+
const code = typeof data?.code === "number" ? data.code : typeof data?.err_code === "number" ? data.err_code : void 0;
|
|
910
|
+
if (code !== void 0) return code;
|
|
911
|
+
}
|
|
912
|
+
if (err instanceof Error) {
|
|
913
|
+
const match = err.message.match(/\[Code\s+(\d+)\]/);
|
|
914
|
+
if (match) return Number(match[1]);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
/**
|
|
918
|
+
* 判断 upload_part_finish 是否命中需要持续重试的服务端状态。
|
|
919
|
+
* @param err 请求错误。
|
|
920
|
+
* @returns 是否需要持续重试。
|
|
921
|
+
*/
|
|
922
|
+
const shouldRetryPartFinishPersistently = (err) => {
|
|
923
|
+
const code = openApiCode(err);
|
|
924
|
+
return code !== void 0 && PART_FINISH_RETRYABLE_CODES.has(code);
|
|
925
|
+
};
|
|
926
|
+
/**
|
|
927
|
+
* 将未知错误重新抛出为 Error。
|
|
928
|
+
* @param err 原始错误。
|
|
929
|
+
*/
|
|
930
|
+
const throwError = (err) => {
|
|
931
|
+
if (err instanceof Error) throw err;
|
|
932
|
+
throw new Error(typeof err === "string" ? err : JSON.stringify(err));
|
|
933
|
+
};
|
|
463
934
|
/**
|
|
464
935
|
* 富媒体上传
|
|
465
936
|
*/
|
|
@@ -468,22 +939,190 @@ var MediaApi = class extends Http {
|
|
|
468
939
|
* @param scene user(单聊) / group(群聊)
|
|
469
940
|
* @param peer openid 或 group_openid
|
|
470
941
|
* @param type 文件类型
|
|
471
|
-
* @param source
|
|
942
|
+
* @param source http url、本地路径或 base64 字符串
|
|
472
943
|
* @param srvSendMsg 上传后是否由服务端直接发送
|
|
944
|
+
* @param fileName 文件消息的展示文件名
|
|
473
945
|
*/
|
|
474
|
-
upload(scene, peer, type, source, srvSendMsg = false) {
|
|
946
|
+
async upload(scene, peer, type, source, srvSendMsg = false, fileName) {
|
|
947
|
+
const sourceBody = await buildUploadSource(source);
|
|
475
948
|
const body = {
|
|
476
949
|
file_type: FILE_TYPE[type],
|
|
477
950
|
srv_send_msg: srvSendMsg
|
|
478
951
|
};
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
952
|
+
Object.assign(body, sourceBody);
|
|
953
|
+
if (type === "file" && fileName) body.file_name = fileName;
|
|
954
|
+
const path = `/v2/${scene}s/${peer}/files`;
|
|
955
|
+
try {
|
|
956
|
+
return await this.post(path, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
957
|
+
} catch (err) {
|
|
958
|
+
if (!sourceBody.url) throw err;
|
|
959
|
+
const fallbackBody = {
|
|
960
|
+
...body,
|
|
961
|
+
file_data: await downloadRemoteAsBase64(sourceBody.url)
|
|
962
|
+
};
|
|
963
|
+
delete fallbackBody.url;
|
|
964
|
+
return this.post(path, fallbackBody, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* 上传没有公网 URL 处理器可用时的 fallback 富媒体资源。
|
|
969
|
+
*
|
|
970
|
+
* 较大的 fallback 资源会优先使用 QQ 分片上传,提高直接发送成功率。
|
|
971
|
+
* `srvSendMsg` 为 true 时保留普通上传路径,避免分片完成接口与服务端直接发送语义冲突。
|
|
972
|
+
*
|
|
973
|
+
* @param scene user(单聊) / group(群聊)
|
|
974
|
+
* @param peer openid 或 group_openid
|
|
975
|
+
* @param type 文件类型
|
|
976
|
+
* @param source 本地路径、http url、data URL、base64:// 或裸 base64
|
|
977
|
+
* @param srvSendMsg 上传后是否由服务端直接发送
|
|
978
|
+
* @param fileName 文件消息的展示文件名
|
|
979
|
+
* @returns 上传结果,包含后续发送消息所需的 file_info
|
|
980
|
+
*/
|
|
981
|
+
async uploadFallback(scene, peer, type, source, srvSendMsg = false, fileName) {
|
|
982
|
+
const fallbackSource = await buildFallbackSource(type, source, fileName);
|
|
983
|
+
assertUploadSize(type, fallbackSource.size);
|
|
984
|
+
const hashes = !srvSendMsg ? await computeUploadHashes(fallbackSource) : void 0;
|
|
985
|
+
if (hashes) {
|
|
986
|
+
const cached = getCachedUpload(scene, peer, type, hashes.md5);
|
|
987
|
+
if (cached) return cached;
|
|
988
|
+
}
|
|
989
|
+
if (!srvSendMsg && fallbackSource.size >= LARGE_UPLOAD_THRESHOLD) {
|
|
990
|
+
const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
|
|
991
|
+
if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
992
|
+
return response;
|
|
993
|
+
}
|
|
994
|
+
const body = {
|
|
995
|
+
file_type: FILE_TYPE[type],
|
|
996
|
+
srv_send_msg: srvSendMsg,
|
|
997
|
+
file_data: await readFallbackSourceAsBase64(fallbackSource)
|
|
998
|
+
};
|
|
999
|
+
if (type === "file") body.file_name = fallbackSource.fileName;
|
|
1000
|
+
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);
|
|
1002
|
+
return response;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* 确认单个分片已完成上传。
|
|
1006
|
+
*
|
|
1007
|
+
* QQ 有时会在分片刚 PUT 完时短暂返回 40093001,此时需要在服务端给出的
|
|
1008
|
+
* retry_timeout 窗口内持续重试,而不是直接判定整个大文件上传失败。
|
|
1009
|
+
*
|
|
1010
|
+
* @param scene user(单聊) / group(群聊)
|
|
1011
|
+
* @param peer openid 或 group_openid
|
|
1012
|
+
* @param body upload_part_finish 请求体
|
|
1013
|
+
* @param retryTimeout 服务端返回的持续重试秒数
|
|
1014
|
+
*/
|
|
1015
|
+
async finishUploadPart(scene, peer, body, retryTimeout) {
|
|
1016
|
+
let lastError;
|
|
1017
|
+
for (let attempt = 0; attempt <= PART_FINISH_RETRIES; attempt++) try {
|
|
1018
|
+
await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
1019
|
+
return;
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
if (shouldRetryPartFinishPersistently(err)) {
|
|
1022
|
+
await this.retryUploadPartFinish(scene, peer, body, retryTimeout);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
lastError = err;
|
|
1026
|
+
if (attempt < PART_FINISH_RETRIES) await sleep(PART_FINISH_BASE_DELAY * 2 ** attempt);
|
|
1027
|
+
}
|
|
1028
|
+
throwError(lastError);
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* 持续重试 upload_part_finish 的短暂服务端状态。
|
|
1032
|
+
*
|
|
1033
|
+
* @param scene user(单聊) / group(群聊)
|
|
1034
|
+
* @param peer openid 或 group_openid
|
|
1035
|
+
* @param body upload_part_finish 请求体
|
|
1036
|
+
* @param retryTimeout 服务端返回的持续重试秒数
|
|
1037
|
+
*/
|
|
1038
|
+
async retryUploadPartFinish(scene, peer, body, retryTimeout) {
|
|
1039
|
+
const retryTimeoutMs = retryTimeout ? Math.min(Number(retryTimeout) * 1e3, PART_FINISH_MAX_RETRY_TIMEOUT) : PART_FINISH_DEFAULT_RETRY_TIMEOUT;
|
|
1040
|
+
const deadline = Date.now() + retryTimeoutMs;
|
|
1041
|
+
let attempts = 0;
|
|
1042
|
+
let lastError;
|
|
1043
|
+
while (Date.now() < deadline) try {
|
|
1044
|
+
await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
1045
|
+
return;
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
lastError = err;
|
|
1048
|
+
if (!shouldRetryPartFinishPersistently(err)) throwError(err);
|
|
1049
|
+
attempts += 1;
|
|
1050
|
+
await sleep(Math.min(PART_FINISH_RETRY_INTERVAL, Math.max(0, deadline - Date.now())));
|
|
1051
|
+
}
|
|
1052
|
+
throw new Error(`upload_part_finish 持续重试超时:${Math.round(retryTimeoutMs / 1e3)}s,已重试 ${attempts} 次,最后错误:${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* 完成 QQ 分片上传,并对短暂失败做有限重试。
|
|
1056
|
+
*
|
|
1057
|
+
* @param scene user(单聊) / group(群聊)
|
|
1058
|
+
* @param peer openid 或 group_openid
|
|
1059
|
+
* @param uploadId upload_prepare 返回的上传任务 ID
|
|
1060
|
+
* @returns 上传结果
|
|
1061
|
+
*/
|
|
1062
|
+
async completeChunkedUpload(scene, peer, uploadId) {
|
|
1063
|
+
let lastError;
|
|
1064
|
+
const body = { upload_id: uploadId };
|
|
1065
|
+
for (let attempt = 0; attempt <= COMPLETE_UPLOAD_RETRIES; attempt++) try {
|
|
1066
|
+
return await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
lastError = err;
|
|
1069
|
+
if (attempt < COMPLETE_UPLOAD_RETRIES) await sleep(COMPLETE_UPLOAD_BASE_DELAY * 2 ** attempt);
|
|
1070
|
+
}
|
|
1071
|
+
return throwError(lastError);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* 使用 QQ 官方分片协议上传 fallback 大资源。
|
|
1075
|
+
*
|
|
1076
|
+
* 流程为:准备上传 -> PUT 每个分片 -> 确认每个分片 -> 完成上传。
|
|
1077
|
+
*
|
|
1078
|
+
* @param scene user(单聊) / group(群聊)
|
|
1079
|
+
* @param peer openid 或 group_openid
|
|
1080
|
+
* @param type 文件类型
|
|
1081
|
+
* @param source 已归一化的 fallback 来源
|
|
1082
|
+
* @param preparedHashes 预计算的文件 hash
|
|
1083
|
+
* @returns 上传结果,包含后续发送消息所需的 file_info
|
|
1084
|
+
*/
|
|
1085
|
+
async uploadChunked(scene, peer, type, source, preparedHashes) {
|
|
1086
|
+
const hashes = preparedHashes || await computeUploadHashes(source);
|
|
1087
|
+
const prepareBody = {
|
|
1088
|
+
file_type: FILE_TYPE[type],
|
|
1089
|
+
file_name: source.fileName,
|
|
1090
|
+
file_size: source.size,
|
|
1091
|
+
md5: hashes.md5,
|
|
1092
|
+
sha1: hashes.sha1
|
|
1093
|
+
};
|
|
1094
|
+
prepareBody["md5_10m"] = hashes.md510m;
|
|
1095
|
+
const prepare = await this.post(`/v2/${scene}s/${peer}/upload_prepare`, prepareBody, void 0, MEDIA_UPLOAD_TIMEOUT).catch((err) => {
|
|
1096
|
+
if (openApiCode(err) === UPLOAD_PREPARE_DAILY_LIMIT_CODE) throw new Error(`QQ 大文件上传额度已达上限,${MEDIA_TYPE_LABEL[type]}暂时无法通过 fallback 直传:${source.fileName} (${formatBytes(source.size)})`);
|
|
1097
|
+
return throwError(err);
|
|
1098
|
+
});
|
|
1099
|
+
if (!prepare.parts.length) throw new Error("分片上传准备失败:QQ 未返回分片信息");
|
|
1100
|
+
const concurrency = Math.min(prepare.concurrency || DEFAULT_PART_CONCURRENCY, MAX_PART_CONCURRENCY, prepare.parts.length);
|
|
1101
|
+
const handle = source.kind === "local" ? await open(source.file, "r") : void 0;
|
|
1102
|
+
try {
|
|
1103
|
+
await runWithConcurrency(prepare.parts.map((part) => async () => {
|
|
1104
|
+
const offset = (part.index - 1) * prepare.block_size;
|
|
1105
|
+
const data = await readPart(source, handle, offset, Math.min(prepare.block_size, source.size - offset));
|
|
1106
|
+
const md5 = crypto.createHash("md5").update(data).digest("hex");
|
|
1107
|
+
await putPart(part.presigned_url, data, part.index, prepare.parts.length);
|
|
1108
|
+
await this.finishUploadPart(scene, peer, {
|
|
1109
|
+
upload_id: prepare.upload_id,
|
|
1110
|
+
part_index: part.index,
|
|
1111
|
+
block_size: data.length,
|
|
1112
|
+
md5
|
|
1113
|
+
}, prepare.retry_timeout);
|
|
1114
|
+
}), concurrency);
|
|
1115
|
+
} finally {
|
|
1116
|
+
await handle?.close().catch(() => void 0);
|
|
1117
|
+
}
|
|
1118
|
+
return this.completeChunkedUpload(scene, peer, prepare.upload_id);
|
|
482
1119
|
}
|
|
483
1120
|
};
|
|
484
1121
|
|
|
485
1122
|
//#endregion
|
|
486
1123
|
//#region src/core/api/interaction.ts
|
|
1124
|
+
/** QQ 客户端回调按钮需要快速 ACK,超时必须小于平台等待窗口。 */
|
|
1125
|
+
const INTERACTION_ACK_TIMEOUT = 2800;
|
|
487
1126
|
/**
|
|
488
1127
|
* INTERACTION_CREATE 回应
|
|
489
1128
|
*/
|
|
@@ -493,7 +1132,7 @@ var InteractionApi = class extends Http {
|
|
|
493
1132
|
* @param code 0 成功 / 1 操作失败 / 2 操作频繁 / 3 重复操作 / 4 无权限 / 5 仅管理员
|
|
494
1133
|
*/
|
|
495
1134
|
async ack(interactionId, code = 0) {
|
|
496
|
-
await this.put(`/interactions/${interactionId}`, { code });
|
|
1135
|
+
await this.put(`/interactions/${interactionId}`, { code }, void 0, INTERACTION_ACK_TIMEOUT);
|
|
497
1136
|
}
|
|
498
1137
|
};
|
|
499
1138
|
|
|
@@ -545,12 +1184,11 @@ const buildQQMsg = {
|
|
|
545
1184
|
ark
|
|
546
1185
|
};
|
|
547
1186
|
},
|
|
548
|
-
/** msg_type=7
|
|
1187
|
+
/** 构造 msg_type=7 富媒体消息体 */
|
|
549
1188
|
media(fileInfo) {
|
|
550
1189
|
return {
|
|
551
1190
|
msg_type: 7,
|
|
552
|
-
media: { file_info: fileInfo }
|
|
553
|
-
content: " "
|
|
1191
|
+
media: { file_info: fileInfo }
|
|
554
1192
|
};
|
|
555
1193
|
}
|
|
556
1194
|
};
|
|
@@ -623,6 +1261,16 @@ var QQBotApi = class {
|
|
|
623
1261
|
}
|
|
624
1262
|
};
|
|
625
1263
|
|
|
1264
|
+
//#endregion
|
|
1265
|
+
//#region src/utils/user-agent.ts
|
|
1266
|
+
const USER_AGENT_SUFFIX = "Karin";
|
|
1267
|
+
/**
|
|
1268
|
+
* 构建 QQBot HTTP / WebSocket 使用的 User-Agent。
|
|
1269
|
+
*/
|
|
1270
|
+
const getUserAgent = () => {
|
|
1271
|
+
return `QQBotAdapter/${String(pkg().version || "unknown")} (Node/${process.versions.node}; ${os.platform()}; ${USER_AGENT_SUFFIX}/${process.env.KARIN_VERSION || "unknown"})`;
|
|
1272
|
+
};
|
|
1273
|
+
|
|
626
1274
|
//#endregion
|
|
627
1275
|
//#region src/core/internal/axios.ts
|
|
628
1276
|
const tokens = /* @__PURE__ */ new Map();
|
|
@@ -632,12 +1280,13 @@ const tokens = /* @__PURE__ */ new Map();
|
|
|
632
1280
|
* - 同一 appId 旧定时器会被清理,避免并发刷新链
|
|
633
1281
|
*/
|
|
634
1282
|
const getAccessToken = async (url, appId, secret) => {
|
|
1283
|
+
const tokenUrl = normalizeHttpUrl(url);
|
|
635
1284
|
let res;
|
|
636
1285
|
try {
|
|
637
|
-
res = await axios.post(
|
|
1286
|
+
res = await axios.post(tokenUrl, {
|
|
638
1287
|
appId: String(appId),
|
|
639
1288
|
clientSecret: secret
|
|
640
|
-
});
|
|
1289
|
+
}, { headers: { "User-Agent": getUserAgent() } });
|
|
641
1290
|
} catch (err) {
|
|
642
1291
|
if (axios.isAxiosError(err)) {
|
|
643
1292
|
const response = err.response;
|
|
@@ -656,7 +1305,7 @@ const getAccessToken = async (url, appId, secret) => {
|
|
|
656
1305
|
tokens.set(appId, state);
|
|
657
1306
|
const delay = Math.max(5e3, expiresIn * 1e3 - 5e4);
|
|
658
1307
|
state.refreshTimer = setTimeout(() => {
|
|
659
|
-
getAccessToken(
|
|
1308
|
+
getAccessToken(tokenUrl, appId, secret).catch(() => {});
|
|
660
1309
|
}, delay);
|
|
661
1310
|
return {
|
|
662
1311
|
accessToken,
|
|
@@ -683,8 +1332,11 @@ const stopTokenRefresh = (appId) => {
|
|
|
683
1332
|
*/
|
|
684
1333
|
const createAxiosInstance = (baseURL, appId) => {
|
|
685
1334
|
const instance = axios.create({
|
|
686
|
-
baseURL,
|
|
687
|
-
headers: {
|
|
1335
|
+
baseURL: normalizeHttpUrl(baseURL),
|
|
1336
|
+
headers: {
|
|
1337
|
+
"Content-Type": "application/json",
|
|
1338
|
+
"User-Agent": getUserAgent()
|
|
1339
|
+
},
|
|
688
1340
|
timeout: 5500
|
|
689
1341
|
});
|
|
690
1342
|
instance.interceptors.request.use((config) => {
|
|
@@ -695,9 +1347,92 @@ const createAxiosInstance = (baseURL, appId) => {
|
|
|
695
1347
|
return instance;
|
|
696
1348
|
};
|
|
697
1349
|
|
|
1350
|
+
//#endregion
|
|
1351
|
+
//#region src/core/adapter/button-enter.ts
|
|
1352
|
+
/** QQ 私聊直发文本链标签。 */
|
|
1353
|
+
const COMMAND_ENTER_TEXT_CHAIN_REGEXP = /<\s*qqbot-cmd-enter\b/i;
|
|
1354
|
+
/**
|
|
1355
|
+
* 判断按钮是否是 `enter: true` 指令按钮。
|
|
1356
|
+
*
|
|
1357
|
+
* 链接按钮和回调按钮有自己的平台行为,只有普通指令按钮才需要提示
|
|
1358
|
+
* `enter: true` 在非私聊场景不生效。
|
|
1359
|
+
*
|
|
1360
|
+
* @param button Karin 按钮配置。
|
|
1361
|
+
* @returns 是否为直发指令按钮。
|
|
1362
|
+
*/
|
|
1363
|
+
const isCommandEnterButton = (button) => {
|
|
1364
|
+
if (button.link || button.callback) return false;
|
|
1365
|
+
return (button.type ?? 2) === 2 && button.enter === true;
|
|
1366
|
+
};
|
|
1367
|
+
/**
|
|
1368
|
+
* 判断 markdown 内容是否手写了 QQ 私聊直发文本链。
|
|
1369
|
+
*
|
|
1370
|
+
* @param markdown markdown 内容。
|
|
1371
|
+
* @returns 是否包含 `<qqbot-cmd-enter ...>`。
|
|
1372
|
+
*/
|
|
1373
|
+
const hasCommandEnterTextChain = (markdown) => {
|
|
1374
|
+
return COMMAND_ENTER_TEXT_CHAIN_REGEXP.test(markdown);
|
|
1375
|
+
};
|
|
1376
|
+
/**
|
|
1377
|
+
* 收集 button/keyboard 中所有 `enter: true` 指令按钮。
|
|
1378
|
+
*
|
|
1379
|
+
* @param buttons 单行按钮元素列表。
|
|
1380
|
+
* @param keyboards 多行 keyboard 元素列表。
|
|
1381
|
+
* @returns 需要特殊处理的直发指令按钮。
|
|
1382
|
+
*/
|
|
1383
|
+
const collectCommandEnterButtons = (buttons, keyboards) => {
|
|
1384
|
+
const result = [];
|
|
1385
|
+
buttons.forEach((element) => {
|
|
1386
|
+
element.data.forEach((button) => {
|
|
1387
|
+
if (isCommandEnterButton(button)) result.push(button);
|
|
1388
|
+
});
|
|
1389
|
+
});
|
|
1390
|
+
keyboards.forEach((element) => {
|
|
1391
|
+
element.rows.forEach((row) => {
|
|
1392
|
+
row.forEach((button) => {
|
|
1393
|
+
if (isCommandEnterButton(button)) result.push(button);
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
});
|
|
1397
|
+
return result;
|
|
1398
|
+
};
|
|
1399
|
+
/**
|
|
1400
|
+
* 归一 QQ keyboard 按钮字段。
|
|
1401
|
+
*
|
|
1402
|
+
* @param button node-karin 转换后的 QQBot 按钮。
|
|
1403
|
+
* @param id 当前 keyboard 内唯一按钮序号。
|
|
1404
|
+
* @returns 可直接放入 QQ 官方 keyboard 的按钮对象。
|
|
1405
|
+
*/
|
|
1406
|
+
const normalizeQQBotButton = (button, id) => {
|
|
1407
|
+
return {
|
|
1408
|
+
...button,
|
|
1409
|
+
id: String(id)
|
|
1410
|
+
};
|
|
1411
|
+
};
|
|
1412
|
+
/**
|
|
1413
|
+
* 生成直发按钮不支持提示中的按钮名称列表。
|
|
1414
|
+
*
|
|
1415
|
+
* @param buttons 直发指令按钮列表。
|
|
1416
|
+
* @returns 便于写入日志的按钮名称。
|
|
1417
|
+
*/
|
|
1418
|
+
const formatCommandEnterButtonNames = (buttons) => {
|
|
1419
|
+
return buttons.map((button) => button.text || button.data || "未命名按钮").join("、");
|
|
1420
|
+
};
|
|
1421
|
+
|
|
698
1422
|
//#endregion
|
|
699
1423
|
//#region src/core/adapter/grouping.ts
|
|
700
1424
|
/**
|
|
1425
|
+
* 生成 QQ 被动回复使用的 msg_seq 种子。
|
|
1426
|
+
*
|
|
1427
|
+
* QQ 官方使用 `msg_id + msg_seq` 判重。这里和 openclaw qqbot 适配器一样限制在
|
|
1428
|
+
* 16-bit 范围内,后续每发一条再递增取模。
|
|
1429
|
+
*
|
|
1430
|
+
* @returns 0..65535 范围内的初始 msg_seq。
|
|
1431
|
+
*/
|
|
1432
|
+
const createMsgSeqSeed = () => {
|
|
1433
|
+
return (Date.now() % 1e8 ^ Math.floor(Math.random() * 65536)) % 65536;
|
|
1434
|
+
};
|
|
1435
|
+
/**
|
|
701
1436
|
* 创建空容器
|
|
702
1437
|
*/
|
|
703
1438
|
const createGrouping = () => ({
|
|
@@ -713,7 +1448,7 @@ const createGrouping = () => ({
|
|
|
713
1448
|
pasmsg: {
|
|
714
1449
|
type: "msg",
|
|
715
1450
|
id: "",
|
|
716
|
-
seq:
|
|
1451
|
+
seq: createMsgSeqSeed()
|
|
717
1452
|
},
|
|
718
1453
|
faces: []
|
|
719
1454
|
});
|
|
@@ -761,19 +1496,22 @@ const groupElements = (scene, elements) => {
|
|
|
761
1496
|
case "video":
|
|
762
1497
|
g.media.push({
|
|
763
1498
|
kind: "video",
|
|
764
|
-
source: v.file
|
|
1499
|
+
source: v.file,
|
|
1500
|
+
name: v.name
|
|
765
1501
|
});
|
|
766
1502
|
break;
|
|
767
1503
|
case "record":
|
|
768
1504
|
g.media.push({
|
|
769
1505
|
kind: "record",
|
|
770
|
-
source: v.file
|
|
1506
|
+
source: v.file,
|
|
1507
|
+
name: v.name
|
|
771
1508
|
});
|
|
772
1509
|
break;
|
|
773
1510
|
case "file":
|
|
774
1511
|
g.media.push({
|
|
775
1512
|
kind: "file",
|
|
776
|
-
source: v.file
|
|
1513
|
+
source: v.file,
|
|
1514
|
+
name: v.name
|
|
777
1515
|
});
|
|
778
1516
|
break;
|
|
779
1517
|
default: break;
|
|
@@ -781,9 +1519,218 @@ const groupElements = (scene, elements) => {
|
|
|
781
1519
|
return g;
|
|
782
1520
|
};
|
|
783
1521
|
|
|
1522
|
+
//#endregion
|
|
1523
|
+
//#region src/core/adapter/media-source.ts
|
|
1524
|
+
/** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
|
|
1525
|
+
const HTTP_URL_RE = /^https?:\/\//i;
|
|
1526
|
+
/** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
|
|
1527
|
+
const DEFAULT_FILENAME = {
|
|
1528
|
+
image: "image.jpg",
|
|
1529
|
+
video: "video.mp4",
|
|
1530
|
+
record: "record.mp3",
|
|
1531
|
+
file: "file.bin"
|
|
1532
|
+
};
|
|
1533
|
+
/**
|
|
1534
|
+
* 当前 Karin 运行时是否注册了 fileToUrl 处理器。
|
|
1535
|
+
* @returns true 表示可以优先通过 fileToUrl 产出公网 URL。
|
|
1536
|
+
*/
|
|
1537
|
+
const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
|
|
1538
|
+
/**
|
|
1539
|
+
* 判断一个来源是否已经是公网 HTTP(S) 地址。
|
|
1540
|
+
* @param source Karin 消息段里的 file 字段。
|
|
1541
|
+
* @returns 是否为 HTTP(S) URL。
|
|
1542
|
+
*/
|
|
1543
|
+
const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
|
|
1544
|
+
/**
|
|
1545
|
+
* 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
|
|
1546
|
+
*
|
|
1547
|
+
* QQ 官方的普通 file_data 上传对视频等资源更容易失败,因此 adapter 层始终把
|
|
1548
|
+
* fileToUrl 作为主路径,QQ 上传只做兜底。
|
|
1549
|
+
*
|
|
1550
|
+
* @param ctx 适配器实例,用于记录降级日志。
|
|
1551
|
+
* @param type 富媒体类型。
|
|
1552
|
+
* @param source Karin 消息段里的 file 字段。
|
|
1553
|
+
* @param filename 可选文件名,主要用于 file 消息。
|
|
1554
|
+
* @returns 解析后的首选来源和来源类型。
|
|
1555
|
+
*/
|
|
1556
|
+
const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
|
|
1557
|
+
if (isHttpMediaSource(source)) return {
|
|
1558
|
+
source,
|
|
1559
|
+
via: "public-url"
|
|
1560
|
+
};
|
|
1561
|
+
if (!hasFileToUrlHandler()) return {
|
|
1562
|
+
source,
|
|
1563
|
+
via: "fallback"
|
|
1564
|
+
};
|
|
1565
|
+
try {
|
|
1566
|
+
return {
|
|
1567
|
+
source: (await fileToUrl(type, source, filename || DEFAULT_FILENAME[type])).url,
|
|
1568
|
+
via: "file-to-url"
|
|
1569
|
+
};
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
|
|
1572
|
+
return {
|
|
1573
|
+
source,
|
|
1574
|
+
via: "fallback"
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
|
|
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
|
+
|
|
784
1730
|
//#endregion
|
|
785
1731
|
//#region src/core/adapter/text-to-md.ts
|
|
786
1732
|
const MAX_BUTTONS = 5;
|
|
1733
|
+
const MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
787
1734
|
/**
|
|
788
1735
|
* 提取文本中的 URL,将其替换为 `[按钮 N]` 占位文本并返回 button 列表
|
|
789
1736
|
*
|
|
@@ -824,17 +1771,58 @@ const imagesToMarkdown = async (urls) => {
|
|
|
824
1771
|
return ``;
|
|
825
1772
|
}));
|
|
826
1773
|
};
|
|
1774
|
+
/**
|
|
1775
|
+
* 将 markdown 文本拆为普通文本片段与图片来源。
|
|
1776
|
+
*/
|
|
1777
|
+
const splitMarkdownImages = (markdown) => {
|
|
1778
|
+
const parts = [];
|
|
1779
|
+
let lastIndex = 0;
|
|
1780
|
+
for (const match of markdown.matchAll(MARKDOWN_IMAGE_RE)) {
|
|
1781
|
+
const index = match.index ?? 0;
|
|
1782
|
+
if (index > lastIndex) {
|
|
1783
|
+
const text = markdown.slice(lastIndex, index);
|
|
1784
|
+
if (text) parts.push({
|
|
1785
|
+
type: "text",
|
|
1786
|
+
value: text
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
const source = (match[1] || "").trim().replace(/^<|>$/g, "");
|
|
1790
|
+
if (source) parts.push({
|
|
1791
|
+
type: "image",
|
|
1792
|
+
source
|
|
1793
|
+
});
|
|
1794
|
+
lastIndex = index + match[0].length;
|
|
1795
|
+
}
|
|
1796
|
+
if (lastIndex < markdown.length) {
|
|
1797
|
+
const text = markdown.slice(lastIndex);
|
|
1798
|
+
if (text) parts.push({
|
|
1799
|
+
type: "text",
|
|
1800
|
+
value: text
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
return parts.length ? parts : [{
|
|
1804
|
+
type: "text",
|
|
1805
|
+
value: markdown
|
|
1806
|
+
}];
|
|
1807
|
+
};
|
|
827
1808
|
|
|
828
1809
|
//#endregion
|
|
829
1810
|
//#region src/core/adapter/pipeline-qq.ts
|
|
1811
|
+
/** QQ 官方限制:单聊中同一 `msg_id` 最多发送四次被动回复。 */
|
|
1812
|
+
const C2C_PASSIVE_REPLY_LIMIT = 4;
|
|
1813
|
+
/** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
|
|
1814
|
+
const KEYBOARD_MAX_ROWS$1 = 5;
|
|
1815
|
+
const KEYBOARD_MAX_BUTTONS_PER_ROW$1 = 5;
|
|
1816
|
+
/** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
|
|
1817
|
+
const BUTTON_ONLY_MARKDOWN$1 = "";
|
|
830
1818
|
/** 群聊 event_id 白名单 */
|
|
831
|
-
const GROUP_EVENT_WHITELIST = new Set([
|
|
1819
|
+
const GROUP_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
832
1820
|
"INTERACTION_CREATE",
|
|
833
1821
|
"GROUP_ADD_ROBOT",
|
|
834
1822
|
"GROUP_MSG_RECEIVE"
|
|
835
1823
|
]);
|
|
836
1824
|
/** 单聊 event_id 白名单 */
|
|
837
|
-
const FRIEND_EVENT_WHITELIST = new Set([
|
|
1825
|
+
const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
838
1826
|
"INTERACTION_CREATE",
|
|
839
1827
|
"C2C_MSG_RECEIVE",
|
|
840
1828
|
"FRIEND_ADD"
|
|
@@ -845,18 +1833,21 @@ const FRIEND_EVENT_WHITELIST = new Set([
|
|
|
845
1833
|
const sendQQ = async (ctx, contact, elements) => {
|
|
846
1834
|
const target = contact.scene === "friend" ? "user" : "group";
|
|
847
1835
|
const grouping = groupElements(contact.scene, elements);
|
|
848
|
-
if (ctx.cfg.keyboard.enable && grouping.text.length) {
|
|
1836
|
+
if (ctx.cfg.markdown.enable && ctx.cfg.keyboard.enable && grouping.text.length) {
|
|
849
1837
|
const { text, buttons } = extractUrlButtons(grouping.text.join(""), contact.scene === "friend");
|
|
850
1838
|
grouping.text = [text];
|
|
851
1839
|
grouping.buttons.push(...buttons);
|
|
852
1840
|
}
|
|
853
1841
|
/**
|
|
854
|
-
*
|
|
855
|
-
* text / at /
|
|
856
|
-
*
|
|
857
|
-
*
|
|
1842
|
+
* 自动 Markdown 通道:
|
|
1843
|
+
* text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
|
|
1844
|
+
* 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 单独补发。所有富媒体
|
|
1845
|
+
* 都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
|
|
1846
|
+
* 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
|
|
1847
|
+
* 显式 segment.markdown 不受该自动通道开关影响。
|
|
858
1848
|
*/
|
|
859
|
-
return sendQQMarkdown(ctx, contact, grouping, target);
|
|
1849
|
+
if (ctx.cfg.markdown.enable) return sendQQMarkdown(ctx, contact, grouping, target);
|
|
1850
|
+
return sendQQClassic(ctx, contact, grouping, target);
|
|
860
1851
|
};
|
|
861
1852
|
/**
|
|
862
1853
|
* Markdown 通道:合成一条 msg_type=2 + 可选 keyboard
|
|
@@ -865,101 +1856,333 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
|
|
|
865
1856
|
const items = [];
|
|
866
1857
|
const lines = [];
|
|
867
1858
|
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
if (grouping.markdowns.length) grouping.markdowns.forEach((m) => lines.push(m.markdown));
|
|
879
|
-
if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
|
|
880
|
-
const content = lines.join("\n");
|
|
1859
|
+
const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages);
|
|
1860
|
+
const markdownFallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
|
|
1861
|
+
fallbackImages.push(...markdownFallbackImages);
|
|
1862
|
+
if (contact.scene === "group") {
|
|
1863
|
+
warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
1864
|
+
warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
|
|
1865
|
+
}
|
|
1866
|
+
if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(ctx, contact, grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
|
|
1867
|
+
else {
|
|
881
1868
|
const keyboard = buildKeyboard$1(grouping);
|
|
1869
|
+
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
|
|
882
1870
|
items.push(ctx.super.qq.markdown({ content }, keyboard));
|
|
883
1871
|
}
|
|
1872
|
+
for (const file of fallbackImages) {
|
|
1873
|
+
const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
|
|
1874
|
+
items.push(ctx.super.qq.media(res.file_info));
|
|
1875
|
+
}
|
|
884
1876
|
for (const m of grouping.media) {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const ext = m.kind === "record" ? "mp3" : m.kind === "video" ? "mp4" : "bin";
|
|
888
|
-
url = (await fileToUrl(m.kind, url, `${m.kind}.${ext}`)).url;
|
|
889
|
-
}
|
|
890
|
-
const res = await ctx.super.media.upload(target, contact.peer, m.kind, url, false);
|
|
1877
|
+
const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name);
|
|
1878
|
+
const res = source.via === "fallback" ? await ctx.super.media.uploadFallback(target, contact.peer, m.kind, source.source, false, m.name) : await ctx.super.media.upload(target, contact.peer, m.kind, source.source, false, m.name);
|
|
891
1879
|
items.push(ctx.super.qq.media(res.file_info));
|
|
892
1880
|
}
|
|
893
1881
|
if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
|
|
894
1882
|
return flushQQ(ctx, contact, grouping, items);
|
|
895
1883
|
};
|
|
896
1884
|
/**
|
|
1885
|
+
* 经典通道:文本走 msg_type=0,图片 / 富媒体走 msg_type=7。
|
|
1886
|
+
*/
|
|
1887
|
+
const sendQQClassic = async (ctx, contact, grouping, target) => {
|
|
1888
|
+
const items = [];
|
|
1889
|
+
const lines = [];
|
|
1890
|
+
const images = [...grouping.qqImages];
|
|
1891
|
+
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
1892
|
+
if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
|
|
1893
|
+
const content = lines.join("\n").trim();
|
|
1894
|
+
if (content) items.push(ctx.super.qq.text(content));
|
|
1895
|
+
for (const image of images) items.push(await buildQQMediaItem(ctx, target, contact.peer, "image", image));
|
|
1896
|
+
for (const m of grouping.media) items.push(await buildQQMediaItem(ctx, target, contact.peer, m.kind, m.source, m.name));
|
|
1897
|
+
await appendExplicitMarkdownItems(ctx, contact, grouping, target, items);
|
|
1898
|
+
if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
|
|
1899
|
+
return flushQQ(ctx, contact, grouping, items);
|
|
1900
|
+
};
|
|
1901
|
+
/**
|
|
1902
|
+
* 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
|
|
1903
|
+
*/
|
|
1904
|
+
const appendExplicitMarkdownItems = async (ctx, contact, grouping, target, items) => {
|
|
1905
|
+
if (!grouping.markdowns.length) return;
|
|
1906
|
+
const lines = [];
|
|
1907
|
+
const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
|
|
1908
|
+
if (contact.scene === "group") {
|
|
1909
|
+
warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
1910
|
+
warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
|
|
1911
|
+
}
|
|
1912
|
+
const keyboard = buildKeyboard$1(grouping);
|
|
1913
|
+
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
|
|
1914
|
+
items.push(ctx.super.qq.markdown({ content }, keyboard));
|
|
1915
|
+
for (const file of fallbackImages) {
|
|
1916
|
+
const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
|
|
1917
|
+
items.push(ctx.super.qq.media(res.file_info));
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
|
|
1921
|
+
const resolved = await resolvePreferredMediaSource(ctx, type, source, name);
|
|
1922
|
+
const res = resolved.via === "fallback" ? await ctx.super.media.uploadFallback(target, peer, type, resolved.source, false, name) : await ctx.super.media.upload(target, peer, type, resolved.source, false, name);
|
|
1923
|
+
return ctx.super.qq.media(res.file_info);
|
|
1924
|
+
};
|
|
1925
|
+
/**
|
|
1926
|
+
* 将能转成公网 URL 的图片追加到 markdown 行,无法转换的图片返回给富媒体 fallback。
|
|
1927
|
+
*
|
|
1928
|
+
* @param ctx 适配器实例,用于输出降级日志。
|
|
1929
|
+
* @param lines markdown 行数组。
|
|
1930
|
+
* @param files QQ 图片消息段的 file 字段。
|
|
1931
|
+
* @returns 需要改走 msg_type=7 富媒体发送的图片列表。
|
|
1932
|
+
*/
|
|
1933
|
+
const appendMarkdownImages = async (ctx, lines, files) => {
|
|
1934
|
+
const fallback = [];
|
|
1935
|
+
for (const file of files) {
|
|
1936
|
+
const resolved = await resolvePreferredMediaSource(ctx, "image", file);
|
|
1937
|
+
if (resolved.via === "fallback") {
|
|
1938
|
+
fallback.push(file);
|
|
1939
|
+
continue;
|
|
1940
|
+
}
|
|
1941
|
+
await appendImageUrlLine(lines, resolved.source);
|
|
1942
|
+
}
|
|
1943
|
+
return fallback;
|
|
1944
|
+
};
|
|
1945
|
+
/**
|
|
1946
|
+
* 处理显式 segment.markdown 中的图片:可转公网 URL 的继续嵌入,无法转的改走富媒体。
|
|
1947
|
+
*/
|
|
1948
|
+
const appendExplicitMarkdown = async (ctx, lines, grouping) => {
|
|
1949
|
+
const fallback = [];
|
|
1950
|
+
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
|
|
1951
|
+
if (part.type === "text") {
|
|
1952
|
+
const text = part.value.trim();
|
|
1953
|
+
if (text) lines.push(text);
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
const resolved = await resolvePreferredMediaSource(ctx, "image", part.source);
|
|
1957
|
+
if (resolved.via === "fallback") {
|
|
1958
|
+
fallback.push(part.source);
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
await appendImageUrlLine(lines, resolved.source);
|
|
1962
|
+
}
|
|
1963
|
+
return fallback;
|
|
1964
|
+
};
|
|
1965
|
+
/**
|
|
1966
|
+
* 生成单张 markdown 图片行。
|
|
1967
|
+
* 尺寸获取失败时使用保守默认尺寸,保证 fileToUrl 成功的图片仍走 markdown。
|
|
1968
|
+
*
|
|
1969
|
+
* @param lines markdown 行数组。
|
|
1970
|
+
* @param url 图片 URL。
|
|
1971
|
+
*/
|
|
1972
|
+
const appendImageUrlLine = async (lines, url) => {
|
|
1973
|
+
try {
|
|
1974
|
+
const [line] = await imagesToMarkdown([url]);
|
|
1975
|
+
lines.push(line);
|
|
1976
|
+
} catch {
|
|
1977
|
+
lines.push(``);
|
|
1978
|
+
}
|
|
1979
|
+
};
|
|
1980
|
+
/**
|
|
1981
|
+
* 判断显式引用回复是否应降级为普通文本消息。
|
|
1982
|
+
*
|
|
1983
|
+
* QQ 的 `message_reference` 对普通文本最稳定;markdown 带引用在客户端渲染上存在兼容问题。
|
|
1984
|
+
* 因此只有纯文本显式引用回复会走 msg_type=0,图片、按钮、keyboard、markdown 段仍走原通道。
|
|
1985
|
+
*
|
|
1986
|
+
* @param grouping 已归类的消息段。
|
|
1987
|
+
* @returns 是否使用普通文本发送主消息。
|
|
1988
|
+
*/
|
|
1989
|
+
const shouldUseTextForReference = (ctx, contact, grouping) => {
|
|
1990
|
+
return isQQReferenceMessageId(resolveReferenceMessageId(ctx, contact, grouping.reply.messageId)) && !grouping.qqImages.length && !grouping.buttons.length && !grouping.keyboards.length && !grouping.markdowns.length;
|
|
1991
|
+
};
|
|
1992
|
+
/**
|
|
1993
|
+
* 判断给定 ID 是否是 QQ 客户端引用使用的 REFIDX。
|
|
1994
|
+
*
|
|
1995
|
+
* `ROBOT1.0_...` 这类官方消息 ID 只能作为被动发送的 `msg_id`,不能放进
|
|
1996
|
+
* `message_reference.message_id`。
|
|
1997
|
+
*
|
|
1998
|
+
* @param messageId 待判断的消息 ID。
|
|
1999
|
+
* @returns 是否为 QQ 引用索引。
|
|
2000
|
+
*/
|
|
2001
|
+
const isQQReferenceMessageId = (messageId) => messageId.startsWith("REFIDX_");
|
|
2002
|
+
/**
|
|
897
2003
|
* 构造 keyboard 字段(buttons + keyboards 合并)
|
|
898
2004
|
*/
|
|
899
2005
|
const buildKeyboard$1 = (grouping) => {
|
|
900
2006
|
const rows = [];
|
|
901
2007
|
grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
|
|
902
2008
|
grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
|
|
903
|
-
|
|
904
|
-
|
|
2009
|
+
const normalizedRows = [];
|
|
2010
|
+
let id = 0;
|
|
2011
|
+
for (const row of rows.slice(0, KEYBOARD_MAX_ROWS$1)) {
|
|
2012
|
+
const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW$1).map((button) => normalizeQQBotButton(button, id++));
|
|
2013
|
+
if (buttons.length) normalizedRows.push({ buttons });
|
|
2014
|
+
}
|
|
2015
|
+
if (!normalizedRows.length) return void 0;
|
|
2016
|
+
return { content: { rows: normalizedRows } };
|
|
2017
|
+
};
|
|
2018
|
+
/**
|
|
2019
|
+
* 群聊不支持 `enter: true` 直发按钮,只输出提示,不拦截 keyboard 发送。
|
|
2020
|
+
*
|
|
2021
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
2022
|
+
* @param buttons 本次消息中的直发指令按钮。
|
|
2023
|
+
*/
|
|
2024
|
+
const warnUnsupportedCommandEnterButtons$1 = (ctx, buttons) => {
|
|
2025
|
+
if (!buttons.length) return;
|
|
2026
|
+
const names = formatCommandEnterButtonNames(buttons);
|
|
2027
|
+
ctx.logger("debug", `群聊不支持 enter: true 直接发送,按钮仍会按原样发送: ${names}`);
|
|
2028
|
+
};
|
|
2029
|
+
/**
|
|
2030
|
+
* 群聊不支持 `<qqbot-cmd-enter>` 文本链,只输出提示,不拦截 markdown 发送。
|
|
2031
|
+
*
|
|
2032
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
2033
|
+
* @param markdowns 本次消息中的 markdown 内容。
|
|
2034
|
+
*/
|
|
2035
|
+
const warnUnsupportedCommandEnterMarkdowns$1 = (ctx, markdowns) => {
|
|
2036
|
+
if (!markdowns.some(hasCommandEnterTextChain)) return;
|
|
2037
|
+
ctx.logger("debug", "群聊不支持 <qqbot-cmd-enter> 直接发送,markdown 仍会按原样发送");
|
|
905
2038
|
};
|
|
906
2039
|
/**
|
|
907
2040
|
* 逐条发送 + 被动消息附加 + 引用回复
|
|
908
2041
|
*/
|
|
909
2042
|
const flushQQ = async (ctx, contact, grouping, items) => {
|
|
910
2043
|
const result = ctx.initSendMsgResults();
|
|
911
|
-
const
|
|
2044
|
+
const passiveSource = resolvePassiveQQ(grouping);
|
|
2045
|
+
const passive = buildPassiveQQ(ctx, contact.scene, grouping);
|
|
912
2046
|
const send = contact.scene === "friend" ? (peer, item) => ctx.super.messages.sendFriendMsg(peer, item) : (peer, item) => ctx.super.messages.sendGroupMsg(peer, item);
|
|
913
|
-
|
|
914
|
-
|
|
2047
|
+
/**
|
|
2048
|
+
* 仅单聊 `msg_id` 回复受四次上限约束。`event_id` 事件回复和群聊不适用该限制。
|
|
2049
|
+
* 这是 QQ 官方 2026-01-10 更新后的限制,超出部分不请求平台接口。
|
|
2050
|
+
*/
|
|
2051
|
+
const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ? C2C_PASSIVE_REPLY_LIMIT : Infinity;
|
|
2052
|
+
if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${C2C_PASSIVE_REPLY_LIMIT} 条,已跳过 ${items.length - maxItems} 条`);
|
|
2053
|
+
let referenceHandled = false;
|
|
2054
|
+
for (const item of items.slice(0, maxItems)) {
|
|
915
2055
|
passive(item);
|
|
916
|
-
if (!
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const res = await send(contact.peer, item);
|
|
2056
|
+
if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(ctx, contact, item, grouping);
|
|
2057
|
+
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);
|
|
921
2060
|
result.rawData.push(res);
|
|
922
2061
|
}
|
|
923
2062
|
return ctx.handleResponse(result);
|
|
924
2063
|
};
|
|
925
2064
|
/**
|
|
2065
|
+
* 发送 QQ 消息,并在平台拒绝 `event_id` 时降级为普通消息重试。
|
|
2066
|
+
*
|
|
2067
|
+
* QQ 文档说明 `INTERACTION_CREATE.d.id` 可用于被动消息发送,但实测群聊接口会出现
|
|
2068
|
+
* 40034025。为了避免按钮回调业务回复把 Karin 命令链路打断,这里只对
|
|
2069
|
+
* `event_id` 参数无效做一次无 `event_id` 重试。
|
|
2070
|
+
*
|
|
2071
|
+
* @param ctx 适配器实例,用于输出降级日志。
|
|
2072
|
+
* @param contact 消息目标。
|
|
2073
|
+
* @param send 当前场景的发送函数。
|
|
2074
|
+
* @param item 即将发送的 QQ 消息体。
|
|
2075
|
+
* @returns QQ 消息发送响应。
|
|
2076
|
+
*/
|
|
2077
|
+
const sendQQWithEventFallback = async (ctx, contact, send, item) => {
|
|
2078
|
+
try {
|
|
2079
|
+
return await send(contact.peer, item);
|
|
2080
|
+
} catch (err) {
|
|
2081
|
+
if (!item.event_id || !isInvalidEventIdError(err)) throw err;
|
|
2082
|
+
ctx.logger("warn", `event_id 被 QQ 拒绝,已改用普通消息重试: ${item.event_id}`);
|
|
2083
|
+
const retryItem = { ...item };
|
|
2084
|
+
delete retryItem.event_id;
|
|
2085
|
+
delete retryItem.msg_seq;
|
|
2086
|
+
return send(contact.peer, retryItem);
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
/**
|
|
2090
|
+
* 判断发送失败是否来自 QQ 对 `event_id` 的参数校验。
|
|
2091
|
+
*
|
|
2092
|
+
* 当前错误由 Http 层格式化为普通 Error,因此这里基于官方错误码和中文错误信息兜底识别。
|
|
2093
|
+
*
|
|
2094
|
+
* @param err 捕获到的发送异常。
|
|
2095
|
+
* @returns 是否可以通过移除 `event_id` 重试。
|
|
2096
|
+
*/
|
|
2097
|
+
const isInvalidEventIdError = (err) => {
|
|
2098
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2099
|
+
return message.includes("40034025") || message.includes("请求参数event_id无效");
|
|
2100
|
+
};
|
|
2101
|
+
/**
|
|
2102
|
+
* 解析 QQ 场景应使用的被动回复来源。
|
|
2103
|
+
*
|
|
2104
|
+
* `msg_id/event_id` 是 QQBot 被动发送凭证,不等于客户端展示引用使用的
|
|
2105
|
+
* `message_reference.message_id`。因此这里只读取事件入口追加的 pasmsg,显式
|
|
2106
|
+
* `segment.reply` 交给 `attachVisibleReferenceQQ` 处理。
|
|
2107
|
+
*
|
|
2108
|
+
* @param grouping 已归类的消息段。
|
|
2109
|
+
* @returns 可附加到发送体的被动回复来源;没有可用来源时返回 undefined。
|
|
2110
|
+
*/
|
|
2111
|
+
const resolvePassiveQQ = (grouping) => {
|
|
2112
|
+
if (grouping.pasmsg.id) return grouping.pasmsg;
|
|
2113
|
+
};
|
|
2114
|
+
/**
|
|
2115
|
+
* 附加 QQ 客户端可见的引用对象。
|
|
2116
|
+
*
|
|
2117
|
+
* 群聊/单聊里真正发给 QQ 的值必须是 `message_scene.ext` 的 `msg_idx=REFIDX_xxx`。
|
|
2118
|
+
* 如果插件传入的是 Karin 暴露的官方 API 消息 ID,这里会先按同会话映射转换。
|
|
2119
|
+
*
|
|
2120
|
+
* @param item 即将发送的 QQ 消息体。
|
|
2121
|
+
* @param grouping 已归类的消息段。
|
|
2122
|
+
* @returns 是否已经消费一次显式引用。
|
|
2123
|
+
*/
|
|
2124
|
+
const attachVisibleReferenceQQ = (ctx, contact, item, grouping) => {
|
|
2125
|
+
const messageId = resolveReferenceMessageId(ctx, contact, grouping.reply.messageId);
|
|
2126
|
+
if (!isQQReferenceMessageId(messageId)) return false;
|
|
2127
|
+
item.message_reference = { message_id: messageId };
|
|
2128
|
+
return true;
|
|
2129
|
+
};
|
|
2130
|
+
/**
|
|
2131
|
+
* 递增并返回下一条 QQ 被动回复序号。
|
|
2132
|
+
*
|
|
2133
|
+
* @param grouping 已归类的消息段。
|
|
2134
|
+
* @returns 0..65535 范围内的 msg_seq。
|
|
2135
|
+
*/
|
|
2136
|
+
const nextPassiveMsgSeq = (grouping) => {
|
|
2137
|
+
grouping.pasmsg.seq = (grouping.pasmsg.seq + 1) % 65536;
|
|
2138
|
+
return grouping.pasmsg.seq;
|
|
2139
|
+
};
|
|
2140
|
+
/**
|
|
926
2141
|
* 构造被动消息附加器(含白名单校验)
|
|
927
2142
|
*/
|
|
928
|
-
const buildPassiveQQ = (scene, grouping) => {
|
|
929
|
-
|
|
2143
|
+
const buildPassiveQQ = (ctx, scene, grouping) => {
|
|
2144
|
+
const source = resolvePassiveQQ(grouping);
|
|
2145
|
+
if (!source?.id) return (_item) => void 0;
|
|
930
2146
|
const whitelist = scene === "friend" ? FRIEND_EVENT_WHITELIST : GROUP_EVENT_WHITELIST;
|
|
931
|
-
if (
|
|
932
|
-
const eventName =
|
|
933
|
-
if (eventName && !whitelist.has(eventName)) {
|
|
2147
|
+
if (source.type === "event") {
|
|
2148
|
+
const eventName = source.id.split(":")[0];
|
|
2149
|
+
if (eventName && !whitelist.has(eventName)) {
|
|
2150
|
+
ctx.logger("warn", `跳过无效 event_id: ${source.id}`);
|
|
2151
|
+
return (_item) => void 0;
|
|
2152
|
+
}
|
|
934
2153
|
return (item) => {
|
|
935
|
-
grouping
|
|
936
|
-
item.
|
|
937
|
-
item.event_id = grouping.pasmsg.id;
|
|
2154
|
+
item.msg_seq = nextPassiveMsgSeq(grouping);
|
|
2155
|
+
item.event_id = source.id;
|
|
938
2156
|
};
|
|
939
2157
|
}
|
|
940
2158
|
return (item) => {
|
|
941
|
-
grouping
|
|
942
|
-
item.
|
|
943
|
-
item.msg_id = grouping.pasmsg.id;
|
|
2159
|
+
item.msg_seq = nextPassiveMsgSeq(grouping);
|
|
2160
|
+
item.msg_id = source.id;
|
|
944
2161
|
};
|
|
945
2162
|
};
|
|
946
2163
|
|
|
947
2164
|
//#endregion
|
|
948
2165
|
//#region src/core/adapter/pipeline-guild.ts
|
|
2166
|
+
/** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
|
|
2167
|
+
const KEYBOARD_MAX_ROWS = 5;
|
|
2168
|
+
const KEYBOARD_MAX_BUTTONS_PER_ROW = 5;
|
|
2169
|
+
/** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
|
|
2170
|
+
const BUTTON_ONLY_MARKDOWN = "";
|
|
949
2171
|
/**
|
|
950
2172
|
* 处理频道场景(子频道 + 私信)的发送
|
|
951
2173
|
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
2174
|
+
* 默认把普通文本 / at / 图片通过 markdown 渲染。关闭自动 Markdown 通道后,
|
|
2175
|
+
* 普通文本和图片改走经典发送;显式 segment.markdown 仍按调用方指定走 Markdown。
|
|
954
2176
|
*/
|
|
955
2177
|
const sendGuild = async (ctx, contact, elements) => {
|
|
956
2178
|
const grouping = groupElements(contact.scene, elements);
|
|
957
|
-
if (ctx.cfg.keyboard.enable && grouping.text.length) {
|
|
2179
|
+
if (ctx.cfg.markdown.enable && ctx.cfg.keyboard.enable && grouping.text.length) {
|
|
958
2180
|
const { text, buttons } = extractUrlButtons(grouping.text.join(""), false);
|
|
959
2181
|
grouping.text = [text];
|
|
960
2182
|
grouping.buttons.push(...buttons);
|
|
961
2183
|
}
|
|
962
|
-
return sendGuildMarkdown(ctx, contact, grouping);
|
|
2184
|
+
if (ctx.cfg.markdown.enable) return sendGuildMarkdown(ctx, contact, grouping);
|
|
2185
|
+
return sendGuildClassic(ctx, contact, grouping);
|
|
963
2186
|
};
|
|
964
2187
|
/**
|
|
965
2188
|
* Markdown 通道
|
|
@@ -977,51 +2200,329 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
|
|
|
977
2200
|
const mdImages = await imagesToMarkdown(allImages);
|
|
978
2201
|
lines.push(...mdImages);
|
|
979
2202
|
}
|
|
980
|
-
grouping.markdowns
|
|
2203
|
+
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
|
|
2204
|
+
const text = part.value.trim();
|
|
2205
|
+
if (text) lines.push(text);
|
|
2206
|
+
} else {
|
|
2207
|
+
const [mdImage] = await imagesToMarkdown([part.source]);
|
|
2208
|
+
lines.push(mdImage);
|
|
2209
|
+
}
|
|
2210
|
+
warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2211
|
+
warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
|
|
981
2212
|
if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
|
|
982
2213
|
const keyboard = buildKeyboard(grouping);
|
|
983
|
-
|
|
2214
|
+
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
|
|
2215
|
+
items.push(ctx.super.guild.markdown({ content }, keyboard));
|
|
984
2216
|
}
|
|
985
2217
|
if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
|
|
986
2218
|
return flushGuild(ctx, contact, grouping, items);
|
|
987
2219
|
};
|
|
2220
|
+
/**
|
|
2221
|
+
* 经典通道:文本走 content,图片按文档走 image / file_image 单独发送。
|
|
2222
|
+
*/
|
|
2223
|
+
const sendGuildClassic = async (ctx, contact, grouping) => {
|
|
2224
|
+
const items = [];
|
|
2225
|
+
const lines = [];
|
|
2226
|
+
const images = [...grouping.guildImageUrls, ...grouping.guildImageFiles];
|
|
2227
|
+
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
2228
|
+
if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
|
|
2229
|
+
const content = lines.join("\n").trim();
|
|
2230
|
+
if (content) items.push(ctx.super.guild.text(content));
|
|
2231
|
+
for (const image of images) items.push(await buildGuildImageItem(image));
|
|
2232
|
+
await appendExplicitGuildMarkdownItems(ctx, contact, grouping, items);
|
|
2233
|
+
if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
|
|
2234
|
+
return flushGuild(ctx, contact, grouping, items);
|
|
2235
|
+
};
|
|
2236
|
+
/**
|
|
2237
|
+
* 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
|
|
2238
|
+
*/
|
|
2239
|
+
const appendExplicitGuildMarkdownItems = async (ctx, contact, grouping, items) => {
|
|
2240
|
+
if (!grouping.markdowns.length) return;
|
|
2241
|
+
const lines = [];
|
|
2242
|
+
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
|
|
2243
|
+
const text = part.value.trim();
|
|
2244
|
+
if (text) lines.push(text);
|
|
2245
|
+
} else {
|
|
2246
|
+
const [mdImage] = await imagesToMarkdown([part.source]);
|
|
2247
|
+
lines.push(mdImage);
|
|
2248
|
+
}
|
|
2249
|
+
warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2250
|
+
warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
|
|
2251
|
+
const keyboard = buildKeyboard(grouping);
|
|
2252
|
+
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
|
|
2253
|
+
items.push(ctx.super.guild.markdown({ content }, keyboard));
|
|
2254
|
+
};
|
|
2255
|
+
const buildGuildImageItem = async (source) => {
|
|
2256
|
+
if (/^https?:\/\//i.test(source)) return {
|
|
2257
|
+
type: "image",
|
|
2258
|
+
image: source
|
|
2259
|
+
};
|
|
2260
|
+
const form = new FormData();
|
|
2261
|
+
form.append("file_image", await common.buffer(source, { http: false }), { filename: resolveImageFilename(source) });
|
|
2262
|
+
return form;
|
|
2263
|
+
};
|
|
2264
|
+
const resolveImageFilename = (source) => {
|
|
2265
|
+
const file = source.split(/[?#]/)[0] || "";
|
|
2266
|
+
const name = path.basename(file);
|
|
2267
|
+
return name.includes(".") ? name : "image.jpg";
|
|
2268
|
+
};
|
|
988
2269
|
const buildKeyboard = (grouping) => {
|
|
989
2270
|
const rows = [];
|
|
990
2271
|
grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
|
|
991
2272
|
grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
|
|
992
|
-
|
|
993
|
-
|
|
2273
|
+
const normalizedRows = [];
|
|
2274
|
+
let id = 0;
|
|
2275
|
+
for (const row of rows.slice(0, KEYBOARD_MAX_ROWS)) {
|
|
2276
|
+
const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW).map((button) => normalizeQQBotButton(button, id++));
|
|
2277
|
+
if (buttons.length) normalizedRows.push({ buttons });
|
|
2278
|
+
}
|
|
2279
|
+
if (!normalizedRows.length) return void 0;
|
|
2280
|
+
return { content: { rows: normalizedRows } };
|
|
2281
|
+
};
|
|
2282
|
+
/**
|
|
2283
|
+
* 频道消息不支持 `enter: true` 直发按钮,只输出提示,不拦截 keyboard 发送。
|
|
2284
|
+
*
|
|
2285
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
2286
|
+
* @param scene 频道消息场景。
|
|
2287
|
+
* @param buttons 本次消息中的直发指令按钮。
|
|
2288
|
+
*/
|
|
2289
|
+
const warnUnsupportedCommandEnterButtons = (ctx, scene, buttons) => {
|
|
2290
|
+
if (!buttons.length) return;
|
|
2291
|
+
const names = formatCommandEnterButtonNames(buttons);
|
|
2292
|
+
const name = scene === "guild" ? "频道" : "频道私信";
|
|
2293
|
+
ctx.logger("debug", `${name}不支持 enter: true 直接发送,按钮仍会按原样发送: ${names}`);
|
|
2294
|
+
};
|
|
2295
|
+
/**
|
|
2296
|
+
* 频道消息不支持 `<qqbot-cmd-enter>` 文本链,只输出提示,不拦截 markdown 发送。
|
|
2297
|
+
*
|
|
2298
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
2299
|
+
* @param scene 频道消息场景。
|
|
2300
|
+
* @param markdowns 本次消息中的 markdown 内容。
|
|
2301
|
+
*/
|
|
2302
|
+
const warnUnsupportedCommandEnterMarkdowns = (ctx, scene, markdowns) => {
|
|
2303
|
+
if (!markdowns.some(hasCommandEnterTextChain)) return;
|
|
2304
|
+
const name = scene === "guild" ? "频道" : "频道私信";
|
|
2305
|
+
ctx.logger("debug", `${name}不支持 <qqbot-cmd-enter> 直接发送,markdown 仍会按原样发送`);
|
|
994
2306
|
};
|
|
995
2307
|
const flushGuild = async (ctx, contact, grouping, items) => {
|
|
996
2308
|
const result = ctx.initSendMsgResults();
|
|
997
2309
|
const passive = buildPassiveGuild(grouping);
|
|
998
2310
|
const send = contact.scene === "guild" ? (peer, subPeer, item) => ctx.super.messages.sendChannelMsg(subPeer, item) : (peer, _subPeer, item) => ctx.super.messages.sendDmsMsg(peer, item);
|
|
999
|
-
let replyHandled = false;
|
|
1000
2311
|
for (const item of items) {
|
|
1001
2312
|
passive(item);
|
|
1002
|
-
if (!replyHandled && grouping.reply.messageId && !(item instanceof FormData)) {
|
|
1003
|
-
item.message_reference = { message_id: grouping.reply.messageId };
|
|
1004
|
-
replyHandled = true;
|
|
1005
|
-
}
|
|
1006
2313
|
const res = await send(contact.peer, contact.subPeer, item);
|
|
1007
2314
|
result.rawData.push(res);
|
|
1008
2315
|
}
|
|
1009
2316
|
return ctx.handleResponse(result);
|
|
1010
2317
|
};
|
|
2318
|
+
/**
|
|
2319
|
+
* 解析频道/私信场景的被动回复来源。
|
|
2320
|
+
*
|
|
2321
|
+
* openclaw 的 qqbot 适配器对 channel/dm 也只传 `msg_id`。这里让显式
|
|
2322
|
+
* `segment.reply` 优先,其次使用事件入口追加的 pasmsg。
|
|
2323
|
+
*
|
|
2324
|
+
* @param grouping 已归类的消息段。
|
|
2325
|
+
* @returns 被动回复来源;没有可用来源时返回 undefined。
|
|
2326
|
+
*/
|
|
2327
|
+
const resolvePassiveGuild = (grouping) => {
|
|
2328
|
+
if (grouping.reply.messageId) return {
|
|
2329
|
+
type: "msg",
|
|
2330
|
+
id: grouping.reply.messageId
|
|
2331
|
+
};
|
|
2332
|
+
if (grouping.pasmsg.id) return grouping.pasmsg;
|
|
2333
|
+
};
|
|
2334
|
+
/**
|
|
2335
|
+
* 构造频道/私信被动消息附加器。
|
|
2336
|
+
*
|
|
2337
|
+
* @param grouping 已归类的消息段。
|
|
2338
|
+
* @returns 可直接修改发送体或 FormData 的附加函数。
|
|
2339
|
+
*/
|
|
1011
2340
|
const buildPassiveGuild = (grouping) => {
|
|
1012
|
-
|
|
1013
|
-
if (
|
|
1014
|
-
|
|
1015
|
-
|
|
2341
|
+
const source = resolvePassiveGuild(grouping);
|
|
2342
|
+
if (!source?.id) return (_item) => void 0;
|
|
2343
|
+
if (source.type === "event") return (item) => {
|
|
2344
|
+
if (item instanceof FormData) item.append("event_id", source.id);
|
|
2345
|
+
else item.event_id = source.id;
|
|
1016
2346
|
};
|
|
1017
2347
|
return (item) => {
|
|
1018
|
-
if (item instanceof FormData) item.append("msg_id",
|
|
1019
|
-
else item.msg_id =
|
|
2348
|
+
if (item instanceof FormData) item.append("msg_id", source.id);
|
|
2349
|
+
else item.msg_id = source.id;
|
|
2350
|
+
};
|
|
2351
|
+
};
|
|
2352
|
+
|
|
2353
|
+
//#endregion
|
|
2354
|
+
//#region src/core/adapter/self-message-cache.ts
|
|
2355
|
+
/**
|
|
2356
|
+
* 只有两个缓存开关同时开启时才需要等待发送结果并缓存自己消息。
|
|
2357
|
+
*/
|
|
2358
|
+
const shouldCacheSelfMessage = (ctx) => {
|
|
2359
|
+
return ctx.cfg.messageCache.enable && ctx.cfg.messageCache.self;
|
|
2360
|
+
};
|
|
2361
|
+
/**
|
|
2362
|
+
* 预处理自己消息元素。
|
|
2363
|
+
*
|
|
2364
|
+
* 静态资源只在这里转一次 HTTP URL,之后发送 pipeline 和缓存都复用该 URL。
|
|
2365
|
+
*/
|
|
2366
|
+
const prepareSelfMessageCache = async (ctx, elements) => {
|
|
2367
|
+
const sendElements = [];
|
|
2368
|
+
const cacheElements = [];
|
|
2369
|
+
for (const element of elements) {
|
|
2370
|
+
const prepared = await prepareSelfCacheElement(ctx, element);
|
|
2371
|
+
sendElements.push(prepared.send);
|
|
2372
|
+
if (prepared.cache) cacheElements.push(prepared.cache);
|
|
2373
|
+
}
|
|
2374
|
+
return {
|
|
2375
|
+
sendElements,
|
|
2376
|
+
cacheElements
|
|
1020
2377
|
};
|
|
1021
2378
|
};
|
|
2379
|
+
/**
|
|
2380
|
+
* 发送成功后缓存机器人自己的消息,供 bot.getMsg(messageId) 查询。
|
|
2381
|
+
*/
|
|
2382
|
+
const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
|
|
2383
|
+
if (!shouldCacheSelfMessage(ctx)) return;
|
|
2384
|
+
if (!cacheElements.length) return;
|
|
2385
|
+
const responses = (Array.isArray(result.rawData) ? result.rawData : [result.rawData]).filter((item) => item?.id);
|
|
2386
|
+
if (!responses.length) return;
|
|
2387
|
+
const sender = selfSender(ctx, contact);
|
|
2388
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2389
|
+
for (const response of responses) {
|
|
2390
|
+
const apiMessageId = String(response.id);
|
|
2391
|
+
const referenceMessageId = typeof response.ext_info?.ref_idx === "string" ? response.ext_info.ref_idx : "";
|
|
2392
|
+
const messageId = apiMessageId;
|
|
2393
|
+
const aliases = [referenceMessageId].filter((id) => id && id !== messageId);
|
|
2394
|
+
if (!messageId || seen.has(messageId)) continue;
|
|
2395
|
+
seen.add(messageId);
|
|
2396
|
+
if (referenceMessageId) rememberApiMessageId(ctx, contact, referenceMessageId, apiMessageId);
|
|
2397
|
+
ctx.messageStore.save(String(ctx.cfg.appId), {
|
|
2398
|
+
messageId,
|
|
2399
|
+
messageSeq: 0,
|
|
2400
|
+
time: responseTime(response.timestamp),
|
|
2401
|
+
contact,
|
|
2402
|
+
sender,
|
|
2403
|
+
elements: cacheElements
|
|
2404
|
+
}, aliases).catch((err) => ctx.logger("warn", `[getMsg] 写入自己消息缓存失败: ${messageId}`, err));
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
/**
|
|
2408
|
+
* 转换单个自己消息元素为缓存安全形态。
|
|
2409
|
+
*/
|
|
2410
|
+
const prepareSelfCacheElement = async (ctx, element) => {
|
|
2411
|
+
switch (element.type) {
|
|
2412
|
+
case "text":
|
|
2413
|
+
case "at":
|
|
2414
|
+
case "reply":
|
|
2415
|
+
case "face":
|
|
2416
|
+
case "markdown": {
|
|
2417
|
+
const cached = { ...element };
|
|
2418
|
+
return {
|
|
2419
|
+
send: cached,
|
|
2420
|
+
cache: cached
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
case "image":
|
|
2424
|
+
case "video":
|
|
2425
|
+
case "record":
|
|
2426
|
+
case "file": return prepareStaticElement(ctx, element);
|
|
2427
|
+
default: return {
|
|
2428
|
+
send: element,
|
|
2429
|
+
cache: null
|
|
2430
|
+
};
|
|
2431
|
+
}
|
|
2432
|
+
};
|
|
2433
|
+
/**
|
|
2434
|
+
* 静态资源统一转 HTTP URL 后再缓存;转换失败时跳过该元素。
|
|
2435
|
+
*/
|
|
2436
|
+
const prepareStaticElement = async (ctx, element) => {
|
|
2437
|
+
if (element.file.startsWith("http")) {
|
|
2438
|
+
const cached = { ...element };
|
|
2439
|
+
return {
|
|
2440
|
+
send: cached,
|
|
2441
|
+
cache: cached
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
try {
|
|
2445
|
+
switch (element.type) {
|
|
2446
|
+
case "image": {
|
|
2447
|
+
const res = await fileToUrl("image", element.file, element.name || "image.jpg");
|
|
2448
|
+
const cached = {
|
|
2449
|
+
...element,
|
|
2450
|
+
file: res.url,
|
|
2451
|
+
width: res.width,
|
|
2452
|
+
height: res.height
|
|
2453
|
+
};
|
|
2454
|
+
return {
|
|
2455
|
+
send: cached,
|
|
2456
|
+
cache: cached
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
case "video": {
|
|
2460
|
+
const res = await fileToUrl("video", element.file, element.name || "video.mp4");
|
|
2461
|
+
const cached = {
|
|
2462
|
+
...element,
|
|
2463
|
+
file: res.url
|
|
2464
|
+
};
|
|
2465
|
+
return {
|
|
2466
|
+
send: cached,
|
|
2467
|
+
cache: cached
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
case "record": {
|
|
2471
|
+
const res = await fileToUrl("record", element.file, element.name || "record.mp3");
|
|
2472
|
+
const cached = {
|
|
2473
|
+
...element,
|
|
2474
|
+
file: res.url
|
|
2475
|
+
};
|
|
2476
|
+
return {
|
|
2477
|
+
send: cached,
|
|
2478
|
+
cache: cached
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
case "file": {
|
|
2482
|
+
const res = await fileToUrl("file", element.file, element.name || "file.bin");
|
|
2483
|
+
const cached = {
|
|
2484
|
+
...element,
|
|
2485
|
+
file: res.url
|
|
2486
|
+
};
|
|
2487
|
+
return {
|
|
2488
|
+
send: cached,
|
|
2489
|
+
cache: cached
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
} catch (err) {
|
|
2494
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2495
|
+
ctx.logger("debug", `[getMsg] 跳过自己消息资源缓存: ${element.type} ${message}`);
|
|
2496
|
+
return {
|
|
2497
|
+
send: element,
|
|
2498
|
+
cache: null
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
};
|
|
2502
|
+
/**
|
|
2503
|
+
* 构造机器人自己的 sender。
|
|
2504
|
+
*/
|
|
2505
|
+
const selfSender = (ctx, contact) => {
|
|
2506
|
+
const userId = ctx.selfSubId("id") || ctx.selfId;
|
|
2507
|
+
const name = ctx.selfName || ctx.cfg.name || "";
|
|
2508
|
+
if (contact.scene === "friend" || contact.scene === "direct") return karin$1.friendSender(userId, name);
|
|
2509
|
+
return karin$1.groupSender(userId, "member", name);
|
|
2510
|
+
};
|
|
2511
|
+
/**
|
|
2512
|
+
* QQ / 频道发送接口 timestamp 格式不同,统一转成毫秒时间戳。
|
|
2513
|
+
*/
|
|
2514
|
+
const responseTime = (timestamp) => {
|
|
2515
|
+
if (typeof timestamp === "string") {
|
|
2516
|
+
const time = new Date(timestamp).getTime();
|
|
2517
|
+
return Number.isFinite(time) ? time : Date.now();
|
|
2518
|
+
}
|
|
2519
|
+
if (typeof timestamp === "number") return timestamp < 0xe8d4a51000 ? timestamp * 1e3 : timestamp;
|
|
2520
|
+
return Date.now();
|
|
2521
|
+
};
|
|
1022
2522
|
|
|
1023
2523
|
//#endregion
|
|
1024
2524
|
//#region src/core/adapter/base.ts
|
|
2525
|
+
const adapterConfigStore = /* @__PURE__ */ new WeakMap();
|
|
1025
2526
|
/**
|
|
1026
2527
|
* QQ Official Bot 适配器
|
|
1027
2528
|
*
|
|
@@ -1031,19 +2532,27 @@ const buildPassiveGuild = (grouping) => {
|
|
|
1031
2532
|
var AdapterQQBot = class extends AdapterBase {
|
|
1032
2533
|
/** 与官方 API 交互 */
|
|
1033
2534
|
super;
|
|
1034
|
-
/** 当前 bot 配置 */
|
|
1035
|
-
cfg;
|
|
1036
2535
|
/** openId / member_openid -> 昵称 缓存 */
|
|
1037
2536
|
nicknameCache = /* @__PURE__ */ new Map();
|
|
1038
2537
|
constructor(cfg, api) {
|
|
1039
2538
|
super();
|
|
1040
|
-
this
|
|
2539
|
+
adapterConfigStore.set(this, cfg);
|
|
1041
2540
|
this.super = api;
|
|
1042
2541
|
this.adapter.name = "QQ Official Bot";
|
|
1043
2542
|
this.adapter.protocol = "qqbot";
|
|
1044
2543
|
this.adapter.platform = "qq";
|
|
1045
2544
|
this.adapter.standard = "other";
|
|
1046
2545
|
}
|
|
2546
|
+
/** 当前 bot 配置。真实配置存放在 WeakMap,避免被系统接口序列化整个适配器时带出。 */
|
|
2547
|
+
get cfg() {
|
|
2548
|
+
const cfg = adapterConfigStore.get(this);
|
|
2549
|
+
if (!cfg) throw new Error("QQBot adapter config missing");
|
|
2550
|
+
return cfg;
|
|
2551
|
+
}
|
|
2552
|
+
/** 接收消息的一天热缓存 + SQLite 缓存,用于实现 Karin 标准 `getMsg`。 */
|
|
2553
|
+
get messageStore() {
|
|
2554
|
+
return getMessageStore();
|
|
2555
|
+
}
|
|
1047
2556
|
logger(level, ...args) {
|
|
1048
2557
|
logger.bot(level, this.selfId, ...args);
|
|
1049
2558
|
}
|
|
@@ -1051,26 +2560,46 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
1051
2560
|
* 主消息回复入口(karin 调用)
|
|
1052
2561
|
*/
|
|
1053
2562
|
async srcReply(e, elements) {
|
|
1054
|
-
const extra =
|
|
2563
|
+
const extra = await buttonHandle(e.msg, { e });
|
|
1055
2564
|
return this.sendMsg(e.contact, [...elements, ...extra]);
|
|
1056
2565
|
}
|
|
1057
2566
|
/**
|
|
1058
2567
|
* 发送消息
|
|
1059
2568
|
*/
|
|
1060
2569
|
async sendMsg(contact, elements, _retryCount) {
|
|
1061
|
-
if (contact.scene === "direct" || contact.scene === "guild")
|
|
1062
|
-
|
|
2570
|
+
if (contact.scene === "direct" || contact.scene === "guild") {
|
|
2571
|
+
if (!shouldCacheSelfMessage(this)) return sendGuild(this, contact, elements);
|
|
2572
|
+
const prepared = await prepareSelfMessageCache(this, elements);
|
|
2573
|
+
const result = await sendGuild(this, contact, prepared.sendElements);
|
|
2574
|
+
cacheSelfMessage(this, contact, prepared.cacheElements, result);
|
|
2575
|
+
return result;
|
|
2576
|
+
}
|
|
2577
|
+
if (contact.scene === "group" || contact.scene === "friend") {
|
|
2578
|
+
if (!shouldCacheSelfMessage(this)) return sendQQ(this, contact, elements);
|
|
2579
|
+
const prepared = await prepareSelfMessageCache(this, elements);
|
|
2580
|
+
const result = await sendQQ(this, contact, prepared.sendElements);
|
|
2581
|
+
cacheSelfMessage(this, contact, prepared.cacheElements, result);
|
|
2582
|
+
return result;
|
|
2583
|
+
}
|
|
1063
2584
|
throw new Error(`不支持的消息场景: ${contact.scene}`);
|
|
1064
2585
|
}
|
|
1065
2586
|
/**
|
|
1066
2587
|
* 撤回消息
|
|
2588
|
+
*
|
|
2589
|
+
* 群聊中机器人被群主设为管理员后,也可撤回成员消息;平台仍限制消息发送后两分钟内。
|
|
1067
2590
|
*/
|
|
1068
2591
|
async recallMsg(contact, messageId) {
|
|
2592
|
+
const apiMessageId = resolveApiMessageId(this, contact, messageId);
|
|
1069
2593
|
try {
|
|
1070
|
-
if (contact.scene === "friend")
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
2594
|
+
if (contact.scene === "friend") {
|
|
2595
|
+
if (!isOwnMessageId(this, contact, apiMessageId)) {
|
|
2596
|
+
this.logger("warn", `[recallMsg] QQ 单聊只能撤回机器人自己发送的消息,已跳过撤回: ${messageId}`);
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
await this.super.messages.recall("user", contact.peer, apiMessageId);
|
|
2600
|
+
} else if (contact.scene === "group") await this.super.messages.recall("group", contact.peer, apiMessageId);
|
|
2601
|
+
else if (contact.scene === "direct") await this.super.messages.recall("dms", contact.peer, apiMessageId);
|
|
2602
|
+
else if (contact.scene === "guild") await this.super.messages.recall("channels", contact.peer, apiMessageId);
|
|
1074
2603
|
} catch (err) {
|
|
1075
2604
|
logger.error("撤回消息失败:", err);
|
|
1076
2605
|
}
|
|
@@ -1187,20 +2716,54 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
1187
2716
|
return `https://p.qlogo.cn/gh/${_groupId}/${_groupId}/${_size || 100}`;
|
|
1188
2717
|
}
|
|
1189
2718
|
/**
|
|
1190
|
-
*
|
|
2719
|
+
* 按消息 ID 获取已接收的消息。
|
|
2720
|
+
*
|
|
2721
|
+
* QQ 官方 Bot API 没有历史消息查询接口,因此仅从本地缓存读取。
|
|
2722
|
+
* 消息在接收时已转换为 Karin elements,最长保留一天;引用消息的 `REFIDX`
|
|
2723
|
+
* 索引同样可作为 `messageId` 查询。
|
|
2724
|
+
*
|
|
2725
|
+
* @param input OneBot v11 标准单参 messageId,或用于限定查询范围的 Contact。
|
|
2726
|
+
* @param messageId 当 input 是 Contact 时传入的目标消息 ID。
|
|
2727
|
+
* @returns 命中的消息;未命中时返回空消息结构。
|
|
1191
2728
|
*/
|
|
1192
|
-
async getMsg(
|
|
1193
|
-
|
|
2729
|
+
async getMsg(input, messageId) {
|
|
2730
|
+
/** OneBot v11 标准单参形式:bot.getMsg(messageId)。 */
|
|
2731
|
+
const targetId = typeof input === "string" ? input : messageId || "";
|
|
2732
|
+
const contact = typeof input === "string" ? void 0 : input;
|
|
2733
|
+
if (!this.cfg.messageCache.enable) {
|
|
2734
|
+
this.logger("debug", `[getMsg] 消息缓存已关闭: ${targetId || "(empty)"}`);
|
|
2735
|
+
return {
|
|
2736
|
+
time: 0,
|
|
2737
|
+
messageId: targetId,
|
|
2738
|
+
messageSeq: 0,
|
|
2739
|
+
contact: contact || {
|
|
2740
|
+
scene: "group",
|
|
2741
|
+
peer: "",
|
|
2742
|
+
subPeer: "",
|
|
2743
|
+
name: ""
|
|
2744
|
+
},
|
|
2745
|
+
sender: {
|
|
2746
|
+
userId: "",
|
|
2747
|
+
nick: "",
|
|
2748
|
+
name: "",
|
|
2749
|
+
role: "member"
|
|
2750
|
+
},
|
|
2751
|
+
elements: []
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
const cached = await this.messageStore.get(String(this.cfg.appId), targetId, contact);
|
|
2755
|
+
if (cached) return cached;
|
|
2756
|
+
this.logger("debug", `[getMsg] 本地一天消息缓存未命中: ${targetId || "(empty)"}`);
|
|
1194
2757
|
return {
|
|
1195
2758
|
time: 0,
|
|
1196
|
-
messageId:
|
|
2759
|
+
messageId: targetId,
|
|
1197
2760
|
messageSeq: 0,
|
|
1198
|
-
contact:
|
|
2761
|
+
contact: contact || {
|
|
1199
2762
|
scene: "group",
|
|
1200
|
-
peer:
|
|
2763
|
+
peer: "",
|
|
1201
2764
|
subPeer: "",
|
|
1202
2765
|
name: ""
|
|
1203
|
-
}
|
|
2766
|
+
},
|
|
1204
2767
|
sender: {
|
|
1205
2768
|
userId: "",
|
|
1206
2769
|
nick: "",
|
|
@@ -1211,9 +2774,18 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
1211
2774
|
};
|
|
1212
2775
|
}
|
|
1213
2776
|
/** @internal */
|
|
1214
|
-
async getHistoryMsg(
|
|
1215
|
-
|
|
1216
|
-
|
|
2777
|
+
async getHistoryMsg(contact, start, count = 1) {
|
|
2778
|
+
if (typeof start === "number") {
|
|
2779
|
+
this.logger("warn", "[getHistoryMsg] QQ Official Bot 仅支持通过 message_id 获取历史消息,不支持 message_seq");
|
|
2780
|
+
return [];
|
|
2781
|
+
}
|
|
2782
|
+
if (!this.cfg.messageCache.enable) {
|
|
2783
|
+
this.logger("warn", "[getHistoryMsg] 历史消息缓存为空,请打开“启用数据库缓存消息”开关后再记录历史消息");
|
|
2784
|
+
return [];
|
|
2785
|
+
}
|
|
2786
|
+
const history = await this.messageStore.getHistory(String(this.cfg.appId), contact, start, count);
|
|
2787
|
+
if (!history.length) this.logger("debug", `[getHistoryMsg] 本地一天历史消息缓存未命中: ${start || "(empty)"}`);
|
|
2788
|
+
return history;
|
|
1217
2789
|
}
|
|
1218
2790
|
/**
|
|
1219
2791
|
* 获取合并转发消息
|
|
@@ -1571,6 +3143,94 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
1571
3143
|
//#region src/core/event/conver.ts
|
|
1572
3144
|
/** 反转义 HTML 实体 */
|
|
1573
3145
|
const unescape = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
3146
|
+
/** 从 QQ `message_scene.ext` 中提取消息索引。 */
|
|
3147
|
+
const getMessageSceneIndex = (scene, key) => {
|
|
3148
|
+
const prefix = `${key}=`;
|
|
3149
|
+
return scene?.ext?.find((item) => item.startsWith(prefix))?.slice(prefix.length) || "";
|
|
3150
|
+
};
|
|
3151
|
+
/** 原生 Markdown 不能从 QQ 回显中完全区分,只识别适配器发送后可稳定还原的常用语法。 */
|
|
3152
|
+
const isNativeMarkdown = (text) => {
|
|
3153
|
+
return /(^|\n)\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s)|\*\*|__|~~|`{1,3}|\[[^\]]+\]\([^\s)]+\)|(^|\n)\s*\|.+\|\s*\n\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?/m.test(text);
|
|
3154
|
+
};
|
|
3155
|
+
/** 将非图片 Markdown 片段恢复为 text 或 markdown 元素。 */
|
|
3156
|
+
const pushMarkdownText = (text, elements) => {
|
|
3157
|
+
if (!text) return;
|
|
3158
|
+
elements.push(isNativeMarkdown(text) ? segment.markdown(text) : segment.text(text));
|
|
3159
|
+
};
|
|
3160
|
+
/**
|
|
3161
|
+
* 反向解析适配器发送的 QQ Markdown。
|
|
3162
|
+
*
|
|
3163
|
+
* 发送侧会将 Karin 图片编码为 ``,并以换行拼接文本、
|
|
3164
|
+
* 图片和原生 Markdown;这里按原顺序恢复为 Karin `text`、`image`、`markdown`。
|
|
3165
|
+
*/
|
|
3166
|
+
const pushQQMarkdown = (text, elements) => {
|
|
3167
|
+
const imageReg = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
3168
|
+
let lastIndex = 0;
|
|
3169
|
+
let matched = false;
|
|
3170
|
+
let match;
|
|
3171
|
+
while (match = imageReg.exec(text)) {
|
|
3172
|
+
matched = true;
|
|
3173
|
+
if (match.index > lastIndex) pushMarkdownText(text.slice(lastIndex, match.index), elements);
|
|
3174
|
+
const alt = match[1];
|
|
3175
|
+
const dimension = alt.match(/\s+#(\d+)px\s+#(\d+)px\s*$/);
|
|
3176
|
+
const name = alt.replace(/\s+#\d+px\s+#\d+px\s*$/, "") || "图片";
|
|
3177
|
+
elements.push(segment.image(match[2], {
|
|
3178
|
+
name,
|
|
3179
|
+
width: dimension ? Number(dimension[1]) : void 0,
|
|
3180
|
+
height: dimension ? Number(dimension[2]) : void 0
|
|
3181
|
+
}));
|
|
3182
|
+
lastIndex = imageReg.lastIndex;
|
|
3183
|
+
}
|
|
3184
|
+
if (matched && lastIndex < text.length) pushMarkdownText(text.slice(lastIndex), elements);
|
|
3185
|
+
return matched;
|
|
3186
|
+
};
|
|
3187
|
+
/**
|
|
3188
|
+
* 只拆 QQ 协议标签,保留普通文本中的 HTML/Markdown 片段。
|
|
3189
|
+
*
|
|
3190
|
+
* 普通文本或 Markdown 里可能包含 `<br>`、`<table>` 等内容;如果用 `[^<]+`
|
|
3191
|
+
* 直接切分,会把这些内容误当成协议标签边界,导致一条文本被拆成多段甚至丢失 `<`。
|
|
3192
|
+
*/
|
|
3193
|
+
const tokenizeContent = (content = "") => {
|
|
3194
|
+
const tokens = [];
|
|
3195
|
+
const knownTag = /<faceType=\d+,faceId="[^"]*",ext="[^"]*">|<qqbot-at-user id="[^"]+"\s*\/?>|<qqbot-at-everyone\s*\/?>|<@!?[A-Za-z0-9]+>/g;
|
|
3196
|
+
let lastIndex = 0;
|
|
3197
|
+
let match;
|
|
3198
|
+
while (match = knownTag.exec(content)) {
|
|
3199
|
+
if (match.index > lastIndex) tokens.push(content.slice(lastIndex, match.index));
|
|
3200
|
+
tokens.push(match[0]);
|
|
3201
|
+
lastIndex = knownTag.lastIndex;
|
|
3202
|
+
}
|
|
3203
|
+
if (lastIndex < content.length) tokens.push(content.slice(lastIndex));
|
|
3204
|
+
return tokens;
|
|
3205
|
+
};
|
|
3206
|
+
/**
|
|
3207
|
+
* 判断 QQ 回显内容是否应恢复成 Karin markdown 段。
|
|
3208
|
+
*
|
|
3209
|
+
* QQBot 自己收到自己发出的 markdown 时可能是 `message_type=103`;收到其他
|
|
3210
|
+
* QQBot 发出的 markdown 时,官方报文仍可能给 `message_type=0`,只能结合
|
|
3211
|
+
* `author.bot` 与内容语法做保守识别。
|
|
3212
|
+
*/
|
|
3213
|
+
const shouldUseMarkdown = (messageType, fromBot, text) => {
|
|
3214
|
+
return (messageType === 103 || fromBot) && isNativeMarkdown(text);
|
|
3215
|
+
};
|
|
3216
|
+
/**
|
|
3217
|
+
* 从 QQ 表情标签的 base64 ext 中提取可读文本。
|
|
3218
|
+
*
|
|
3219
|
+
* faceType=4 的表情包套图没有图片附件,只能通过 ext 中的 JSON 文本展示。
|
|
3220
|
+
*
|
|
3221
|
+
* @param ext QQ 表情标签里的 base64 字段。
|
|
3222
|
+
* @returns ext JSON 中的 text;解析失败时返回空字符串。
|
|
3223
|
+
*/
|
|
3224
|
+
const getFaceExtText = (ext) => {
|
|
3225
|
+
if (!ext) return "";
|
|
3226
|
+
try {
|
|
3227
|
+
const decoded = Buffer.from(ext, "base64").toString("utf-8");
|
|
3228
|
+
const payload = JSON.parse(decoded);
|
|
3229
|
+
return typeof payload.text === "string" ? payload.text : "";
|
|
3230
|
+
} catch {
|
|
3231
|
+
return "";
|
|
3232
|
+
}
|
|
3233
|
+
};
|
|
1574
3234
|
/**
|
|
1575
3235
|
* QQ Bot 报文 → karin elements
|
|
1576
3236
|
*
|
|
@@ -1582,17 +3242,39 @@ const unescape = (text) => text.replace(/&/g, "&").replace(/</g, "<").rep
|
|
|
1582
3242
|
const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
1583
3243
|
const elements = [];
|
|
1584
3244
|
const data = ev.d;
|
|
3245
|
+
const messageType = "message_type" in data ? data.message_type : void 0;
|
|
3246
|
+
const fromBot = !!data.author?.bot;
|
|
1585
3247
|
/** mentions 索引 id -> displayName */
|
|
1586
3248
|
const mentionMap = /* @__PURE__ */ new Map();
|
|
3249
|
+
const selfMentionIds = new Set([appid, selfSubId].filter(Boolean));
|
|
1587
3250
|
if (ev.t === "MESSAGE_CREATE" || ev.t === "AT_MESSAGE_CREATE") ev.d.mentions?.forEach((v) => {
|
|
1588
3251
|
mentionMap.set(v.id, v.username);
|
|
3252
|
+
if (v.id === selfSubId) selfMentionIds.add(v.id);
|
|
1589
3253
|
});
|
|
1590
3254
|
else if (ev.t === "GROUP_MESSAGE_CREATE" || ev.t === "GROUP_AT_MESSAGE_CREATE") ev.d.mentions?.forEach((v) => {
|
|
1591
3255
|
mentionMap.set(v.id, v.username);
|
|
1592
3256
|
mentionMap.set(v.member_openid, v.username);
|
|
3257
|
+
if (v.is_you) {
|
|
3258
|
+
selfMentionIds.add(v.id);
|
|
3259
|
+
selfMentionIds.add(v.member_openid);
|
|
3260
|
+
}
|
|
1593
3261
|
});
|
|
1594
|
-
|
|
1595
|
-
|
|
3262
|
+
const isSelfAt = (element) => element.type === "at" && selfMentionIds.has(element.targetId);
|
|
3263
|
+
const hasSelfAt = () => elements.some(isSelfAt);
|
|
3264
|
+
const pushAt = (id, name = "") => {
|
|
3265
|
+
const targetId = selfMentionIds.has(id) ? appid : id;
|
|
3266
|
+
if (targetId === appid && hasSelfAt()) {
|
|
3267
|
+
const prev = elements.at(-1);
|
|
3268
|
+
const beforePrev = elements.at(-2);
|
|
3269
|
+
if (prev?.type === "text" && !prev.text.trim() && beforePrev && isSelfAt(beforePrev)) elements.pop();
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
elements.push(segment.at(targetId, name));
|
|
3273
|
+
};
|
|
3274
|
+
/** 将 QQ 附件转换为 Karin 元素。 */
|
|
3275
|
+
const attachments = data.attachments || [];
|
|
3276
|
+
let attachmentIndex = 0;
|
|
3277
|
+
const pushAttachment = (v) => {
|
|
1596
3278
|
const url = v.url.startsWith("http") ? v.url : `https://${v.url}`;
|
|
1597
3279
|
if (v.content_type.startsWith("image/")) elements.push(segment.image(url, {
|
|
1598
3280
|
subType: v.content_type.split("/")[1],
|
|
@@ -1602,29 +3284,49 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
|
1602
3284
|
}));
|
|
1603
3285
|
else if (v.content_type === "video/mp4") elements.push(segment.video(url));
|
|
1604
3286
|
else if (v.content_type === "voice") elements.push(segment.record(v.voice_wav_url || url));
|
|
1605
|
-
else if (v.content_type === "file") elements.push(segment.
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
}))
|
|
1609
|
-
}
|
|
1610
|
-
|
|
3287
|
+
else if (v.content_type === "file") elements.push(segment.file(url, {
|
|
3288
|
+
name: v.filename,
|
|
3289
|
+
size: v.size
|
|
3290
|
+
}));
|
|
3291
|
+
};
|
|
3292
|
+
/** content 解析 */
|
|
3293
|
+
const tokens = tokenizeContent(data?.content);
|
|
1611
3294
|
const cfg = getConfig(appid);
|
|
1612
3295
|
for (const tok of tokens) {
|
|
1613
3296
|
if (tok.startsWith("<faceType=")) {
|
|
1614
|
-
const m = tok.match(/
|
|
1615
|
-
|
|
3297
|
+
const m = tok.match(/faceType=(\d+),faceId="([^"]*)",ext="([^"]*)"/);
|
|
3298
|
+
const faceType = Number(m?.[1]);
|
|
3299
|
+
const faceIdText = m?.[2] || "";
|
|
3300
|
+
const faceId = Number(faceIdText);
|
|
3301
|
+
const attachment = attachments[attachmentIndex];
|
|
3302
|
+
if (!faceIdText) {
|
|
3303
|
+
if (attachment?.content_type.startsWith("image/")) {
|
|
3304
|
+
pushAttachment(attachment);
|
|
3305
|
+
attachmentIndex++;
|
|
3306
|
+
} else {
|
|
3307
|
+
const text = getFaceExtText(m?.[3] || "");
|
|
3308
|
+
if (text) elements.push(segment.text(text));
|
|
3309
|
+
}
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3313
|
+
* QQ 将用户收藏表情图片编码为 `faceId=0` + 图片附件。附件在 content 中
|
|
3314
|
+
* 没有独立标签,必须在这个占位符位置消费,才能保留原始元素顺序。
|
|
3315
|
+
*/
|
|
3316
|
+
if (faceId === 0 && (messageType === 103 || faceType === 6) && attachment?.content_type.startsWith("image/")) {
|
|
3317
|
+
pushAttachment(attachment);
|
|
3318
|
+
attachmentIndex++;
|
|
3319
|
+
} else if (faceType === 6 && faceId === 0) {} else if (m) elements.push(segment.face(faceId, faceType === 3));
|
|
1616
3320
|
continue;
|
|
1617
3321
|
}
|
|
1618
|
-
if (tok.startsWith("
|
|
1619
|
-
const id = tok.replace(
|
|
1620
|
-
|
|
1621
|
-
elements.push(segment.at(id === selfSubId ? appid : id, name));
|
|
3322
|
+
if (tok.startsWith("<@")) {
|
|
3323
|
+
const id = tok.replace(/^<@!?|>$/g, "");
|
|
3324
|
+
pushAt(id, mentionMap.get(id) || "");
|
|
1622
3325
|
continue;
|
|
1623
3326
|
}
|
|
1624
3327
|
if (tok.startsWith("<qqbot-at-user")) {
|
|
1625
3328
|
const id = tok.match(/id="([^"]+)"/)?.[1] || "";
|
|
1626
|
-
|
|
1627
|
-
elements.push(segment.at(id === selfSubId ? appid : id, name));
|
|
3329
|
+
pushAt(id, mentionMap.get(id) || "");
|
|
1628
3330
|
continue;
|
|
1629
3331
|
}
|
|
1630
3332
|
if (tok.startsWith("<qqbot-at-everyone")) {
|
|
@@ -1634,10 +3336,18 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
|
1634
3336
|
let text = unescape(tok);
|
|
1635
3337
|
if (cfg?.regex) for (const r of cfg.regex) {
|
|
1636
3338
|
const reg = r.reg instanceof RegExp ? r.reg : new RegExp(r.reg);
|
|
1637
|
-
text = text.
|
|
3339
|
+
text = text.replace(reg, r.rep);
|
|
3340
|
+
}
|
|
3341
|
+
if (!text) continue;
|
|
3342
|
+
if (messageType === 103 && pushQQMarkdown(text, elements)) continue;
|
|
3343
|
+
if (shouldUseMarkdown(messageType, fromBot, text)) {
|
|
3344
|
+
elements.push(segment.markdown(text));
|
|
3345
|
+
continue;
|
|
1638
3346
|
}
|
|
1639
3347
|
elements.push(segment.text(text));
|
|
1640
3348
|
}
|
|
3349
|
+
/** content 中未出现占位符的普通附件,按 QQ 报文顺序追加。 */
|
|
3350
|
+
for (; attachmentIndex < attachments.length; attachmentIndex++) pushAttachment(attachments[attachmentIndex]);
|
|
1641
3351
|
/** 群消息是否需要在头部补 self at */
|
|
1642
3352
|
if (ev.t === "GROUP_AT_MESSAGE_CREATE" || ev.t === "GROUP_MESSAGE_CREATE") {
|
|
1643
3353
|
const youMention = (ev.d.mentions || []).find((m) => m.is_you);
|
|
@@ -1647,10 +3357,79 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
|
1647
3357
|
}
|
|
1648
3358
|
return elements;
|
|
1649
3359
|
};
|
|
3360
|
+
/**
|
|
3361
|
+
* 将 QQ 引用上下文转换为 Karin 元素。
|
|
3362
|
+
*
|
|
3363
|
+
* QQ 不会提供引用消息的正式消息 ID;调用方应以 `msg_idx` 作为缓存键。
|
|
3364
|
+
*/
|
|
3365
|
+
const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
|
|
3366
|
+
return convertToKarin(appid, {
|
|
3367
|
+
...ev,
|
|
3368
|
+
d: {
|
|
3369
|
+
...ev.d,
|
|
3370
|
+
content: reference.content,
|
|
3371
|
+
attachments: reference.attachments,
|
|
3372
|
+
mentions: void 0,
|
|
3373
|
+
message_scene: void 0,
|
|
3374
|
+
msg_elements: void 0,
|
|
3375
|
+
message_type: reference.message_type
|
|
3376
|
+
}
|
|
3377
|
+
}, selfSubId);
|
|
3378
|
+
};
|
|
1650
3379
|
|
|
1651
3380
|
//#endregion
|
|
1652
3381
|
//#region src/core/event/message.ts
|
|
1653
3382
|
/**
|
|
3383
|
+
* 写入 getMsg 热缓存并投递 SQLite 后台队列。
|
|
3384
|
+
*
|
|
3385
|
+
* 不等待数据库落库,避免缓存特性拖慢 Karin 消息事件下发。
|
|
3386
|
+
*
|
|
3387
|
+
* @param client 当前 QQBot 适配器实例。
|
|
3388
|
+
* @param message 需要缓存的消息快照。
|
|
3389
|
+
* @param aliases 可用于查询同一消息的 QQ 侧索引,例如 msg_idx。
|
|
3390
|
+
*/
|
|
3391
|
+
const cacheMessage = (client, message, aliases = []) => {
|
|
3392
|
+
if (!client.cfg.messageCache.enable) return;
|
|
3393
|
+
client.messageStore.save(String(client.cfg.appId), {
|
|
3394
|
+
...message,
|
|
3395
|
+
messageSeq: 0
|
|
3396
|
+
}, aliases).catch((err) => log("warn", `[getMsg] 写入消息缓存失败: ${message.messageId}`, err));
|
|
3397
|
+
};
|
|
3398
|
+
/**
|
|
3399
|
+
* 写入引用消息上下文缓存。
|
|
3400
|
+
*
|
|
3401
|
+
* 引用上下文没有正式消息 ID 时,以 QQ 的 `ref_msg_idx` 建立只写入一次的缓存。
|
|
3402
|
+
*
|
|
3403
|
+
* @param client 当前 QQBot 适配器实例。
|
|
3404
|
+
* @param message 以 ref_msg_idx 作为 messageId 的引用消息快照。
|
|
3405
|
+
*/
|
|
3406
|
+
const cacheReference = (client, message) => {
|
|
3407
|
+
if (!client.cfg.messageCache.enable) return;
|
|
3408
|
+
client.messageStore.saveReferenceIfAbsent(String(client.cfg.appId), {
|
|
3409
|
+
...message,
|
|
3410
|
+
messageSeq: 0
|
|
3411
|
+
}).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
|
|
3412
|
+
};
|
|
3413
|
+
/**
|
|
3414
|
+
* 将 Karin 快速回复自动生成的当前消息引用 ID 转成 QQ 客户端可见引用 ID。
|
|
3415
|
+
*
|
|
3416
|
+
* Karin 核心只能根据 `e.message_id` 生成 `segment.reply(e.message_id)`;而 QQ 群/单聊
|
|
3417
|
+
* 的可见引用需要 `message_scene.ext` 里的 `msg_idx=REFIDX_xxx`。这里仅转换
|
|
3418
|
+
* `reply.messageId === ev.d.id` 的那一个自动引用段,保留 `ev.d.id` 作为 Karin 标准
|
|
3419
|
+
* messageId,避免影响 getMsg / recallMsg / 被动回复 msg_id 等主链路。
|
|
3420
|
+
*/
|
|
3421
|
+
const normalizeQuickReplyReference = (elements, sourceMessageId, referenceMessageId) => {
|
|
3422
|
+
if (!sourceMessageId || !referenceMessageId) return elements;
|
|
3423
|
+
let normalized = false;
|
|
3424
|
+
return elements.map((element) => {
|
|
3425
|
+
if (!normalized && element.type === "reply" && element.messageId === sourceMessageId) {
|
|
3426
|
+
normalized = true;
|
|
3427
|
+
return segment.reply(referenceMessageId);
|
|
3428
|
+
}
|
|
3429
|
+
return element;
|
|
3430
|
+
});
|
|
3431
|
+
};
|
|
3432
|
+
/**
|
|
1654
3433
|
* 群消息(@机器人 / 普通消息合并入口)
|
|
1655
3434
|
*/
|
|
1656
3435
|
const onGroupMsg = (client, ev, opts) => {
|
|
@@ -1661,19 +3440,41 @@ const onGroupMsg = (client, ev, opts) => {
|
|
|
1661
3440
|
ev.d.mentions?.forEach((m) => {
|
|
1662
3441
|
if (m.member_openid && m.username) client.nicknameCache.set(m.member_openid, m.username);
|
|
1663
3442
|
});
|
|
1664
|
-
const contact = karin.contactGroup(ev.d.group_openid || ev.d.group_id);
|
|
1665
|
-
const sender = karin.groupSender(userId, "
|
|
3443
|
+
const contact = karin$1.contactGroup(ev.d.group_openid || ev.d.group_id);
|
|
3444
|
+
const sender = karin$1.groupSender(userId, ev.d.author.member_role || "unknown", username);
|
|
3445
|
+
const elements = convertToKarin(selfId, ev, client.selfSubId("id"), { forceAtSelf: opts.forceAtSelf });
|
|
3446
|
+
const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
|
|
3447
|
+
const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
|
|
3448
|
+
const karinMessageId = ev.d.id;
|
|
3449
|
+
if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
|
|
3450
|
+
if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
|
|
3451
|
+
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3452
|
+
cacheMessage(client, {
|
|
3453
|
+
messageId: karinMessageId,
|
|
3454
|
+
time,
|
|
3455
|
+
contact,
|
|
3456
|
+
sender,
|
|
3457
|
+
elements
|
|
3458
|
+
}, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
|
|
3459
|
+
const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
|
|
3460
|
+
if (reference && referenceIndex) cacheReference(client, {
|
|
3461
|
+
messageId: referenceIndex,
|
|
3462
|
+
time,
|
|
3463
|
+
contact,
|
|
3464
|
+
sender: karin$1.groupSender("", "member", reference.author?.username || ""),
|
|
3465
|
+
elements: convertReferenceToKarin(selfId, ev, client.selfSubId("id"), reference)
|
|
3466
|
+
});
|
|
1666
3467
|
const e = createGroupMessage({
|
|
1667
3468
|
bot: client,
|
|
1668
|
-
elements
|
|
3469
|
+
elements,
|
|
1669
3470
|
eventId: ev.id,
|
|
1670
|
-
messageId:
|
|
3471
|
+
messageId: karinMessageId,
|
|
1671
3472
|
messageSeq: 0,
|
|
1672
3473
|
rawEvent: ev,
|
|
1673
|
-
time
|
|
3474
|
+
time,
|
|
1674
3475
|
contact,
|
|
1675
3476
|
sender,
|
|
1676
|
-
srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
|
|
3477
|
+
srcReply: (elements) => client.srcReply(e, [...normalizeQuickReplyReference(elements, karinMessageId, messageIndex), segment.pasmsg(ev.d.id)])
|
|
1677
3478
|
});
|
|
1678
3479
|
return e;
|
|
1679
3480
|
};
|
|
@@ -1685,19 +3486,41 @@ const onFriendMsg = (client, ev) => {
|
|
|
1685
3486
|
const userId = ev.d.author.user_openid || ev.d.author.id;
|
|
1686
3487
|
const username = ev.d.author.username || "";
|
|
1687
3488
|
if (userId && username) client.nicknameCache.set(userId, username);
|
|
1688
|
-
const contact = karin.contactFriend(userId);
|
|
1689
|
-
const sender = karin.friendSender(userId, username);
|
|
3489
|
+
const contact = karin$1.contactFriend(userId);
|
|
3490
|
+
const sender = karin$1.friendSender(userId, username);
|
|
3491
|
+
const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
|
|
3492
|
+
const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
|
|
3493
|
+
const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
|
|
3494
|
+
const karinMessageId = ev.d.id;
|
|
3495
|
+
if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
|
|
3496
|
+
if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
|
|
3497
|
+
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3498
|
+
cacheMessage(client, {
|
|
3499
|
+
messageId: karinMessageId,
|
|
3500
|
+
time,
|
|
3501
|
+
contact,
|
|
3502
|
+
sender,
|
|
3503
|
+
elements
|
|
3504
|
+
}, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
|
|
3505
|
+
const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
|
|
3506
|
+
if (reference && referenceIndex) cacheReference(client, {
|
|
3507
|
+
messageId: referenceIndex,
|
|
3508
|
+
time,
|
|
3509
|
+
contact,
|
|
3510
|
+
sender: karin$1.friendSender("", reference.author?.username || ""),
|
|
3511
|
+
elements: convertReferenceToKarin(selfId, ev, client.selfSubId("id"), reference)
|
|
3512
|
+
});
|
|
1690
3513
|
const e = createFriendMessage({
|
|
1691
3514
|
bot: client,
|
|
1692
|
-
elements
|
|
3515
|
+
elements,
|
|
1693
3516
|
eventId: ev.id,
|
|
1694
|
-
messageId:
|
|
3517
|
+
messageId: karinMessageId,
|
|
1695
3518
|
messageSeq: 0,
|
|
1696
3519
|
rawEvent: ev,
|
|
1697
|
-
time
|
|
3520
|
+
time,
|
|
1698
3521
|
contact,
|
|
1699
3522
|
sender,
|
|
1700
|
-
srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
|
|
3523
|
+
srcReply: (elements) => client.srcReply(e, [...normalizeQuickReplyReference(elements, karinMessageId, messageIndex), segment.pasmsg(ev.d.id)])
|
|
1701
3524
|
});
|
|
1702
3525
|
return e;
|
|
1703
3526
|
};
|
|
@@ -1707,16 +3530,25 @@ const onFriendMsg = (client, ev) => {
|
|
|
1707
3530
|
const onChannelMsg = (client, ev) => {
|
|
1708
3531
|
const selfId = client.selfId;
|
|
1709
3532
|
const userId = ev.d.author.id;
|
|
1710
|
-
const contact = karin.contact("guild", ev.d.guild_id, ev.d.channel_id);
|
|
1711
|
-
const sender = karin.groupSender(userId, "unknown", ev.d.author.username || "");
|
|
3533
|
+
const contact = karin$1.contact("guild", ev.d.guild_id, ev.d.channel_id);
|
|
3534
|
+
const sender = karin$1.groupSender(userId, "unknown", ev.d.author.username || "");
|
|
3535
|
+
const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
|
|
3536
|
+
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3537
|
+
cacheMessage(client, {
|
|
3538
|
+
messageId: ev.d.id,
|
|
3539
|
+
time,
|
|
3540
|
+
contact,
|
|
3541
|
+
sender,
|
|
3542
|
+
elements
|
|
3543
|
+
});
|
|
1712
3544
|
const e = createGuildMessage({
|
|
1713
3545
|
bot: client,
|
|
1714
|
-
elements
|
|
3546
|
+
elements,
|
|
1715
3547
|
eventId: ev.id,
|
|
1716
3548
|
messageId: ev.d.id,
|
|
1717
3549
|
messageSeq: 0,
|
|
1718
3550
|
rawEvent: ev,
|
|
1719
|
-
time
|
|
3551
|
+
time,
|
|
1720
3552
|
contact,
|
|
1721
3553
|
sender,
|
|
1722
3554
|
srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
|
|
@@ -1729,16 +3561,25 @@ const onChannelMsg = (client, ev) => {
|
|
|
1729
3561
|
const onDirectMsg = (client, ev) => {
|
|
1730
3562
|
const selfId = client.selfId;
|
|
1731
3563
|
const userId = ev.d.author.id;
|
|
1732
|
-
const contact = karin.contact("direct", ev.d.guild_id, ev.d.channel_id);
|
|
1733
|
-
const sender = karin.friendSender(userId, ev.d.author.username || "");
|
|
3564
|
+
const contact = karin$1.contact("direct", ev.d.guild_id, ev.d.channel_id);
|
|
3565
|
+
const sender = karin$1.friendSender(userId, ev.d.author.username || "");
|
|
3566
|
+
const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
|
|
3567
|
+
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3568
|
+
cacheMessage(client, {
|
|
3569
|
+
messageId: ev.d.id,
|
|
3570
|
+
time,
|
|
3571
|
+
contact,
|
|
3572
|
+
sender,
|
|
3573
|
+
elements
|
|
3574
|
+
});
|
|
1734
3575
|
const e = createDirectMessage({
|
|
1735
3576
|
bot: client,
|
|
1736
|
-
elements
|
|
3577
|
+
elements,
|
|
1737
3578
|
eventId: ev.id,
|
|
1738
3579
|
messageId: ev.d.id,
|
|
1739
3580
|
messageSeq: 0,
|
|
1740
3581
|
rawEvent: ev,
|
|
1741
|
-
time
|
|
3582
|
+
time,
|
|
1742
3583
|
contact,
|
|
1743
3584
|
sender,
|
|
1744
3585
|
srcGuildId: ev.d.src_guild_id,
|
|
@@ -1758,8 +3599,8 @@ const onGroupAddRobot = (client, event) => {
|
|
|
1758
3599
|
const selfId = client.selfId;
|
|
1759
3600
|
const userId = event.d.op_member_openid;
|
|
1760
3601
|
const groupId = event.d.group_openid;
|
|
1761
|
-
const contact = karin.contactGroup(groupId);
|
|
1762
|
-
const sender = karin.groupSender(
|
|
3602
|
+
const contact = karin$1.contactGroup(groupId);
|
|
3603
|
+
const sender = karin$1.groupSender(selfId, "member");
|
|
1763
3604
|
const eventId = event.id;
|
|
1764
3605
|
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
1765
3606
|
createGroupMemberAddNotice({
|
|
@@ -1786,8 +3627,8 @@ const onGroupDelRobot = (client, event) => {
|
|
|
1786
3627
|
const selfId = client.selfId;
|
|
1787
3628
|
const userId = event.d.op_member_openid;
|
|
1788
3629
|
const groupId = event.d.group_openid;
|
|
1789
|
-
const contact = karin.contactGroup(groupId);
|
|
1790
|
-
const sender = karin.groupSender(
|
|
3630
|
+
const contact = karin$1.contactGroup(groupId);
|
|
3631
|
+
const sender = karin$1.groupSender(selfId, "unknown");
|
|
1791
3632
|
const eventId = event.id;
|
|
1792
3633
|
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
1793
3634
|
createGroupMemberDelNotice({
|
|
@@ -1806,6 +3647,64 @@ const onGroupDelRobot = (client, event) => {
|
|
|
1806
3647
|
});
|
|
1807
3648
|
};
|
|
1808
3649
|
/**
|
|
3650
|
+
* 群成员加入事件。
|
|
3651
|
+
*
|
|
3652
|
+
* QQ 官方仅下发加入成员,未提供操作人和加入方式;Karin 的通知模型要求
|
|
3653
|
+
* `type`,因此以 `approve` 表示已加入,并将 `operatorId` 留空。
|
|
3654
|
+
* 该事件不能作为 QQ 被动消息的 `event_id`,回复会按普通消息发送。
|
|
3655
|
+
*
|
|
3656
|
+
* @param client 机器人实例
|
|
3657
|
+
* @param event 官方群成员加入事件
|
|
3658
|
+
*/
|
|
3659
|
+
const onGroupMemberAdd = (client, event) => {
|
|
3660
|
+
const memberId = event.d.member_openid;
|
|
3661
|
+
const contact = karin$1.contactGroup(event.d.group_openid);
|
|
3662
|
+
const sender = karin$1.groupSender(memberId, "member");
|
|
3663
|
+
createGroupMemberAddNotice({
|
|
3664
|
+
contact,
|
|
3665
|
+
eventId: event.id,
|
|
3666
|
+
sender,
|
|
3667
|
+
srcReply: (elements) => client.sendMsg(contact, elements),
|
|
3668
|
+
bot: client,
|
|
3669
|
+
rawEvent: event,
|
|
3670
|
+
time: event.d.timestamp,
|
|
3671
|
+
content: {
|
|
3672
|
+
operatorId: "",
|
|
3673
|
+
targetId: memberId,
|
|
3674
|
+
type: "approve"
|
|
3675
|
+
}
|
|
3676
|
+
});
|
|
3677
|
+
};
|
|
3678
|
+
/**
|
|
3679
|
+
* 群成员退出事件。
|
|
3680
|
+
*
|
|
3681
|
+
* QQ 官方仅下发退出成员,未提供操作人和退出原因;Karin 的通知模型要求
|
|
3682
|
+
* `type`,因此以 `leave` 表示成员已离开,并将 `operatorId` 留空。
|
|
3683
|
+
* 该事件不能作为 QQ 被动消息的 `event_id`,回复会按普通消息发送。
|
|
3684
|
+
*
|
|
3685
|
+
* @param client 机器人实例
|
|
3686
|
+
* @param event 官方群成员退出事件
|
|
3687
|
+
*/
|
|
3688
|
+
const onGroupMemberRemove = (client, event) => {
|
|
3689
|
+
const memberId = event.d.member_openid;
|
|
3690
|
+
const contact = karin$1.contactGroup(event.d.group_openid);
|
|
3691
|
+
const sender = karin$1.groupSender(memberId, "unknown");
|
|
3692
|
+
createGroupMemberDelNotice({
|
|
3693
|
+
contact,
|
|
3694
|
+
eventId: event.id,
|
|
3695
|
+
sender,
|
|
3696
|
+
srcReply: (elements) => client.sendMsg(contact, elements),
|
|
3697
|
+
bot: client,
|
|
3698
|
+
rawEvent: event,
|
|
3699
|
+
time: event.d.timestamp,
|
|
3700
|
+
content: {
|
|
3701
|
+
operatorId: "",
|
|
3702
|
+
targetId: memberId,
|
|
3703
|
+
type: "leave"
|
|
3704
|
+
}
|
|
3705
|
+
});
|
|
3706
|
+
};
|
|
3707
|
+
/**
|
|
1809
3708
|
* 群聊接受机器人主动消息
|
|
1810
3709
|
* @param client 机器人实例
|
|
1811
3710
|
* @param event 事件
|
|
@@ -1833,8 +3732,8 @@ const onGroupMsgReject = (client, event) => {
|
|
|
1833
3732
|
const onFriendAdd = (client, event) => {
|
|
1834
3733
|
const selfId = client.selfId;
|
|
1835
3734
|
const userId = event.d.openid;
|
|
1836
|
-
const contact = karin.contactFriend(userId);
|
|
1837
|
-
const sender = karin.friendSender(userId, "");
|
|
3735
|
+
const contact = karin$1.contactFriend(userId);
|
|
3736
|
+
const sender = karin$1.friendSender(userId, "");
|
|
1838
3737
|
const eventId = event.id;
|
|
1839
3738
|
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
1840
3739
|
createFriendIncreaseNotice({
|
|
@@ -1856,8 +3755,8 @@ const onFriendAdd = (client, event) => {
|
|
|
1856
3755
|
const onFriendDel = (client, event) => {
|
|
1857
3756
|
const selfId = client.selfId;
|
|
1858
3757
|
const userId = event.d.openid;
|
|
1859
|
-
const contact = karin.contactFriend(userId);
|
|
1860
|
-
const sender = karin.friendSender(userId, "");
|
|
3758
|
+
const contact = karin$1.contactFriend(userId);
|
|
3759
|
+
const sender = karin$1.friendSender(userId, "");
|
|
1861
3760
|
const eventId = event.id;
|
|
1862
3761
|
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
1863
3762
|
createFriendDecreaseNotice({
|
|
@@ -1893,46 +3792,103 @@ const onC2CMsgReject = (client, event) => {
|
|
|
1893
3792
|
//#endregion
|
|
1894
3793
|
//#region src/core/event/interaction.ts
|
|
1895
3794
|
/**
|
|
3795
|
+
* 取得互动事件真正用于 ACK 的平台 ID。
|
|
3796
|
+
*
|
|
3797
|
+
* WebSocket Dispatch 外层也有一个 `id`,形如 `INTERACTION_CREATE:uuid`;按钮 ACK
|
|
3798
|
+
* 接口需要的是事件体内的 `d.id`,也就是不带事件名前缀的 uuid。
|
|
3799
|
+
*
|
|
3800
|
+
* @param ev QQBot 原始互动事件。
|
|
3801
|
+
* @returns 可传给 `/interactions/{interaction_id}` 的 ID。
|
|
3802
|
+
*/
|
|
3803
|
+
const getInteractionId = (ev) => ev.d.id || ev.id;
|
|
3804
|
+
/**
|
|
3805
|
+
* 构造按钮回调附加给 Karin 消息的元信息。
|
|
3806
|
+
*
|
|
3807
|
+
* QQBot 不同场景下下发字段差异很大:群聊通常只有 button_data/button_id,
|
|
3808
|
+
* 频道才可能带 user_id/message_id/feature_id。这里只写入实际存在的字段,
|
|
3809
|
+
* 避免插件侧看到一堆语义不明的空值。
|
|
3810
|
+
*
|
|
3811
|
+
* @param ev QQBot 原始互动事件。
|
|
3812
|
+
* @param interactionId 已确认可用于 ACK 的平台互动 ID。
|
|
3813
|
+
* @returns 可放入 segment.json 的精简元信息。
|
|
3814
|
+
*/
|
|
3815
|
+
const createInteractionMeta = (ev, interactionId) => {
|
|
3816
|
+
const resolved = ev.d.data?.resolved;
|
|
3817
|
+
const meta = {
|
|
3818
|
+
tag: "qqbot-button-click",
|
|
3819
|
+
interaction_id: interactionId,
|
|
3820
|
+
dispatch_id: ev.id,
|
|
3821
|
+
interaction_type: ev.d.type
|
|
3822
|
+
};
|
|
3823
|
+
const optional = {
|
|
3824
|
+
data_type: ev.d.data?.type,
|
|
3825
|
+
chat_type: ev.d.chat_type,
|
|
3826
|
+
scene: ev.d.scene,
|
|
3827
|
+
button_id: resolved?.button_id,
|
|
3828
|
+
button_data: resolved?.button_data,
|
|
3829
|
+
application_id: ev.d.application_id,
|
|
3830
|
+
group_openid: ev.d.group_openid,
|
|
3831
|
+
group_member_openid: ev.d.group_member_openid,
|
|
3832
|
+
user_openid: ev.d.user_openid,
|
|
3833
|
+
guild_id: ev.d.guild_id,
|
|
3834
|
+
channel_id: ev.d.channel_id,
|
|
3835
|
+
guild_user_id: resolved?.user_id,
|
|
3836
|
+
feature_id: resolved?.feature_id,
|
|
3837
|
+
message_id: resolved?.message_id,
|
|
3838
|
+
require_mention: resolved?.require_mention,
|
|
3839
|
+
group_policy: resolved?.group_policy,
|
|
3840
|
+
mention_patterns: resolved?.mention_patterns,
|
|
3841
|
+
version: ev.d.version
|
|
3842
|
+
};
|
|
3843
|
+
for (const [key, value] of Object.entries(optional)) if (value !== void 0 && value !== "") meta[key] = value;
|
|
3844
|
+
return meta;
|
|
3845
|
+
};
|
|
3846
|
+
/**
|
|
1896
3847
|
* INTERACTION_CREATE 处理
|
|
1897
3848
|
*
|
|
1898
3849
|
* - 先 ack 客户端(必须在 3s 内),失败仅 warn
|
|
1899
3850
|
* - 借用普通消息事件投递给业务层,业务层通过 e.rawEvent.t === 'INTERACTION_CREATE' 区分
|
|
1900
|
-
* - elements
|
|
3851
|
+
* - elements 只放 button_data 文本,避免 at/json 段污染 e.msg 导致 command 无法精确匹配
|
|
1901
3852
|
*/
|
|
1902
3853
|
const onInteraction = (client, ev) => {
|
|
1903
|
-
|
|
3854
|
+
const interactionId = getInteractionId(ev);
|
|
3855
|
+
const ackStart = Date.now();
|
|
3856
|
+
client.logger("debug", `[INTERACTION] ack 准备: ${interactionId} dispatch=${ev.id}`);
|
|
3857
|
+
client.super.interaction.ack(interactionId, 0).then(() => {
|
|
3858
|
+
client.logger("debug", `[INTERACTION] ack 成功: ${interactionId} (${Date.now() - ackStart}ms)`);
|
|
3859
|
+
}).catch((err) => {
|
|
1904
3860
|
client.logger("warn", "[INTERACTION] ack 失败:", err);
|
|
3861
|
+
}).finally(() => {
|
|
3862
|
+
emitInteractionMessage(client, ev, interactionId);
|
|
1905
3863
|
});
|
|
1906
|
-
|
|
3864
|
+
};
|
|
3865
|
+
/**
|
|
3866
|
+
* 将按钮互动事件转换为 Karin 消息事件。
|
|
3867
|
+
*
|
|
3868
|
+
* ACK 请求先发出并结束后再投递业务层,避免业务回复请求抢在 ACK 前占用回调窗口。
|
|
3869
|
+
*
|
|
3870
|
+
* @param client 当前 QQBot 适配器实例。
|
|
3871
|
+
* @param ev QQBot 原始互动事件。
|
|
3872
|
+
* @param interactionId 已确认可用于 ACK 的平台互动 ID。
|
|
3873
|
+
*/
|
|
3874
|
+
const emitInteractionMessage = (client, ev, interactionId) => {
|
|
1907
3875
|
const buttonData = ev.d.data?.resolved?.button_data || "";
|
|
1908
|
-
const
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
button_id: ev.d.data?.resolved?.button_id,
|
|
1914
|
-
button_data: ev.d.data?.resolved?.button_data,
|
|
1915
|
-
feature_id: ev.d.data?.resolved?.feature_id,
|
|
1916
|
-
message_id: ev.d.data?.resolved?.message_id,
|
|
1917
|
-
user_id: ev.d.data?.resolved?.user_id,
|
|
1918
|
-
version: ev.d.version
|
|
1919
|
-
}));
|
|
1920
|
-
const time = Number(new Date(ev.d.timestamp).getTime() || Date.now());
|
|
3876
|
+
const rawEvent = {
|
|
3877
|
+
...ev,
|
|
3878
|
+
buttonMeta: createInteractionMeta(ev, interactionId)
|
|
3879
|
+
};
|
|
3880
|
+
const time = Number(ev.d.timestamp ? new Date(ev.d.timestamp).getTime() : Date.now()) || Date.now();
|
|
1921
3881
|
if (ev.d.chat_type === 1 && ev.d.group_openid) {
|
|
1922
3882
|
const userId = ev.d.group_member_openid || "";
|
|
1923
|
-
const contact = karin.contactGroup(ev.d.group_openid);
|
|
1924
|
-
const sender = karin.groupSender(userId, "unknown");
|
|
3883
|
+
const contact = karin$1.contactGroup(ev.d.group_openid);
|
|
3884
|
+
const sender = karin$1.groupSender(userId, "unknown");
|
|
1925
3885
|
const e = createGroupMessage({
|
|
1926
3886
|
bot: client,
|
|
1927
|
-
elements: [
|
|
1928
|
-
segment.at(selfId),
|
|
1929
|
-
segment.text(buttonData),
|
|
1930
|
-
meta
|
|
1931
|
-
],
|
|
3887
|
+
elements: [segment.text(buttonData)],
|
|
1932
3888
|
eventId: ev.id,
|
|
1933
|
-
messageId:
|
|
3889
|
+
messageId: interactionId,
|
|
1934
3890
|
messageSeq: 0,
|
|
1935
|
-
rawEvent
|
|
3891
|
+
rawEvent,
|
|
1936
3892
|
time,
|
|
1937
3893
|
contact,
|
|
1938
3894
|
sender,
|
|
@@ -1942,19 +3898,15 @@ const onInteraction = (client, ev) => {
|
|
|
1942
3898
|
}
|
|
1943
3899
|
if (ev.d.chat_type === 2 && ev.d.user_openid) {
|
|
1944
3900
|
const userId = ev.d.user_openid;
|
|
1945
|
-
const contact = karin.contactFriend(userId);
|
|
1946
|
-
const sender = karin.friendSender(userId, "");
|
|
3901
|
+
const contact = karin$1.contactFriend(userId);
|
|
3902
|
+
const sender = karin$1.friendSender(userId, "");
|
|
1947
3903
|
const e = createFriendMessage({
|
|
1948
3904
|
bot: client,
|
|
1949
|
-
elements: [
|
|
1950
|
-
segment.at(selfId),
|
|
1951
|
-
segment.text(buttonData),
|
|
1952
|
-
meta
|
|
1953
|
-
],
|
|
3905
|
+
elements: [segment.text(buttonData)],
|
|
1954
3906
|
eventId: ev.id,
|
|
1955
|
-
messageId:
|
|
3907
|
+
messageId: interactionId,
|
|
1956
3908
|
messageSeq: 0,
|
|
1957
|
-
rawEvent
|
|
3909
|
+
rawEvent,
|
|
1958
3910
|
time,
|
|
1959
3911
|
contact,
|
|
1960
3912
|
sender,
|
|
@@ -1964,15 +3916,15 @@ const onInteraction = (client, ev) => {
|
|
|
1964
3916
|
}
|
|
1965
3917
|
if (ev.d.chat_type === 0 && ev.d.guild_id && ev.d.channel_id) {
|
|
1966
3918
|
const userId = ev.d.data?.resolved?.user_id || "";
|
|
1967
|
-
const contact = karin.contact("guild", ev.d.guild_id, ev.d.channel_id);
|
|
1968
|
-
const sender = karin.groupSender(userId, "unknown");
|
|
3919
|
+
const contact = karin$1.contact("guild", ev.d.guild_id, ev.d.channel_id);
|
|
3920
|
+
const sender = karin$1.groupSender(userId, "unknown");
|
|
1969
3921
|
const e = createGuildMessage({
|
|
1970
3922
|
bot: client,
|
|
1971
|
-
elements: [segment.text(buttonData)
|
|
3923
|
+
elements: [segment.text(buttonData)],
|
|
1972
3924
|
eventId: ev.id,
|
|
1973
|
-
messageId:
|
|
3925
|
+
messageId: interactionId,
|
|
1974
3926
|
messageSeq: 0,
|
|
1975
|
-
rawEvent
|
|
3927
|
+
rawEvent,
|
|
1976
3928
|
time,
|
|
1977
3929
|
contact,
|
|
1978
3930
|
sender,
|
|
@@ -1997,6 +3949,8 @@ const eventLabel = {
|
|
|
1997
3949
|
["DIRECT_MESSAGE_CREATE"]: "频道私信",
|
|
1998
3950
|
["GROUP_ADD_ROBOT"]: "机器人入群",
|
|
1999
3951
|
["GROUP_DEL_ROBOT"]: "机器人退群",
|
|
3952
|
+
["GROUP_MEMBER_ADD"]: "群成员加入",
|
|
3953
|
+
["GROUP_MEMBER_REMOVE"]: "群成员退出",
|
|
2000
3954
|
["GROUP_MSG_RECEIVE"]: "群聊开启通知",
|
|
2001
3955
|
["GROUP_MSG_REJECT"]: "群聊关闭通知",
|
|
2002
3956
|
["FRIEND_ADD"]: "添加好友",
|
|
@@ -2039,6 +3993,12 @@ const dispatch$1 = (client, ev) => {
|
|
|
2039
3993
|
case "GROUP_DEL_ROBOT":
|
|
2040
3994
|
onGroupDelRobot(client, ev);
|
|
2041
3995
|
return;
|
|
3996
|
+
case "GROUP_MEMBER_ADD":
|
|
3997
|
+
onGroupMemberAdd(client, ev);
|
|
3998
|
+
return;
|
|
3999
|
+
case "GROUP_MEMBER_REMOVE":
|
|
4000
|
+
onGroupMemberRemove(client, ev);
|
|
4001
|
+
return;
|
|
2042
4002
|
case "GROUP_MSG_RECEIVE":
|
|
2043
4003
|
onGroupMsgReceive(client, ev);
|
|
2044
4004
|
return;
|
|
@@ -2062,7 +4022,7 @@ const dispatch$1 = (client, ev) => {
|
|
|
2062
4022
|
return;
|
|
2063
4023
|
case "MESSAGE_AUDIT_PASS": return client.logger("debug", `消息审核通过: audit_id=${ev.d.audit_id}`);
|
|
2064
4024
|
case "MESSAGE_AUDIT_REJECT": return client.logger("debug", `消息审核不通过: audit_id=${ev.d.audit_id}`);
|
|
2065
|
-
default: logger
|
|
4025
|
+
default: client.logger("debug", `[QQ Official Bot] 未处理事件: ${ev.t}`);
|
|
2066
4026
|
}
|
|
2067
4027
|
};
|
|
2068
4028
|
|
|
@@ -2105,6 +4065,11 @@ const wsErrorMap = {
|
|
|
2105
4065
|
canResume: false,
|
|
2106
4066
|
canIdentify: false
|
|
2107
4067
|
},
|
|
4068
|
+
4004: {
|
|
4069
|
+
message: "鉴权失败或 access_token 无效",
|
|
4070
|
+
canResume: false,
|
|
4071
|
+
canIdentify: true
|
|
4072
|
+
},
|
|
2108
4073
|
4007: {
|
|
2109
4074
|
message: "seq 错误",
|
|
2110
4075
|
canResume: false,
|
|
@@ -2215,6 +4180,8 @@ var WSClient = class {
|
|
|
2215
4180
|
seq = 0;
|
|
2216
4181
|
closed = false;
|
|
2217
4182
|
closedReason = "manual";
|
|
4183
|
+
openedAt = 0;
|
|
4184
|
+
lastAuthAction;
|
|
2218
4185
|
constructor(opts) {
|
|
2219
4186
|
this.opts = opts;
|
|
2220
4187
|
if (opts.resume) {
|
|
@@ -2230,21 +4197,30 @@ var WSClient = class {
|
|
|
2230
4197
|
};
|
|
2231
4198
|
}
|
|
2232
4199
|
start() {
|
|
2233
|
-
const ws = new WebSocket(this.opts.gatewayUrl);
|
|
4200
|
+
const ws = new WebSocket(this.opts.gatewayUrl, { headers: { "User-Agent": this.opts.userAgent } });
|
|
2234
4201
|
this.socket = ws;
|
|
2235
4202
|
ws.on("open", () => {
|
|
4203
|
+
this.openedAt = Date.now();
|
|
2236
4204
|
log("debug", `${this.opts.appId}: WebSocket opened (${this.opts.gatewayUrl})`);
|
|
2237
4205
|
});
|
|
2238
4206
|
ws.on("close", (code, reason) => {
|
|
2239
|
-
const
|
|
4207
|
+
const remoteReason = reason?.toString() || "";
|
|
4208
|
+
const closeInfo = formatWSClose(code, remoteReason);
|
|
4209
|
+
const info = getWSErrorInfo(code);
|
|
2240
4210
|
log("error", `${this.opts.appId}: WebSocket 关闭 | ${closeInfo}`);
|
|
2241
4211
|
if (this.closed) return;
|
|
2242
|
-
this.close("closed"
|
|
4212
|
+
this.close("closed", {
|
|
4213
|
+
code,
|
|
4214
|
+
remoteReason: remoteReason || void 0,
|
|
4215
|
+
durationMs: this.durationMs(),
|
|
4216
|
+
canResume: info?.canResume,
|
|
4217
|
+
canIdentify: info?.canIdentify
|
|
4218
|
+
});
|
|
2243
4219
|
});
|
|
2244
4220
|
ws.on("error", (err) => {
|
|
2245
4221
|
log("error", `${this.opts.appId}: WebSocket error:`, err);
|
|
2246
4222
|
if (this.closed) return;
|
|
2247
|
-
this.close("error");
|
|
4223
|
+
this.close("error", { durationMs: this.durationMs() });
|
|
2248
4224
|
});
|
|
2249
4225
|
ws.on("unexpected-response", (_req, res) => {
|
|
2250
4226
|
log("error", `${this.opts.appId}: WebSocket unexpected-response status=${res.statusCode} ${res.statusMessage}`);
|
|
@@ -2257,7 +4233,7 @@ var WSClient = class {
|
|
|
2257
4233
|
stop() {
|
|
2258
4234
|
this.close("manual");
|
|
2259
4235
|
}
|
|
2260
|
-
close(reason) {
|
|
4236
|
+
close(reason, event = {}) {
|
|
2261
4237
|
if (this.closed) return;
|
|
2262
4238
|
this.closed = true;
|
|
2263
4239
|
this.closedReason = reason;
|
|
@@ -2269,7 +4245,13 @@ var WSClient = class {
|
|
|
2269
4245
|
this.socket?.removeAllListeners();
|
|
2270
4246
|
this.socket?.close();
|
|
2271
4247
|
} catch {}
|
|
2272
|
-
this.opts.onClose(
|
|
4248
|
+
this.opts.onClose({
|
|
4249
|
+
reason,
|
|
4250
|
+
...event
|
|
4251
|
+
});
|
|
4252
|
+
}
|
|
4253
|
+
durationMs() {
|
|
4254
|
+
return this.openedAt ? Date.now() - this.openedAt : void 0;
|
|
2273
4255
|
}
|
|
2274
4256
|
send(payload) {
|
|
2275
4257
|
try {
|
|
@@ -2289,6 +4271,7 @@ var WSClient = class {
|
|
|
2289
4271
|
}, this.heartbeatInterval);
|
|
2290
4272
|
}
|
|
2291
4273
|
identify() {
|
|
4274
|
+
this.lastAuthAction = "identify";
|
|
2292
4275
|
this.send({
|
|
2293
4276
|
op: 2,
|
|
2294
4277
|
d: {
|
|
@@ -2305,6 +4288,7 @@ var WSClient = class {
|
|
|
2305
4288
|
log("debug", `${this.opts.appId}: Identify sent, intents=${this.opts.intents}`);
|
|
2306
4289
|
}
|
|
2307
4290
|
resume() {
|
|
4291
|
+
this.lastAuthAction = "resume";
|
|
2308
4292
|
this.send({
|
|
2309
4293
|
op: 6,
|
|
2310
4294
|
d: {
|
|
@@ -2339,6 +4323,7 @@ var WSClient = class {
|
|
|
2339
4323
|
case 0:
|
|
2340
4324
|
if (typeof msg.s === "number") this.seq = msg.s;
|
|
2341
4325
|
if (msg.t === "READY") {
|
|
4326
|
+
this.lastAuthAction = void 0;
|
|
2342
4327
|
this.sessionId = msg.d?.session_id || "";
|
|
2343
4328
|
log("debug", `${this.opts.appId}: READY received, session_id=${this.sessionId}`);
|
|
2344
4329
|
}
|
|
@@ -2352,17 +4337,31 @@ var WSClient = class {
|
|
|
2352
4337
|
log("debug", `${this.opts.appId}: server asked reconnect (op:7)`);
|
|
2353
4338
|
this.close("reconnect");
|
|
2354
4339
|
break;
|
|
2355
|
-
case 9:
|
|
2356
|
-
|
|
2357
|
-
|
|
4340
|
+
case 9: {
|
|
4341
|
+
const canResume = msg.d === true;
|
|
4342
|
+
if (this.lastAuthAction === "identify") {
|
|
4343
|
+
log("error", `${this.opts.appId}: identify rejected (op:9), will try fallback intents, canResume=${canResume}`);
|
|
4344
|
+
this.close("auth_fail", {
|
|
4345
|
+
durationMs: this.durationMs(),
|
|
4346
|
+
invalidSessionCanResume: canResume
|
|
4347
|
+
});
|
|
4348
|
+
} else if (canResume && this.sessionId) {
|
|
4349
|
+
log("debug", `${this.opts.appId}: invalid session, server allows resume`);
|
|
4350
|
+
this.close("reconnect", {
|
|
4351
|
+
durationMs: this.durationMs(),
|
|
4352
|
+
invalidSessionCanResume: true
|
|
4353
|
+
});
|
|
4354
|
+
} else {
|
|
4355
|
+
log("debug", `${this.opts.appId}: invalid session, will retry with fresh identify, canResume=${canResume}`);
|
|
2358
4356
|
this.sessionId = "";
|
|
2359
4357
|
this.seq = 0;
|
|
2360
|
-
this.close("session_lost"
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
4358
|
+
this.close("session_lost", {
|
|
4359
|
+
durationMs: this.durationMs(),
|
|
4360
|
+
invalidSessionCanResume: canResume
|
|
4361
|
+
});
|
|
2364
4362
|
}
|
|
2365
4363
|
break;
|
|
4364
|
+
}
|
|
2366
4365
|
default: log("debug", `${this.opts.appId}: unhandled opcode ${msg.op}`);
|
|
2367
4366
|
}
|
|
2368
4367
|
}
|
|
@@ -2378,7 +4377,9 @@ const intentBitMap = {
|
|
|
2378
4377
|
[2]: "GUILD_MEMBERS (频道成员变更)",
|
|
2379
4378
|
[512]: "GUILD_MESSAGES (频道消息-私域)",
|
|
2380
4379
|
[4096]: "DIRECT_MESSAGE (频道私信)",
|
|
4380
|
+
[1 << 24]: "群成员进退群事件",
|
|
2381
4381
|
[1 << 25]: "GROUP_AND_C2C_EVENT (群聊/单聊消息)",
|
|
4382
|
+
[1 << 26]: "INTERACTION (按钮交互回调)",
|
|
2382
4383
|
[1 << 30]: "PUBLIC_GUILD_MESSAGES (频道消息-公域)"
|
|
2383
4384
|
};
|
|
2384
4385
|
/** 优先级从高到低,回退时从最前面剥离 */
|
|
@@ -2387,6 +4388,8 @@ const priorityIntents = [
|
|
|
2387
4388
|
512,
|
|
2388
4389
|
1 << 30,
|
|
2389
4390
|
4096,
|
|
4391
|
+
1 << 24,
|
|
4392
|
+
1 << 26,
|
|
2390
4393
|
1 << 25,
|
|
2391
4394
|
1
|
|
2392
4395
|
];
|
|
@@ -2416,31 +4419,47 @@ const formatIntentNames = (intents, prefix = "") => {
|
|
|
2416
4419
|
const conns = /* @__PURE__ */ new Map();
|
|
2417
4420
|
/** /gateway 接口超时(毫秒) */
|
|
2418
4421
|
const GATEWAY_FETCH_TIMEOUT_MS = 5e3;
|
|
4422
|
+
const RATE_LIMIT_DELAY_MS = 6e4;
|
|
4423
|
+
const QUICK_DISCONNECT_THRESHOLD_MS = 5e3;
|
|
4424
|
+
const QUICK_DISCONNECT_LIMIT = 3;
|
|
4425
|
+
const QUICK_DISCONNECT_DELAY_MS = 6e4;
|
|
2419
4426
|
/**
|
|
2420
|
-
* 从 prodApi / sandboxApi 推导兜底的
|
|
4427
|
+
* 从 proxy.prodApi / proxy.sandboxApi 推导兜底的 WS 地址
|
|
2421
4428
|
* 官方实际长期返回的也是 wss://{host}/websocket/,不依赖 /gateway 也能直连
|
|
2422
4429
|
*/
|
|
2423
|
-
const
|
|
2424
|
-
return
|
|
4430
|
+
const buildFallbackWsUrl = (cfg) => {
|
|
4431
|
+
return buildFallbackWsUrlFromApi(cfg.sandbox ? cfg.proxy.sandboxApi : cfg.proxy.prodApi);
|
|
2425
4432
|
};
|
|
2426
4433
|
/**
|
|
2427
4434
|
* 获取网关地址:优先调 /gateway;失败 / 超时 / 限频 → 直接 fallback 到硬编码
|
|
2428
4435
|
*/
|
|
2429
4436
|
const fetchGateway = async (cfg) => {
|
|
2430
|
-
const apiUrl = cfg.sandbox ? cfg.sandboxApi : cfg.prodApi;
|
|
2431
|
-
const fallback =
|
|
4437
|
+
const apiUrl = normalizeHttpUrl(cfg.sandbox ? cfg.proxy.sandboxApi : cfg.proxy.prodApi);
|
|
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
|
+
}
|
|
2432
4448
|
const accessToken = getBotAccessToken(cfg.appId);
|
|
2433
4449
|
if (!accessToken) {
|
|
2434
|
-
log("warn", `${cfg.appId}: 无 access_token,使用硬编码
|
|
4450
|
+
log("warn", `${cfg.appId}: 无 access_token,使用硬编码 WS ${fallback}`);
|
|
2435
4451
|
return fallback;
|
|
2436
4452
|
}
|
|
2437
4453
|
try {
|
|
2438
|
-
const { data } = await axios.get(
|
|
2439
|
-
headers: {
|
|
4454
|
+
const { data } = await axios.get(joinHttpPath(apiUrl, "gateway"), {
|
|
4455
|
+
headers: {
|
|
4456
|
+
Authorization: `QQBot ${accessToken}`,
|
|
4457
|
+
"User-Agent": getUserAgent()
|
|
4458
|
+
},
|
|
2440
4459
|
timeout: GATEWAY_FETCH_TIMEOUT_MS
|
|
2441
4460
|
});
|
|
2442
4461
|
if (data?.url) return data.url;
|
|
2443
|
-
log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码
|
|
4462
|
+
log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码 WS ${fallback}`);
|
|
2444
4463
|
return fallback;
|
|
2445
4464
|
} catch (err) {
|
|
2446
4465
|
if (axios.isAxiosError(err)) {
|
|
@@ -2448,10 +4467,10 @@ const fetchGateway = async (cfg) => {
|
|
|
2448
4467
|
const status = response?.status ?? 0;
|
|
2449
4468
|
const data = response?.data;
|
|
2450
4469
|
const detail = formatOpenAPIError(status, typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0);
|
|
2451
|
-
log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码
|
|
4470
|
+
log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码 WS ${fallback}`);
|
|
2452
4471
|
} else {
|
|
2453
4472
|
const reason = err?.message || "unknown";
|
|
2454
|
-
log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码
|
|
4473
|
+
log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码 WS ${fallback}`);
|
|
2455
4474
|
}
|
|
2456
4475
|
return fallback;
|
|
2457
4476
|
}
|
|
@@ -2493,6 +4512,7 @@ const start = async (cfg) => {
|
|
|
2493
4512
|
cfg,
|
|
2494
4513
|
intents,
|
|
2495
4514
|
intentHistory: [intents],
|
|
4515
|
+
quickDisconnects: 0,
|
|
2496
4516
|
aborted: false
|
|
2497
4517
|
});
|
|
2498
4518
|
await connect(cfg, intents, 0);
|
|
@@ -2516,12 +4536,15 @@ const schedule = (appId, delay, fn) => {
|
|
|
2516
4536
|
const connect = async (cfg, intents, attempt, resume) => {
|
|
2517
4537
|
let conn = conns.get(cfg.appId);
|
|
2518
4538
|
if (!conn || conn.aborted) return;
|
|
2519
|
-
|
|
4539
|
+
let gatewayUrl = conn.gatewayUrl;
|
|
4540
|
+
if (!gatewayUrl) gatewayUrl = await fetchGateway(cfg);
|
|
2520
4541
|
conn = conns.get(cfg.appId);
|
|
2521
4542
|
if (!conn || conn.aborted) return;
|
|
4543
|
+
conn.gatewayUrl = gatewayUrl;
|
|
2522
4544
|
const client = new WSClient({
|
|
2523
4545
|
appId: cfg.appId,
|
|
2524
4546
|
gatewayUrl,
|
|
4547
|
+
userAgent: getUserAgent(),
|
|
2525
4548
|
getAccessToken: () => getBotAccessToken(cfg.appId) || "",
|
|
2526
4549
|
intents,
|
|
2527
4550
|
resume,
|
|
@@ -2529,7 +4552,7 @@ const connect = async (cfg, intents, attempt, resume) => {
|
|
|
2529
4552
|
if (ev.t === "READY") summarize(cfg.appId, intents, true);
|
|
2530
4553
|
dispatch(cfg.appId, ev);
|
|
2531
4554
|
},
|
|
2532
|
-
onClose: (
|
|
4555
|
+
onClose: (event) => onClose(cfg, intents, attempt, event, client)
|
|
2533
4556
|
});
|
|
2534
4557
|
conn.client = client;
|
|
2535
4558
|
conn.cfg = cfg;
|
|
@@ -2540,16 +4563,19 @@ const connect = async (cfg, intents, attempt, resume) => {
|
|
|
2540
4563
|
/**
|
|
2541
4564
|
* 关闭回调处理
|
|
2542
4565
|
*/
|
|
2543
|
-
const onClose = (cfg, intents, attempt,
|
|
2544
|
-
if (reason === "manual") {
|
|
4566
|
+
const onClose = (cfg, intents, attempt, event, client) => {
|
|
4567
|
+
if (event.reason === "manual") {
|
|
2545
4568
|
log("debug", `${cfg.appId}: WebSocket 已主动关闭`);
|
|
2546
4569
|
return;
|
|
2547
4570
|
}
|
|
2548
4571
|
const conn = conns.get(cfg.appId);
|
|
2549
4572
|
if (!conn || conn.aborted) return;
|
|
2550
|
-
|
|
4573
|
+
const code = event.code;
|
|
4574
|
+
const closeLabel = formatCloseLabel(event);
|
|
4575
|
+
if (event.reason === "auth_fail" || code === 4013 || code === 4014) {
|
|
2551
4576
|
const fallback = computeFallback(intents);
|
|
2552
4577
|
if (fallback && fallback !== intents) {
|
|
4578
|
+
log("warn", `${cfg.appId}: Identify 被拒,尝试降级 intents ${intents} -> ${fallback}`);
|
|
2553
4579
|
schedule(cfg.appId, 1e3, () => connect(cfg, fallback, 0));
|
|
2554
4580
|
return;
|
|
2555
4581
|
}
|
|
@@ -2559,11 +4585,64 @@ const onClose = (cfg, intents, attempt, reason, client) => {
|
|
|
2559
4585
|
conns.delete(cfg.appId);
|
|
2560
4586
|
return;
|
|
2561
4587
|
}
|
|
4588
|
+
if (code === 4914 || code === 4915) {
|
|
4589
|
+
summarize(cfg.appId, intents, false);
|
|
4590
|
+
log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),机器人状态不允许连接,已停止自动重连`);
|
|
4591
|
+
conn.aborted = true;
|
|
4592
|
+
conns.delete(cfg.appId);
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4595
|
+
if (event.canResume === false && event.canIdentify === false) {
|
|
4596
|
+
summarize(cfg.appId, intents, false);
|
|
4597
|
+
log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),该错误不建议重试,已停止自动重连`);
|
|
4598
|
+
conn.aborted = true;
|
|
4599
|
+
conns.delete(cfg.appId);
|
|
4600
|
+
return;
|
|
4601
|
+
}
|
|
4602
|
+
if (code === 4004) {
|
|
4603
|
+
log("warn", `${cfg.appId}: access_token 已失效,刷新 token 后重连...`);
|
|
4604
|
+
schedule(cfg.appId, 1e3, () => {
|
|
4605
|
+
getAccessToken(cfg.proxy.tokenApi, cfg.appId, cfg.secret).then(() => connect(cfg, intents, 0)).catch(() => {
|
|
4606
|
+
log("error", `${cfg.appId}: access_token 刷新失败,${RATE_LIMIT_DELAY_MS}ms 后重试连接...`);
|
|
4607
|
+
schedule(cfg.appId, RATE_LIMIT_DELAY_MS, () => connect(cfg, intents, attempt + 1));
|
|
4608
|
+
});
|
|
4609
|
+
});
|
|
4610
|
+
return;
|
|
4611
|
+
}
|
|
2562
4612
|
const snap = client.snapshot();
|
|
2563
|
-
const resume = reason === "session_lost" ? void 0 : snap.sessionId ? snap : void 0;
|
|
2564
|
-
const delay =
|
|
2565
|
-
|
|
2566
|
-
|
|
4613
|
+
const resume = event.reason === "session_lost" || shouldFreshIdentify(code) ? void 0 : snap.sessionId ? snap : void 0;
|
|
4614
|
+
const delay = reconnectDelay(conn, attempt, event, code === 4008 ? RATE_LIMIT_DELAY_MS : void 0);
|
|
4615
|
+
const nextAttempt = shouldResetAttempt(event) ? 0 : attempt + 1;
|
|
4616
|
+
log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),${delay}ms 后${resume ? "Resume" : "Identify"}重连...`);
|
|
4617
|
+
schedule(cfg.appId, delay, () => connect(cfg, intents, nextAttempt, resume));
|
|
4618
|
+
};
|
|
4619
|
+
const shouldFreshIdentify = (code) => {
|
|
4620
|
+
if (code === 4006 || code === 4007 || code === 4009) return true;
|
|
4621
|
+
return typeof code === "number" && code >= 4900 && code <= 4913;
|
|
4622
|
+
};
|
|
4623
|
+
const shouldResetAttempt = (event) => {
|
|
4624
|
+
return typeof event.durationMs === "number" && event.durationMs >= QUICK_DISCONNECT_THRESHOLD_MS;
|
|
4625
|
+
};
|
|
4626
|
+
const reconnectDelay = (conn, attempt, event, forcedDelay) => {
|
|
4627
|
+
const stable = shouldResetAttempt(event);
|
|
4628
|
+
let delay = forcedDelay ?? backoff(stable ? 0 : attempt);
|
|
4629
|
+
if (typeof event.durationMs !== "number") return delay;
|
|
4630
|
+
if (event.durationMs >= QUICK_DISCONNECT_THRESHOLD_MS) {
|
|
4631
|
+
conn.quickDisconnects = 0;
|
|
4632
|
+
return delay;
|
|
4633
|
+
}
|
|
4634
|
+
conn.quickDisconnects += 1;
|
|
4635
|
+
if (conn.quickDisconnects >= QUICK_DISCONNECT_LIMIT) {
|
|
4636
|
+
delay = Math.max(delay, QUICK_DISCONNECT_DELAY_MS);
|
|
4637
|
+
log("warn", `${conn.cfg.appId}: WebSocket 连续快速断开 ${conn.quickDisconnects} 次,延长重连间隔到 ${delay}ms`);
|
|
4638
|
+
}
|
|
4639
|
+
return delay;
|
|
4640
|
+
};
|
|
4641
|
+
const formatCloseLabel = (event) => {
|
|
4642
|
+
const parts = [event.reason];
|
|
4643
|
+
if (typeof event.code === "number") parts.push(String(event.code));
|
|
4644
|
+
if (event.remoteReason) parts.push(event.remoteReason);
|
|
4645
|
+
return parts.join(" / ");
|
|
2567
4646
|
};
|
|
2568
4647
|
/**
|
|
2569
4648
|
* 停止连接(同步:标记 abort + 清 pendingTimer + 关 client)
|
|
@@ -2604,17 +4683,21 @@ const initState = /* @__PURE__ */ new Map();
|
|
|
2604
4683
|
const hashConfig = (bot) => JSON.stringify({
|
|
2605
4684
|
appId: bot.appId,
|
|
2606
4685
|
secret: bot.secret,
|
|
2607
|
-
|
|
2608
|
-
prodApi: bot.prodApi,
|
|
2609
|
-
sandboxApi: bot.sandboxApi,
|
|
4686
|
+
proxy: bot.proxy,
|
|
2610
4687
|
sandbox: bot.sandbox,
|
|
2611
4688
|
event: bot.event,
|
|
2612
|
-
keyboard: bot.keyboard
|
|
4689
|
+
keyboard: bot.keyboard,
|
|
4690
|
+
markdown: bot.markdown,
|
|
4691
|
+
messageCache: bot.messageCache
|
|
2613
4692
|
});
|
|
2614
4693
|
/**
|
|
2615
4694
|
* 创建单个 Bot 实例
|
|
2616
4695
|
*/
|
|
2617
|
-
const createBot = async (
|
|
4696
|
+
const createBot = async (input) => {
|
|
4697
|
+
const bot = {
|
|
4698
|
+
...input,
|
|
4699
|
+
proxy: normalizeProxyConfig(input.proxy)
|
|
4700
|
+
};
|
|
2618
4701
|
const appId = String(bot.appId);
|
|
2619
4702
|
if (!appId) {
|
|
2620
4703
|
logger.warn("[QQ Official Bot] 配置缺少 appId,跳过");
|
|
@@ -2636,8 +4719,8 @@ const createBot = async (bot) => {
|
|
|
2636
4719
|
offAll(appId);
|
|
2637
4720
|
stopTokenRefresh(appId);
|
|
2638
4721
|
logger.info(`[QQ Official Bot][${appId}] 获取 access_token...`);
|
|
2639
|
-
await getAccessToken(bot.tokenApi, appId, bot.secret);
|
|
2640
|
-
const baseUrl = bot.sandbox ? bot.sandboxApi : bot.prodApi;
|
|
4722
|
+
await getAccessToken(bot.proxy.tokenApi, appId, bot.secret);
|
|
4723
|
+
const baseUrl = bot.sandbox ? bot.proxy.sandboxApi : bot.proxy.prodApi;
|
|
2641
4724
|
const api = new QQBotApi(createAxiosInstance(baseUrl, appId));
|
|
2642
4725
|
const me = await api.meta.getMe();
|
|
2643
4726
|
const client = new AdapterQQBot(bot, api);
|
|
@@ -2649,9 +4732,11 @@ const createBot = async (bot) => {
|
|
|
2649
4732
|
client.account.subId.qq = qq;
|
|
2650
4733
|
client.account.subId.appid = appId;
|
|
2651
4734
|
client.account.subId.union_openid = me.union_openid;
|
|
2652
|
-
bus.on(appId, (ev) =>
|
|
4735
|
+
bus.on(appId, (ev) => {
|
|
4736
|
+
dispatch$1(client, ev);
|
|
4737
|
+
});
|
|
2653
4738
|
client.adapter.address = baseUrl;
|
|
2654
|
-
client.adapter.secret =
|
|
4739
|
+
client.adapter.secret = "*********";
|
|
2655
4740
|
client.adapter.version = pkg().version;
|
|
2656
4741
|
if (bot.event.type === 2) {
|
|
2657
4742
|
client.adapter.index = registerBot("webSocketClient", client);
|