@zaofan/dsh-qqbot 1.5.30 → 1.5.31

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.
Files changed (37) hide show
  1. package/client/qqbot-settings.js +117 -24
  2. package/client/qqbot-settings.js.bak-inc-1790865292905 +4967 -0
  3. package/client/qqbot-settings.js.bak-inc-1790865309979 +4967 -0
  4. package/client/qqbot-settings.js.bak-newer-1790865544006 +5023 -0
  5. package/client/qqbot-settings.js.bak-stick-1790865591295 +5033 -0
  6. package/dist/features/chat-ledger.d.ts +13 -0
  7. package/dist/features/chat-ledger.d.ts.map +1 -1
  8. package/dist/features/chat-ledger.js +52 -0
  9. package/dist/features/chat-ledger.js.map +1 -1
  10. package/dist/gateway/debounce.d.ts.map +1 -1
  11. package/dist/gateway/debounce.js +32 -2
  12. package/dist/gateway/debounce.js.map +1 -1
  13. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  14. package/dist/gateway/middleware-setup.js +25 -5
  15. package/dist/gateway/middleware-setup.js.map +1 -1
  16. package/dist/middleware/media-history.d.ts +27 -0
  17. package/dist/middleware/media-history.d.ts.map +1 -1
  18. package/dist/middleware/media-history.js +19 -5
  19. package/dist/middleware/media-history.js.map +1 -1
  20. package/dist/transport/inbound.d.ts +33 -0
  21. package/dist/transport/inbound.d.ts.map +1 -1
  22. package/dist/transport/inbound.js +232 -28
  23. package/dist/transport/inbound.js.map +1 -1
  24. package/dist/transport/msg-content-cache.d.ts +15 -0
  25. package/dist/transport/msg-content-cache.d.ts.map +1 -0
  26. package/dist/transport/msg-content-cache.js +105 -0
  27. package/dist/transport/msg-content-cache.js.map +1 -0
  28. package/dist/transport/quote-images.d.ts +17 -0
  29. package/dist/transport/quote-images.d.ts.map +1 -0
  30. package/dist/transport/quote-images.js +91 -0
  31. package/dist/transport/quote-images.js.map +1 -0
  32. package/dist/transport/quote-text.d.ts +30 -0
  33. package/dist/transport/quote-text.d.ts.map +1 -0
  34. package/dist/transport/quote-text.js +119 -0
  35. package/dist/transport/quote-text.js.map +1 -0
  36. package/package.json +1 -1
  37. package/settings-host.js +52 -5
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 被引用消息的内容台账(2026-10-01 主人实测:**引用纯图片**看不到是哪张图)。
3
+ *
4
+ * 为什么要自己记:
5
+ * QQ 引用一条**纯图片**消息时,`msg_elements` 里往往只有个文件名(`[image: x.jpg]`)甚至
6
+ * 什么都没有 —— 拿不到 URL / 本地路径,AI 就不知道被引用的是哪张图(实测:群里引用表情包回话,她答非所问)。
7
+ * 而这条消息**入站那一刻**我们手里是有图片本地路径的
8
+ * (表情包采集中间件已落盘 + image-path-cache 已缓存 URL→本地路径)。
9
+ * → 入站时把「QQ 的 msg_idx → { 文本, 图片本地路径 }」记进本地台账;解析引用时按 `refKey` 回查补齐。
10
+ *
11
+ * 落盘:`{dataRoot}/.qqbot/msg-content.jsonl`(一行一条;超过 {@link MAX_LINES} 行时只留尾部)。
12
+ * ⚠️ 只存消息索引与**本机路径**,不存任何凭据;与本机图库同生命周期。
13
+ */
14
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ /** 单个 peer 保留的台账行数上限(超出只留尾部,防文件无限膨胀) */
17
+ const MAX_LINES = 2000;
18
+ /** 文件路径 → { mtimeKey, index, lines } */
19
+ const cache = new Map();
20
+ function fileOf(dataRoot) {
21
+ return join(dataRoot, '.qqbot', 'msg-content.jsonl');
22
+ }
23
+ function indexKey(peerKey, msgIdx) {
24
+ return peerKey + '\u0000' + msgIdx;
25
+ }
26
+ function load(dataRoot) {
27
+ const file = fileOf(dataRoot);
28
+ let key = 'missing';
29
+ try {
30
+ const st = statSync(file);
31
+ key = st.mtimeMs + ':' + st.size;
32
+ }
33
+ catch { /* 还没有台账文件 */ }
34
+ const hit = cache.get(file);
35
+ // 'dirty' = 本进程刚写过,内存比文件新 → 直接用内存,别重读整个文件
36
+ if (hit && (hit.key === key || hit.key === 'dirty'))
37
+ return { index: hit.index, lines: hit.lines };
38
+ const index = new Map();
39
+ let lines = 0;
40
+ try {
41
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
42
+ const s = line.trim();
43
+ if (!s)
44
+ continue;
45
+ lines++;
46
+ try {
47
+ const r = JSON.parse(s);
48
+ if (!r || !r.p || !r.i)
49
+ continue;
50
+ index.set(indexKey(r.p, r.i), {
51
+ ...(r.t ? { t: r.t } : {}),
52
+ ...(Array.isArray(r.imgs) && r.imgs.length ? { imgs: r.imgs } : {}),
53
+ });
54
+ }
55
+ catch { /* 坏行跳过 */ }
56
+ }
57
+ }
58
+ catch { /* 没有文件 */ }
59
+ cache.set(file, { key, index, lines });
60
+ return { index, lines };
61
+ }
62
+ /**
63
+ * 记一条:这条消息(msgIdx)的文本与图片本地路径。
64
+ * 之后有人**引用它**时,就能靠 `refKey = msgIdx` 回查到"被引用的到底是哪张图"。
65
+ */
66
+ export function rememberMsgContent(dataRoot, peerKey, msgIdx, rec) {
67
+ const idx = String(msgIdx ?? '').trim();
68
+ if (!dataRoot || !peerKey || !idx)
69
+ return;
70
+ if (!rec.t && !(rec.imgs && rec.imgs.length))
71
+ return; // 没内容可记(纯表情符号之类),跳过
72
+ const { index, lines } = load(dataRoot);
73
+ index.set(indexKey(peerKey, idx), rec);
74
+ const file = fileOf(dataRoot);
75
+ try {
76
+ mkdirSync(dirname(file), { recursive: true });
77
+ appendFileSync(file, JSON.stringify({ p: peerKey, i: idx, ...rec, at: Date.now() }) + '\n');
78
+ const nextLines = lines + 1;
79
+ if (nextLines > MAX_LINES) {
80
+ // 超上限 → 重写成尾部 MAX_LINES 条(内存里已有的直接重建)
81
+ const tail = [];
82
+ for (const [k, v] of index) {
83
+ const pos = k.indexOf('\u0000');
84
+ tail.push(JSON.stringify({ p: k.slice(0, pos), i: k.slice(pos + 1), ...v, at: 0 }));
85
+ }
86
+ writeFileSync(file, tail.slice(-MAX_LINES).join('\n') + '\n', 'utf8');
87
+ cache.set(file, { key: 'dirty', index, lines: Math.min(nextLines, MAX_LINES) });
88
+ return;
89
+ }
90
+ cache.set(file, { key: 'dirty', index, lines: nextLines });
91
+ }
92
+ catch { /* 落盘失败不影响主链(内存索引已经更新) */ }
93
+ }
94
+ /** 回查:这条被引用消息(msgIdx)我们记过什么 */
95
+ export function lookupMsgContent(dataRoot, peerKey, msgIdx) {
96
+ if (!dataRoot || !peerKey || !msgIdx)
97
+ return undefined;
98
+ try {
99
+ return load(dataRoot).index.get(indexKey(peerKey, msgIdx));
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ //# sourceMappingURL=msg-content-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"msg-content-cache.js","sourceRoot":"","sources":["../../src/transport/msg-content-cache.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ /** 引用里能拿到的附件形状(SDK 的 QuotedAttachment) */
2
+ export interface QuoteImageAttachment {
3
+ contentType?: string;
4
+ url?: string;
5
+ filename?: string;
6
+ }
7
+ /**
8
+ * 把被引用的图片预取到本地(幂等;已缓存/非图片/非 http 的直接跳过)。
9
+ * @returns 成功预取的张数
10
+ */
11
+ export declare function prefetchQuoteImages(quote: {
12
+ attachments?: QuoteImageAttachment[];
13
+ } | undefined, dataRoot: string, logger?: {
14
+ debug?: (m: string) => void;
15
+ warn?: (m: string) => void;
16
+ }): Promise<number>;
17
+ //# sourceMappingURL=quote-images.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quote-images.d.ts","sourceRoot":"","sources":["../../src/transport/quote-images.ts"],"names":[],"mappings":"AAwBA,0CAA0C;AAC1C,MAAM,WAAW,oBAAoB;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAsBD;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,KAAK,EAAE;IAAE,WAAW,CAAC,EAAE,oBAAoB,EAAE,CAAA;CAAE,GAAG,SAAS,EAC3D,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACnE,OAAO,CAAC,MAAM,CAAC,CA4BjB"}
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 被引用消息里的**图片预取**(2026-10-01 主人要求:「为什么没尽可能地转换成本地路径」)。
3
+ *
4
+ * 场景:有人引用一张图问"这是啥" —— 那张图我们**可能从没收藏过**(别人发的、闸门外、采集关过),
5
+ * image-path-cache 与图库台账都查不到 → 引用块里只能回退 QQ 的长链接
6
+ * (`multimedia.nt.qq.com.cn/download?…&rkey=…`,又长又会过期,AI 读不了图)。
7
+ *
8
+ * 做法:入站时(引用解析之后、**拼上下文之前**)异步把被引用的图片**下载到本地**
9
+ * `{dataRoot}/.qqbot/referenced/<sha1>.<ext>`,并 `rememberImagePath(url → 本地路径)` ——
10
+ * 之后同步的 buildQuotePart / media-history 就能给出本地路径,AI 直接读图。
11
+ *
12
+ * 边界:只处理 http(s) 的**图片**附件;最多 {@link MAX_IMAGES} 张、每张 {@link TIMEOUT_MS} 超时;
13
+ * 已缓存过的直接跳过;任何失败都静默(引用块照旧回退 URL,不影响主链)。
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { mkdirSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { lookupImagePath, rememberImagePath } from '../features/image-path-cache.js';
19
+ /** 一次最多预取几张(防有人引用一堆图拖慢入站) */
20
+ const MAX_IMAGES = 3;
21
+ /** 单张下载超时 */
22
+ const TIMEOUT_MS = 8000;
23
+ function isImageAtt(a) {
24
+ const ct = String(a?.contentType ?? '').toLowerCase();
25
+ const url = String(a?.url ?? '');
26
+ if (ct.startsWith('image/'))
27
+ return true;
28
+ // contentType 不可信(QQ 群聊图片实测会报 'file')→ 再用文件名 / URL 扩展名兜底
29
+ const name = String(a?.filename ?? url).split(/[?#]/)[0] ?? '';
30
+ return /\.(jpe?g|png|gif|webp|bmp)$/i.test(name);
31
+ }
32
+ function extOf(a) {
33
+ const ct = String(a?.contentType ?? '').toLowerCase();
34
+ if (ct.includes('png'))
35
+ return '.png';
36
+ if (ct.includes('gif'))
37
+ return '.gif';
38
+ if (ct.includes('webp'))
39
+ return '.webp';
40
+ if (ct.includes('bmp'))
41
+ return '.bmp';
42
+ const name = String(a?.filename ?? a?.url ?? '').split(/[?#]/)[0] ?? '';
43
+ const m = name.match(/\.(jpe?g|png|gif|webp|bmp)$/i);
44
+ return m && m[1] ? '.' + m[1].toLowerCase() : '.jpg';
45
+ }
46
+ /**
47
+ * 把被引用的图片预取到本地(幂等;已缓存/非图片/非 http 的直接跳过)。
48
+ * @returns 成功预取的张数
49
+ */
50
+ export async function prefetchQuoteImages(quote, dataRoot, logger) {
51
+ const atts = Array.isArray(quote?.attachments) ? quote.attachments : [];
52
+ if (!atts.length || !dataRoot)
53
+ return 0;
54
+ let done = 0;
55
+ for (const a of atts) {
56
+ if (done >= MAX_IMAGES)
57
+ break;
58
+ const url = String(a?.url ?? '').trim();
59
+ if (!/^https?:\/\//i.test(url))
60
+ continue; // 已经是本机路径 / 非 http → 无需预取
61
+ if (!isImageAtt(a))
62
+ continue;
63
+ if (lookupImagePath(url))
64
+ continue; // 缓存已有 → 同步侧本来就能命中
65
+ try {
66
+ const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
67
+ if (!res.ok) {
68
+ logger?.debug?.(`[引用图片预取] HTTP ${res.status}: ${url.slice(0, 80)}`);
69
+ continue;
70
+ }
71
+ const buf = Buffer.from(await res.arrayBuffer());
72
+ if (buf.length === 0 || buf.length > 12 * 1024 * 1024) {
73
+ logger?.debug?.(`[引用图片预取] 体积异常(${buf.length}B), 跳过`);
74
+ continue;
75
+ }
76
+ const dir = join(dataRoot, '.qqbot', 'referenced');
77
+ mkdirSync(dir, { recursive: true });
78
+ const sha1 = createHash('sha1').update(buf).digest('hex').slice(0, 12);
79
+ const file = join(dir, sha1 + extOf(a));
80
+ writeFileSync(file, buf);
81
+ rememberImagePath(url, file);
82
+ done++;
83
+ logger?.debug?.(`[引用图片预取] 已落盘 ${file} (${buf.length}B)`);
84
+ }
85
+ catch (e) {
86
+ logger?.debug?.(`[引用图片预取] 失败(忽略): ${e instanceof Error ? e.message : String(e)}`);
87
+ }
88
+ }
89
+ return done;
90
+ }
91
+ //# sourceMappingURL=quote-images.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quote-images.js","sourceRoot":"","sources":["../../src/transport/quote-images.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 剥掉 QQ 引用模板的壳,返回各层正文(**最外层在前**,即"最近被引用的那句话"排第一)。
3
+ * 不是模板串时,按行返回原文(保留原样,只 trim)。
4
+ */
5
+ export declare function stripQqQuoteTemplate(s: string): string[];
6
+ /**
7
+ * 从 QQ 表情标签里挖**图片线索**。
8
+ *
9
+ * 背景(2026-10-01 主人实测:「图片收藏进了表情里,依然是图片」):
10
+ * QQ 的「图片表情 / 收藏表情」在消息里只给一个 `<faceType=…,ext="base64">` 标签。
11
+ * 实测那条 ext = `eyJ0ZXh0IjoiIn0=` → `{"text":""}` —— **里面没有图**(QQ 官方文档也没定义 faceType,
12
+ * SDK 同样只解 ext.text),所以光靠标签解析不出图;真正的出路是"那条消息入站时的附件"
13
+ * (走本地台账回查,见 msg-content-cache.ts / quote-images.ts 的预取)。
14
+ * 但不同端/版本的 ext 里**可能**带 url 之类字段 —— 这里全部扫一遍,挖到就当真图片附件用。
15
+ */
16
+ export declare function extractFaceImageUrls(text: string): string[];
17
+ /**
18
+ * 引用块正文瘦身:剥模板壳 → **只取最外层那一句** → 表情标签转可读 → 压平空白 → 限长。
19
+ *
20
+ * ⚠️ 只取最外层 —— 那才是"他引用的内容"。QQ 给的嵌套链(`[关联消息] --- 第1条 --- [消息内容] …`)
21
+ * 全是"那句话又引用了谁",又长又乱(主人:「不止是排版乱的问题,这是内容就乱了」)。
22
+ * @param max 最大字数(默认 160;传 0 = 不限长)
23
+ */
24
+ export declare function slimQuoteText(s: string, max?: number): string;
25
+ /**
26
+ * 把整个 `[引]…[/引]` 块压成**一行**并限长(历史行用)。
27
+ * 附件行不参与截断(否则路径被切掉 = 白记)。
28
+ */
29
+ export declare function slimQuoteBlockOneLine(block: string, max?: number): string;
30
+ //# sourceMappingURL=quote-text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quote-text.d.ts","sourceRoot":"","sources":["../../src/transport/quote-text.ts"],"names":[],"mappings":"AAmBA;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAiBxD;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAiB3D;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,SAAM,GAAG,MAAM,CAM1D;AAMD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAM,GAAG,MAAM,CAsBtE"}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 引用文本清洗(2026-10-01 主人实测:「历史里包含引用的排版太乱了」+「引用的是表情包?」)。
3
+ *
4
+ * 两个数据源问题都在这里修:
5
+ * ① QQ 的引用在 `msg_elements` 里是一段**模板串**:
6
+ * `=== 消息 1 === [消息内容] A [消息类型] 引用消息 [关联消息] --- 第1条 --- [消息内容] B …`
7
+ * 嵌套几层就重复几段,夹着一堆纯噪声 —— 原样喂给 AI 又长又乱。
8
+ * ② 引用一条**表情消息**时,内容是一个 QQ 表情标签:
9
+ * `<faceType=6,faceId="0",ext="eyJ0ZXh0IjoiIn0=">`(ext 是 base64 的 `{"text":""}`)。
10
+ * 原样显示谁也看不懂 —— 这里转成 `【表情: 微笑】`;并顺手把 ext 里**可能藏着**的图片 URL
11
+ * (图片表情 / 收藏表情的其它端形态)挖出来当附件用。
12
+ */
13
+ import { resolveFaceTags } from '../features/face-tags.js';
14
+ /** 判断是不是 QQ 的引用模板串 */
15
+ function looksLikeQqTemplate(s) {
16
+ return /===+\s*消息|\[消息内容\]|\[消息类型\]|\[关联消息\]|引用消息|第\s*\d+\s*条/.test(s);
17
+ }
18
+ /**
19
+ * 剥掉 QQ 引用模板的壳,返回各层正文(**最外层在前**,即"最近被引用的那句话"排第一)。
20
+ * 不是模板串时,按行返回原文(保留原样,只 trim)。
21
+ */
22
+ export function stripQqQuoteTemplate(s) {
23
+ const raw = String(s ?? '');
24
+ if (!looksLikeQqTemplate(raw)) {
25
+ return raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
26
+ }
27
+ let t = raw;
28
+ // 每层的正文起点:`[消息内容]` 和 `--- 第N条 ---` 都切开
29
+ t = t.replace(/\[消息内容\]/g, '\n');
30
+ t = t.replace(/-{2,}\s*第\s*\d+\s*条\s*-{2,}/g, '\n');
31
+ // 剩余的壳当空白
32
+ t = t.replace(/\[(?:消息类型|关联消息|消息来源|消息序号)\]/g, ' ');
33
+ t = t.replace(/[=]{2,}\s*消息\s*\d+\s*[=]{2,}/g, ' ');
34
+ t = t.replace(/引用消息/g, ' ');
35
+ return t
36
+ .split(/\r?\n/)
37
+ .map((l) => l.replace(/\s+/g, ' ').trim())
38
+ .filter(Boolean);
39
+ }
40
+ /**
41
+ * 从 QQ 表情标签里挖**图片线索**。
42
+ *
43
+ * 背景(2026-10-01 主人实测:「图片收藏进了表情里,依然是图片」):
44
+ * QQ 的「图片表情 / 收藏表情」在消息里只给一个 `<faceType=…,ext="base64">` 标签。
45
+ * 实测那条 ext = `eyJ0ZXh0IjoiIn0=` → `{"text":""}` —— **里面没有图**(QQ 官方文档也没定义 faceType,
46
+ * SDK 同样只解 ext.text),所以光靠标签解析不出图;真正的出路是"那条消息入站时的附件"
47
+ * (走本地台账回查,见 msg-content-cache.ts / quote-images.ts 的预取)。
48
+ * 但不同端/版本的 ext 里**可能**带 url 之类字段 —— 这里全部扫一遍,挖到就当真图片附件用。
49
+ */
50
+ export function extractFaceImageUrls(text) {
51
+ const src = String(text ?? '');
52
+ if (src.indexOf('<faceType=') < 0)
53
+ return [];
54
+ const out = [];
55
+ const re = /<faceType=\d+,faceId="[^"]*",ext="([^"]*)">/g;
56
+ let m;
57
+ while ((m = re.exec(src)) !== null) {
58
+ const b64 = m[1] ?? '';
59
+ if (!b64)
60
+ continue;
61
+ try {
62
+ const obj = JSON.parse(Buffer.from(b64, 'base64').toString('utf-8'));
63
+ for (const v of Object.values(obj ?? {})) {
64
+ if (typeof v === 'string' && /^https?:\/\//i.test(v) && !out.includes(v))
65
+ out.push(v);
66
+ }
67
+ }
68
+ catch { /* ext 不是 JSON / 解不开 → 跳过 */ }
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * 引用块正文瘦身:剥模板壳 → **只取最外层那一句** → 表情标签转可读 → 压平空白 → 限长。
74
+ *
75
+ * ⚠️ 只取最外层 —— 那才是"他引用的内容"。QQ 给的嵌套链(`[关联消息] --- 第1条 --- [消息内容] …`)
76
+ * 全是"那句话又引用了谁",又长又乱(主人:「不止是排版乱的问题,这是内容就乱了」)。
77
+ * @param max 最大字数(默认 160;传 0 = 不限长)
78
+ */
79
+ export function slimQuoteText(s, max = 160) {
80
+ const lines = stripQqQuoteTemplate(s);
81
+ // 表情标签 → `【表情: 微笑】`(原来是 `<faceType=6,faceId="0",ext="…">` 这种谁也看不懂的串)
82
+ let out = resolveFaceTags(lines[0] ?? '').replace(/\s+/g, ' ').trim();
83
+ if (max > 0 && out.length > max)
84
+ out = out.slice(0, max) + '…';
85
+ return out;
86
+ }
87
+ /** 附件行(`📷 被引用的图片: <本地路径>` 等)—— 截断时必须豁免:那是"引用的是哪张图"的唯一线索
88
+ * ⚠️ 不能用字符类 `[📷🎵🎬📎]` —— emoji 是代理对,字符类里会被拆成单个 UTF-16 码元,**匹配不上**。 */
89
+ const ATT_LINE_RE = /^\s*(?:📷|🎵|🎬|📎)\s*被引用的/;
90
+ /**
91
+ * 把整个 `[引]…[/引]` 块压成**一行**并限长(历史行用)。
92
+ * 附件行不参与截断(否则路径被切掉 = 白记)。
93
+ */
94
+ export function slimQuoteBlockOneLine(block, max = 140) {
95
+ const raw = String(block ?? '').trim();
96
+ if (!raw)
97
+ return '';
98
+ const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
99
+ const isBegin = (l) => /^\[(?:引|Quoted message begins)\]$/i.test(l);
100
+ const isEnd = (l) => /^\[\/(?:引|Quoted message ends)\]$/i.test(l);
101
+ const hasBegin = lines.some(isBegin);
102
+ const hasEnd = lines.some(isEnd);
103
+ // ⚠️ 2026-10-01 主人实测抓到:附件行**必须留在 `[引]…[/引]` 里面**。
104
+ // 原实现把「正文」和「附件」分成两段各自拼,附件被甩到 `[引] [/引]` **之后** →
105
+ // 引用块看着是空的、图却挂在块外(`[引] [/引] 📷 被引用的图片: …`)。
106
+ const att = lines.filter((l) => ATT_LINE_RE.test(l));
107
+ const body = lines
108
+ .filter((l) => !isBegin(l) && !isEnd(l) && !ATT_LINE_RE.test(l))
109
+ .join(' ')
110
+ .replace(/\s+/g, ' ')
111
+ .trim();
112
+ const clipped = max > 0 && body.length > max ? body.slice(0, max) + '…' : body;
113
+ const inner = [clipped, ...att].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
114
+ if (!hasBegin && !hasEnd)
115
+ return inner;
116
+ // 老英文标记([Quoted message begins]…)也归一成短标记
117
+ return `[引] ${inner} [/引]`.replace(/\s+/g, ' ').trim();
118
+ }
119
+ //# sourceMappingURL=quote-text.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quote-text.js","sourceRoot":"","sources":["../../src/transport/quote-text.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaofan/dsh-qqbot",
3
- "version": "1.5.30",
3
+ "version": "1.5.31",
4
4
  "description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
5
5
  "type": "module",
6
6
  "main": "./entry.js",
package/settings-host.js CHANGED
@@ -3260,7 +3260,12 @@ export function apply(ctx) {
3260
3260
  for (const line of String(body || '').split('\n')) {
3261
3261
  const s = line.trim();
3262
3262
  if (!s) continue;
3263
- if (RE_HIST_BEGIN_LINE.test(s) || RE_HIST_END_LINE.test(s) || RE_CURRENT_LINE.test(s) || isTimeHeadLine(s)) continue;
3263
+ // ⚠️ 2026-10-01 修(主人实测:"当前消息引用的消息显示给上一个人了"):
3264
+ // `[当前]` 是**分段点** —— 它之前属于历史(该收尾结算),之后的"引用块 + 正文"才是当前消息。
3265
+ // 原来这里直接 continue 不 flush,导致当前消息的 `[引]…[/引]` 被并进**上一条历史消息**里
3266
+ // (于是界面上引用块挂在别人气泡上)。
3267
+ if (RE_CURRENT_LINE.test(s)) { flush(); continue; }
3268
+ if (RE_HIST_BEGIN_LINE.test(s) || RE_HIST_END_LINE.test(s) || isTimeHeadLine(s)) continue;
3264
3269
  if (/^\[系统提示\]/.test(s)) { flush(); return out; } // 系统注入段, 之后都不属于群聊内容
3265
3270
  // 媒体元数据行: 暂存, 等归属给下面那条带昵称的消息
3266
3271
  // (Layer 4 现输出 `- Image:/- Video:/- Voice:/- File:` 单数前缀, 此处一并覆盖)
@@ -3268,7 +3273,23 @@ export function apply(ctx) {
3268
3273
  // 2026-09-12 适配(token 瘦身后历史行变成 "[昵称]"): id 段设为**可选**, 老格式 "[昵称 (openid)]" 仍认;
3269
3274
  // 同时排除含冒号的方括号(如 "[图片: url]"), 免得把附件标记当成发送者。
3270
3275
  const m = s.match(/^\[([^\]\n:]*?)(?:\s*\([A-Za-z0-9_-]{6,}\))?\](.*)$/);
3271
- if (m) { flush(); cur = { sender: m[1].trim(), lines: [m[2].trim(), ...pendingMeta].filter(Boolean) }; pendingMeta = []; }
3276
+ // ⚠️ 2026-10-01 修(主人实测:dock 聊天界面冒出个叫「引」的人发消息):
3277
+ // `[引]` / `[/引]` / `[引用]` 是**块标记**而不是昵称壳 —— 命中它们时不能开新气泡,
3278
+ // 要当普通行并入当前段(真昵称在后面几行,交给 chatPolishOne 去找)。
3279
+ // 与 chatPolishOne 的 RE_MARKER_WORD 同款防御,此处之前漏了。
3280
+ const isNick = m && m[1].trim() && !RE_MARKER_WORD.test(m[1].trim());
3281
+ if (isNick) {
3282
+ // ⚠️ 2026-10-01:当前消息的引用块会先形成一个 sender 为空的段 —— 紧随的 [昵称] 行要**接管**它
3283
+ // (引用块 + 正文同属一条消息),否则引用块会独立成一个没有名字的气泡。
3284
+ if (cur && !cur.sender) {
3285
+ cur.sender = m[1].trim();
3286
+ cur.lines.push(...[m[2].trim(), ...pendingMeta].filter(Boolean));
3287
+ } else {
3288
+ flush();
3289
+ cur = { sender: m[1].trim(), lines: [m[2].trim(), ...pendingMeta].filter(Boolean) };
3290
+ }
3291
+ pendingMeta = [];
3292
+ }
3272
3293
  else { if (cur) cur.lines.push(...pendingMeta, s); else { flush(); cur = { sender: '', lines: [...pendingMeta, s] }; } pendingMeta = []; }
3273
3294
  }
3274
3295
  flush();
@@ -3406,6 +3427,10 @@ export function apply(ctx) {
3406
3427
  const limit = Math.max(1, Math.min(200, Math.round(Number(u.searchParams.get('limit'))) || 50));
3407
3428
  const bRaw = Number(u.searchParams.get('beforeSeq'));
3408
3429
  const beforeSeq = Number.isFinite(bRaw) && bRaw > 0 ? Math.floor(bRaw) : undefined;
3430
+ // 2026-10-01 新增 afterSeq:**往后取更新的消息**(dock 聊天页滚轮增量刷新用 ——
3431
+ // 只 append 新气泡,不重绘不闪、不跳滚动位置)。与 beforeSeq 互斥:给了 afterSeq 就正扫。
3432
+ const aRaw = Number(u.searchParams.get('afterSeq'));
3433
+ const afterSeq = Number.isFinite(aRaw) && aRaw >= 0 ? Math.floor(aRaw) : undefined;
3409
3434
  const bot = nsBot(ns);
3410
3435
  if (!bot) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
3411
3436
  const reg = await import('./dist/features/session-registry.js');
@@ -3434,9 +3459,30 @@ export function apply(ctx) {
3434
3459
  const fallbackSender = scope === 'c2c' ? ((ledger.get('c2c:' + peerId) || {}).name || '') : '';
3435
3460
  // 尾部倒扫: 默认从最新一条事件 seq 往前; beforeSeq=加载更早(beforeSeq 之前的)
3436
3461
  const items = [];
3437
- let high = beforeSeq !== undefined ? Math.max(0, beforeSeq - 1) : Math.max(0, sess.seq - 1);
3438
- let reachedEnd = false;
3439
3462
  const STEP = 500;
3463
+ let reachedEnd = false;
3464
+ if (afterSeq !== undefined) {
3465
+ // ★ 正扫:从 afterSeq+1 扫到最新(升序)—— 只取"比前端已有的更新"的那批
3466
+ const top = Math.max(0, sess.seq - 1);
3467
+ let low = afterSeq + 1;
3468
+ while (low <= top && items.length < limit) {
3469
+ const hi = Math.min(top, low + STEP - 1);
3470
+ let evs = [];
3471
+ if (snap) { try { evs = snap(low, hi + 1) || []; } catch { evs = []; } }
3472
+ if (!evs.length) { const all = sess.events; if (Array.isArray(all) && all.length && all.length > low) evs = all.slice(low, hi + 1); }
3473
+ for (const ev of evs) {
3474
+ const got = chatDecodeEvent(ev, scope, nameByMid, fallbackSender);
3475
+ if (got) {
3476
+ if (Array.isArray(got)) { for (const g of got) items.push(g); }
3477
+ else items.push(got);
3478
+ }
3479
+ if (items.length >= limit) break;
3480
+ }
3481
+ low = hi + 1;
3482
+ }
3483
+ reachedEnd = low > top;
3484
+ } else {
3485
+ let high = beforeSeq !== undefined ? Math.max(0, beforeSeq - 1) : Math.max(0, sess.seq - 1);
3440
3486
  while (high >= 0 && items.length < limit) {
3441
3487
  const low = Math.max(0, high - STEP + 1);
3442
3488
  let evs = [];
@@ -3454,12 +3500,13 @@ export function apply(ctx) {
3454
3500
  if (low === 0) { reachedEnd = true; break; }
3455
3501
  high = low - 1;
3456
3502
  }
3503
+ }
3457
3504
  // ⚠️ 2026-09-10 修复(主人实测"同一批次的历史消息会倒序"):
3458
3505
  // 原为 items.reverse() —— 倒扫时同一打包块([Chat history begins]…[Chat history ends])
3459
3506
  // 拆出的多条共用同一个 ev.seq, 块内本就是旧→新; 整体 reverse 会把"块内顺序"也翻反,
3460
3507
  // 表现为"整批之间顺序对、批内倒序"。
3461
3508
  // 改为: 按 seq 分块 → 块间倒序(旧→新), 块内保持原顺序。
3462
- {
3509
+ if (afterSeq === undefined) {
3463
3510
  const blocks = [];
3464
3511
  for (const it of items) {
3465
3512
  const last = blocks[blocks.length - 1];