@zaofan/dsh-qqbot 1.5.30 → 1.6.0

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 (63) hide show
  1. package/client/qqbot-settings.js +161 -29
  2. package/client/qqbot-settings.js.bak-ctxkey-1790880133352 +5069 -0
  3. package/client/qqbot-settings.js.bak-inc-1790865292905 +4967 -0
  4. package/client/qqbot-settings.js.bak-inc-1790865309979 +4967 -0
  5. package/client/qqbot-settings.js.bak-newer-1790865544006 +5023 -0
  6. package/client/qqbot-settings.js.bak-radio-1790899148239 +5076 -0
  7. package/client/qqbot-settings.js.bak-stick-1790865591295 +5033 -0
  8. package/client/qqbot-settings.js.bak-ui4b-1790879732726 +5046 -0
  9. package/dist/channel-tools.d.ts +18 -0
  10. package/dist/channel-tools.d.ts.map +1 -1
  11. package/dist/channel-tools.js +272 -3
  12. package/dist/channel-tools.js.map +1 -1
  13. package/dist/config.d.ts +9 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +4 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/features/chat-ledger.d.ts +13 -0
  18. package/dist/features/chat-ledger.d.ts.map +1 -1
  19. package/dist/features/chat-ledger.js +52 -0
  20. package/dist/features/chat-ledger.js.map +1 -1
  21. package/dist/features/context-memo.d.ts +29 -0
  22. package/dist/features/context-memo.d.ts.map +1 -0
  23. package/dist/features/context-memo.js +173 -0
  24. package/dist/features/context-memo.js.map +1 -0
  25. package/dist/features/contextless-store.d.ts +16 -0
  26. package/dist/features/contextless-store.d.ts.map +1 -1
  27. package/dist/features/contextless-store.js +24 -1
  28. package/dist/features/contextless-store.js.map +1 -1
  29. package/dist/gateway/debounce.d.ts.map +1 -1
  30. package/dist/gateway/debounce.js +32 -2
  31. package/dist/gateway/debounce.js.map +1 -1
  32. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  33. package/dist/gateway/middleware-setup.js +25 -5
  34. package/dist/gateway/middleware-setup.js.map +1 -1
  35. package/dist/middleware/media-history.d.ts +27 -0
  36. package/dist/middleware/media-history.d.ts.map +1 -1
  37. package/dist/middleware/media-history.js +19 -5
  38. package/dist/middleware/media-history.js.map +1 -1
  39. package/dist/session/session-manager.d.ts.map +1 -1
  40. package/dist/session/session-manager.js +198 -18
  41. package/dist/session/session-manager.js.map +1 -1
  42. package/dist/shared/path-utils.d.ts +6 -0
  43. package/dist/shared/path-utils.d.ts.map +1 -0
  44. package/dist/shared/path-utils.js +53 -0
  45. package/dist/shared/path-utils.js.map +1 -0
  46. package/dist/transport/inbound.d.ts +33 -0
  47. package/dist/transport/inbound.d.ts.map +1 -1
  48. package/dist/transport/inbound.js +232 -28
  49. package/dist/transport/inbound.js.map +1 -1
  50. package/dist/transport/msg-content-cache.d.ts +15 -0
  51. package/dist/transport/msg-content-cache.d.ts.map +1 -0
  52. package/dist/transport/msg-content-cache.js +105 -0
  53. package/dist/transport/msg-content-cache.js.map +1 -0
  54. package/dist/transport/quote-images.d.ts +17 -0
  55. package/dist/transport/quote-images.d.ts.map +1 -0
  56. package/dist/transport/quote-images.js +91 -0
  57. package/dist/transport/quote-images.js.map +1 -0
  58. package/dist/transport/quote-text.d.ts +30 -0
  59. package/dist/transport/quote-text.d.ts.map +1 -0
  60. package/dist/transport/quote-text.js +119 -0
  61. package/dist/transport/quote-text.js.map +1 -0
  62. package/package.json +1 -1
  63. package/settings-host.js +54 -6
@@ -1,10 +1,43 @@
1
1
  import type { SessionManager } from '../session/index.js';
2
2
  import type { ImQQBotConfig } from '../config.js';
3
3
  import type { Logger } from '../types.js';
4
+ export interface ResolvedQuote {
5
+ /** SDK 给的被引用消息索引(msg_idx)—— 本地台账回查用 */
6
+ refKey?: string;
7
+ text?: string;
8
+ entry?: {
9
+ senderId?: string;
10
+ content?: string;
11
+ };
12
+ attachments?: {
13
+ contentType?: string;
14
+ url?: string;
15
+ filename?: string;
16
+ asrText?: string;
17
+ }[];
18
+ }
19
+ /**
20
+ * 群消息时间头:同一会话**同一分钟内只注入一次**。
21
+ *
22
+ * 2026-10-01 主人要求(省 token):「同一分钟的消息或聚合消息不重复显示时间」。
23
+ * 原来每轮入站都无条件在最前面加 `[YYYY-MM-DD 周X HH:MM]` —— 同一分钟里连发几轮 / 聚合一波,
24
+ * 上下文里就堆出好几行一模一样的时间头(每行 ~25 字符,纯浪费)。
25
+ * 现在同分钟内的后续回合不再重复注入:时间信息不变(最近那条时间头就是当前时间),信息量零损失。
26
+ *
27
+ * @param cache 会话 → 上次时间头(进程内缓存,重启自然清空 → 重启后第一条会重新带上)
28
+ * @returns 要注入的时间行(形如 `[2026-10-01 周四 20:47]`),本次不需注入时返回 null
29
+ */
30
+ export declare function groupTimeHead(cache: Map<string, string>, scope: string, peerId: string, now?: Date): string | null;
4
31
  /**
5
32
  * 处理 QQ 入站消息(已经过 SDK 中间件链)
6
33
  */
7
34
  export declare function handleInbound(rawMsg: unknown, manager: SessionManager, config: ImQQBotConfig, logger: Logger, state?: Record<string, unknown>): Promise<void>;
35
+ export declare function buildQuoteBlock(quote: ResolvedQuote | undefined, stickerDir: string): string;
36
+ /**
37
+ * Layer 2 成品:引用块 + 紧随其后的 `[当前]`(短标记见 markers.ts)。
38
+ * ⚠️ 历史行请用 {@link buildQuoteBlock} —— 那边不该出现 `[当前]` 标记。
39
+ */
40
+ export declare function buildQuotePart(quote: ResolvedQuote | undefined, stickerDir: string): string;
8
41
  /**
9
42
  * Layer 3: 带发送者标签的用户消息
10
43
  * 引用消息功能开启时, 每条入站都带**短消息号**(msgRef, 形如 0913a; 群聊挂发送者标签, 私聊独立一行)
@@ -1 +1 @@
1
- {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../../src/transport/inbound.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAa,MAAM,EAA8B,MAAM,aAAa,CAAC;AAoGjF;;GAEG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,cAAc,EACvB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC,IAAI,CAAC,CAqtBf;AAqPD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,MAAM,CAgBR"}
1
+ {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../../src/transport/inbound.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAa,MAAM,EAA8B,MAAM,aAAa,CAAC;AA+CjF,MAAM,WAAW,aAAa;IAC5B,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,WAAW,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC7F;AAqDD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,GAAE,IAAiB,GAAG,MAAM,GAAG,IAAI,CAQ9H;AAID;;GAEG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,cAAc,EACvB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC,IAAI,CAAC,CA6uBf;AA+ND,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAiC5F;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAK3F;AAgID;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,MAAM,CAkBR"}
@@ -6,12 +6,15 @@ import { traceContextless } from '../features/contextless-store.js';
6
6
  import { applyInjectRules } from './inject-rules.js';
7
7
  import { inferMediaKind, mediaKindLabel } from './media-kind.js';
8
8
  import { replaceBotMention, mentionsOthers } from '../shared/mention-clean.js';
9
+ import { resolveMentionNames } from '../features/chat-ledger.js';
9
10
  import { MK, QUOTE_BEGIN_ALL, QUOTE_END_ALL, findFirstMarker, findLastMarker, escapeBlockMarkers, stripBlockMarkers } from './markers.js';
10
11
  import { dataRootOf, stickerDirOf } from '../gateway/data-root.js';
11
12
  import { registerMsgIndex } from './msg-index.js';
12
13
  import { createValueScorer, appendScoreLog, OTHER_MENTION_PENALTY } from '../features/value-score.js';
13
14
  import { getStickerStore, computeDHash } from '../features/sticker-store.js';
14
15
  import { lookupImagePath, rememberImagePath } from '../features/image-path-cache.js';
16
+ import { extractFaceImageUrls, slimQuoteText } from './quote-text.js';
17
+ import { lookupMsgContent, rememberMsgContent } from './msg-content-cache.js';
15
18
  import { recordImageUrl, lookupStickerIdByUrl } from '../features/image-url-ledger.js';
16
19
  import { pushQuote } from '../features/quote-cache.js';
17
20
  import { computeRelevance, touchAffinity, classifyEmo, getAffinityEntry, memoryStrength } from '../features/local-signals.js';
@@ -24,6 +27,29 @@ import { detectSelfDisclosure, selfDisclosureHint, bumpHint } from '../features/
24
27
  import { createLocalEmbedder } from '../features/local-embed.js';
25
28
  /** 小传注入节流(同一人 24h 内不重复): uid → 上次注入时间 */
26
29
  const memoInjectAt = new Map();
30
+ /** 每会话"上次注入的群时间头"(同分钟不重复注入,省 token) */
31
+ const lastGroupTimeHead = new Map();
32
+ /**
33
+ * 群消息时间头:同一会话**同一分钟内只注入一次**。
34
+ *
35
+ * 2026-10-01 主人要求(省 token):「同一分钟的消息或聚合消息不重复显示时间」。
36
+ * 原来每轮入站都无条件在最前面加 `[YYYY-MM-DD 周X HH:MM]` —— 同一分钟里连发几轮 / 聚合一波,
37
+ * 上下文里就堆出好几行一模一样的时间头(每行 ~25 字符,纯浪费)。
38
+ * 现在同分钟内的后续回合不再重复注入:时间信息不变(最近那条时间头就是当前时间),信息量零损失。
39
+ *
40
+ * @param cache 会话 → 上次时间头(进程内缓存,重启自然清空 → 重启后第一条会重新带上)
41
+ * @returns 要注入的时间行(形如 `[2026-10-01 周四 20:47]`),本次不需注入时返回 null
42
+ */
43
+ export function groupTimeHead(cache, scope, peerId, now = new Date()) {
44
+ const p = (n) => String(n).padStart(2, '0');
45
+ const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][now.getDay()];
46
+ const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${wd} ${p(now.getHours())}:${p(now.getMinutes())}`;
47
+ const key = `${scope}:${peerId}`;
48
+ if (cache.get(key) === stamp)
49
+ return null;
50
+ cache.set(key, stamp);
51
+ return `[${stamp}]`;
52
+ }
27
53
  // ── 主处理函数 ──
28
54
  /**
29
55
  * 处理 QQ 入站消息(已经过 SDK 中间件链)
@@ -88,6 +114,32 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
88
114
  catch (e) {
89
115
  logger.warn(`im-qqbot: 拼消息前的图片预检失败(不影响主流程): ${e instanceof Error ? e.message : String(e)}`);
90
116
  }
117
+ // 2026-10-01: 登记"这条消息的文本 + 图片本地路径",供**别人引用它**时还原
118
+ // (QQ 引用纯图片给不出图,只能自己记;见 transport/msg-content-cache.ts)
119
+ // ⚠️ 位置讲究:必须在上面那次"图片预检"(会 rememberImagePath) **之后** —— 那时查缓存才有本地路径。
120
+ try {
121
+ const _mi = String(msg.msgIdx ?? '').trim();
122
+ if (_mi) {
123
+ const _imgs = [];
124
+ for (const _a of (Array.isArray(msg.attachments) ? msg.attachments : [])) {
125
+ const _url = String(_a?.url ?? '').trim();
126
+ if (!_url)
127
+ continue;
128
+ // ⚠️ 2026-10-01: 用**两级解析**(内存缓存 → 图库台账反查)——
129
+ // 台账是落盘的、跨重启/超 TTL 也命中;只查内存缓存的话(TTL 1h),
130
+ // 稍早的图就记不下本地路径了(主人实测:引用的图明明在图库里,却没转成本地路径)。
131
+ const _lp = resolveImageLocalPath(_url, stickerDirOf(config));
132
+ if (_lp && !_imgs.includes(_lp))
133
+ _imgs.push(_lp);
134
+ }
135
+ const _txt = String(msg.content ?? '').trim();
136
+ rememberMsgContent(dataRootOf(config), `${scope}:${peerId}`, _mi, {
137
+ ...(_txt ? { t: _txt.slice(0, 200) } : {}),
138
+ ...(_imgs.length ? { imgs: _imgs } : {}),
139
+ });
140
+ }
141
+ }
142
+ catch { /* 台账失败不影响主链 */ }
91
143
  let agentBody = assembleAgentBody(msg, mwState, scope, logger, downloaded, refEnabled, msgRef, dataRootOf(config), stickerDirOf(config));
92
144
  if (!agentBody)
93
145
  return;
@@ -148,11 +200,12 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
148
200
  // 群聊时间戳(原"群守则"拼接位): 守则已迁 systemPrompt.section(session-manager 装配期注册,
149
201
  // 每请求进 system, 不再每轮塞 user 历史); 此处改为注入当前系统时间, 让 AI 每轮知道日期/星期/时刻。
150
202
  // 2026-09-15 省 token: 去掉"当前时间"四个字(上下文里一看就懂), 一条省 ~4 token。
203
+ // 2026-10-01 省 token(主人要求): **同一分钟只注入一次** —— 同分钟连发几轮/聚合一波时,
204
+ // 重复的时间头(每行 ~25 字符)没有信息量, 直接省掉。见 groupTimeHead()。
151
205
  if (scope === 'group') {
152
- const _now = new Date();
153
- const _p = (n) => String(n).padStart(2, '0');
154
- const _wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][_now.getDay()];
155
- agentBody = `[${_now.getFullYear()}-${_p(_now.getMonth() + 1)}-${_p(_now.getDate())} ${_wd} ${_p(_now.getHours())}:${_p(_now.getMinutes())}]\n\n${agentBody}`;
206
+ const _head = groupTimeHead(lastGroupTimeHead, scope, peerId, new Date());
207
+ if (_head)
208
+ agentBody = `${_head}\n\n${agentBody}`;
156
209
  }
157
210
  logger.debug(`Processing: scope=${scope} peerId=${peerId} body="${agentBody.slice(0, 200)}"`);
158
211
  // ── 获取或创建会话 ──
@@ -788,15 +841,52 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
788
841
  * 组装 agentBody — AI 实际看到的完整上下文
789
842
  */
790
843
  function assembleAgentBody(msg, state, scope, logger, downloaded, enableRef, msgRef, dataRoot, stickerDir) {
791
- const userContent = buildUserContent(msg, state, logger);
844
+ const userContentRaw = buildUserContent(msg, state, logger);
845
+ // 2026-10-01 主人实测「别人 @ 别人怎么没转换成昵称,依然是 id」:
846
+ // 正文里 @ 别人的 `<@openid>` 查**群成员台账**换成 `@昵称`(查不到退化成 @短id);
847
+ // 顺手省 token —— `<@07B470BDA5052489D2D0532C2CC2A2EB>`(35 字符) → `@难崩`(3 字符)。
848
+ const userContent = resolveMentionNames(userContentRaw, stickerDir, scope === 'group' ? msg.groupOpenid : undefined);
792
849
  if (!userContent && (!msg.attachments || msg.attachments.length === 0))
793
850
  return null;
794
- let quotePart = buildQuotePart(state.quote);
851
+ // ⚠️ 2026-10-01: QQ 引用**纯图片**时 msg_elements 给不出图 —— 先用本地台账补一把(见 msg-content-cache.ts)
852
+ const quotePeer = `${scope}:${scope === 'group' ? (msg.groupOpenid ?? msg.senderId) : msg.senderId}`;
853
+ const quote = enrichQuoteFromCache(state.quote, dataRoot, quotePeer);
854
+ let quotePart = buildQuotePart(quote, stickerDir);
795
855
  // 引用消息(2026-09-13): SDK 中间件没解析出 quote 时, 自己从 103/msg_elements 提取被引用原文
796
856
  if (!quotePart && enableRef) {
797
857
  const quoted = extractQuotedContent(msg);
798
- if (quoted)
799
- quotePart = `${MK.QUOTE_BEGIN}\n${escapeBlockMarkers(quoted)}\n${MK.QUOTE_END}\n${MK.CURRENT}\n`;
858
+ // ⚠️ 2026-10-01 主人实测抓到的**漏网之鱼**:这条路(SDK 没解析出 quote 时的兜底)拿到的是
859
+ // `msg_elements` 的**原串** —— `=== 消息 1 === [消息内容] … [消息类型] 引用消息 …` 模板串,
860
+ // 原来没剥壳就直接塞进上下文(还会被 trimQuoteBlock 按"519 字"截断+缓存,看着莫名其妙)。
861
+ // 这里跟 buildQuoteBlock 用**同一套清洗**:剥模板壳 + 只留最外层那句 + 表情转可读。
862
+ const lines = [];
863
+ const slim = quoted ? slimQuoteText(quoted, 0) : '';
864
+ if (slim)
865
+ lines.push(escapeBlockMarkers(slim));
866
+ // ⚠️ 2026-10-01 补(主人问"多图+文本混合考虑了吗"时发现的缺口):
867
+ // SDK 连 quote 都没解析出来时,**也要查我们自己的内容台账** —— 否则"引用一条图片表情 /
868
+ // 纯图片消息"永远是空的(QQ 那边只给一个空表情标签,没有任何媒体标识)。
869
+ // 被引用消息的索引藏在 `message_scene.ext` 的 `ref_msg_idx=` 里。
870
+ // 台账天然支持**多图 + 文本混合**(imgs 是数组,逐张出行)。
871
+ const _ext = Array.isArray(msg.message_scene?.ext) ? msg.message_scene.ext : [];
872
+ const _refRaw = _ext.find((x) => typeof x === 'string' && x.startsWith('ref_msg_idx='));
873
+ const refIdx = typeof _refRaw === 'string' ? _refRaw.slice('ref_msg_idx='.length).trim() : '';
874
+ if (refIdx) {
875
+ const hit = lookupMsgContent(dataRoot, quotePeer, refIdx);
876
+ if (hit) {
877
+ if (!slim && hit.t)
878
+ lines.push(escapeBlockMarkers(slimQuoteText(hit.t, 0)));
879
+ for (const p of hit.imgs ?? []) {
880
+ const line = describeQuoteAttachment({ contentType: 'image/*', url: p }, stickerDir);
881
+ if (line)
882
+ lines.push(line);
883
+ }
884
+ }
885
+ }
886
+ // ⚠️ 2026-10-01:这里**不再自带** `[当前]` —— 它由 Layer 5(buildAgentBody) 统一输出,
887
+ // 否则群聊带引用时会出现两个 `[当前]`,引用块被夹在中间(主人实测:"引用被塞到当前外面")。
888
+ if (lines.length > 0)
889
+ quotePart = `${MK.QUOTE_BEGIN}\n${lines.join('\n')}\n${MK.QUOTE_END}\n`;
800
890
  }
801
891
  // ⚠️ 2026-09-13 主人要求(省 token): 引用原文只给**前 QUOTE_KEEP 字**,
802
892
  // 完整原文进本地缓存(每会话最多 10 条) → AI 需要时用 `quote_view` 工具取。
@@ -806,7 +896,7 @@ function assembleAgentBody(msg, state, scope, logger, downloaded, enableRef, msg
806
896
  const batchDispatch = state.batchDispatch === true;
807
897
  const aggregated = state.aggregated === true;
808
898
  const userMessage = buildUserMessage(userContent, quotePart, msg.senderId, msg.senderName, isGroup, wasMentioned, msgRef);
809
- const dynamicCtx = buildDynamicCtx(msg, state, downloaded);
899
+ const dynamicCtx = buildDynamicCtx(msg, downloaded);
810
900
  const base = dynamicCtx ? `${dynamicCtx}${userMessage}` : userMessage;
811
901
  // ⚠️ 2026-09-10 去重(主人: "图片链接重复两次"): 聚合/冷却重新派发时, 同一条消息会**既被
812
902
  // mediaHistoryBuffer 记进历史、又作为当前消息出现** → 同一条消息(含媒体 URL)在上下文里出现
@@ -913,13 +1003,121 @@ function cleanTextForScore(raw) {
913
1003
  }
914
1004
  /**
915
1005
  * Layer 2: 引用消息块
1006
+ *
1007
+ * ⚠️ 2026-10-01 修(主人实测:群里有人**引用一张表情包**回话,AI 完全不知道引用的是哪张图 → 答非所问):
1008
+ * 两个叠在一起的坑:
1009
+ * ① 原判空只看 `text`/`entry.content` —— 对方引用纯图(自己没有文字)时整块引用**直接消失**;
1010
+ * ② 即使非空,SDK 的 `quote-ref` 中间件把附件**降级成占位文本**(`[image]` / `[image: a.png]`,
1011
+ * 见 `@tencent-connect/qqbot-nodejs/src/middleware/quote-ref.ts` 的 `buildText`)——
1012
+ * **URL 被丢掉了**,模型对着 4 个字干瞪眼。
1013
+ * 好在 `quote.attachments[].url` 一直都在手边(SDK 已解析,只是没人用)。
1014
+ * → 现在把被引用消息的附件**内联进引用块**,图片给「**本地路径优先、URL 兜底**」的可读目标
1015
+ * (本地路径模型能用视觉工具直接读图;QQ 链接又长又会过期),语音给 ASR 转写,文件给文件名+URL。
1016
+ */
1017
+ /**
1018
+ * 用本地台账补齐 QQ 没给全的被引用内容(2026-10-01 主人实测:引用**纯图片**看不到是哪张图)。
1019
+ * QQ 引用纯图时 msg_elements 可能只给个文件名、甚至什么都不给 → 按 SDK 给的 refKey(= 被引用消息的 msg_idx)
1020
+ * 回查我们**入站时**记下的台账(见 msg-content-cache.ts),补出图片**本地路径**(AI 能直接读)。
1021
+ * 已经够了(有正文 + 有附件)就直接原样返回,零开销。
916
1022
  */
917
- function buildQuotePart(quote) {
918
- if (!quote?.text && !quote?.entry?.content)
1023
+ function enrichQuoteFromCache(q, dataRoot, peerKey) {
1024
+ if (!q)
1025
+ return q;
1026
+ const refKey = String(q.refKey ?? '').trim();
1027
+ if (!refKey)
1028
+ return q;
1029
+ const rawText = String(q.text ?? q.entry?.content ?? '').trim();
1030
+ const hasText = Boolean(rawText) && !isPlaceholderQuoteText(rawText);
1031
+ const hasAtt = Array.isArray(q.attachments) && q.attachments.length > 0;
1032
+ if (hasText && hasAtt)
1033
+ return q;
1034
+ const hit = lookupMsgContent(dataRoot, peerKey, refKey);
1035
+ if (!hit)
1036
+ return q;
1037
+ const out = { ...q };
1038
+ if (!hasText && hit.t)
1039
+ out.text = hit.t;
1040
+ if (!hasAtt && hit.imgs && hit.imgs.length) {
1041
+ // 走"本机路径"分支:describeQuoteAttachment 会原样给出路径(图库/缓存查不到也不会坏)
1042
+ out.attachments = hit.imgs.map((p) => ({ contentType: 'image/*', url: p }));
1043
+ }
1044
+ return out;
1045
+ }
1046
+ export function buildQuoteBlock(quote, stickerDir) {
1047
+ const atts = Array.isArray(quote?.attachments) ? quote.attachments : [];
1048
+ const rawText = String(quote?.text ?? quote?.entry?.content ?? '').trim();
1049
+ // 2026-10-01: 表情消息(图片表情 / 收藏表情)在 msg_elements 里只给一个 `<faceType=…,ext="base64">` ——
1050
+ // 先把 ext 里**可能藏着**的图片 URL 挖出来当附件用;实测常见形态 ext=`{"text":""}` 是空的,
1051
+ // 那种只能靠"那条消息入站时登记的台账"回查(见 msg-content-cache.ts / enrichQuoteFromCache)。
1052
+ const faceImgs = extractFaceImageUrls(rawText);
1053
+ // SDK 由附件拼出的占位文本(纯 `[image]` / `[voice: xx]`…)在有附件行时不必重复一遍
1054
+ const text = (atts.length > 0 || faceImgs.length > 0) && isPlaceholderQuoteText(rawText) ? '' : rawText;
1055
+ if (!text && atts.length === 0 && faceImgs.length === 0)
919
1056
  return '';
920
- const quoteText = escapeBlockMarkers(quote.text || quote.entry?.content || 'Original content unavailable');
921
- // 短标记(2026-09-15 省 token): [引]…[/引][当前] —— 见 markers.ts
922
- return `${MK.QUOTE_BEGIN}\n${quoteText}\n${MK.QUOTE_END}\n${MK.CURRENT}\n`;
1057
+ const lines = [];
1058
+ // 2026-10-01: 剥掉 QQ 的引用模板壳(`=== 消息 N === [消息内容]…[消息类型] 引用消息 …`)并压平,
1059
+ // 只去噪声不限长 —— 限长交给调用方(当前消息走 trimQuoteBlock 的 60 字+缓存,历史行走媒体历史那边)
1060
+ if (text) {
1061
+ // 表情标签在这里转成 `【表情: 微笑】`(原样是 `<faceType=6,faceId="0",ext="…">`,谁也看不懂)
1062
+ const slim = slimQuoteText(text, 0);
1063
+ if (slim)
1064
+ lines.push(escapeBlockMarkers(slim));
1065
+ }
1066
+ for (const a of atts) {
1067
+ const line = describeQuoteAttachment(a, stickerDir);
1068
+ if (line)
1069
+ lines.push(line);
1070
+ }
1071
+ // 表情标签 ext 里挖到的图片 URL(图片表情的其它端形态)→ 同样给一行"被引用的图片"
1072
+ for (const u of faceImgs) {
1073
+ const line = describeQuoteAttachment({ contentType: 'image/*', url: u }, stickerDir);
1074
+ if (line)
1075
+ lines.push(line);
1076
+ }
1077
+ if (lines.length === 0)
1078
+ return '';
1079
+ // 排版(2026-10-01 专家团共识): 引用内容整体退 2 格 —— 视觉上从属, 不再和正文同权重
1080
+ const indented = lines.map((l) => ' ' + l).join('\n');
1081
+ return `${MK.QUOTE_BEGIN}\n${indented}\n${MK.QUOTE_END}`;
1082
+ }
1083
+ /**
1084
+ * Layer 2 成品:引用块 + 紧随其后的 `[当前]`(短标记见 markers.ts)。
1085
+ * ⚠️ 历史行请用 {@link buildQuoteBlock} —— 那边不该出现 `[当前]` 标记。
1086
+ */
1087
+ export function buildQuotePart(quote, stickerDir) {
1088
+ const blk = buildQuoteBlock(quote, stickerDir);
1089
+ // ⚠️ 2026-10-01:`[当前]` 由 Layer 5 统一输出 —— 引用块与正文都属于当前消息,
1090
+ // 引用块自己再带一个会让 `[当前]` 重复、引用块看起来"在当前消息之外"(主人实测)。
1091
+ return blk ? `${blk}\n` : '';
1092
+ }
1093
+ /** SDK 的附件占位文本(整段只有 `[image]`/`[video: x]`/`[voice: x]`/`[file: x]` 这类行) */
1094
+ function isPlaceholderQuoteText(t) {
1095
+ if (!t)
1096
+ return true;
1097
+ return t.split('\n').every((l) => /^\[(image|video|voice|file)(:\s*[^\]]*)?\]$/i.test(l.trim()));
1098
+ }
1099
+ /** 被引用消息的附件 → 一行可读描述(图片优先给**本地路径**,模型能直接读图) */
1100
+ function describeQuoteAttachment(a, stickerDir) {
1101
+ const ct = String(a?.contentType ?? '').toLowerCase();
1102
+ const name = String(a?.filename ?? '').trim();
1103
+ const url = String(a?.url ?? '').trim();
1104
+ const ext = (name.split('.').pop() ?? '').toLowerCase();
1105
+ const isImg = ct.startsWith('image/') || /^(jpe?g|png|gif|webp|bmp)$/.test(ext);
1106
+ const isVoice = ct.startsWith('audio/') || ct.includes('voice') || /^(silk|amr|ogg|mp3|wav|m4a|aac|opus)$/.test(ext);
1107
+ const isVideo = ct.startsWith('video/') || /^(mp4|webm|mov|m4v)$/.test(ext);
1108
+ if (isVoice) {
1109
+ const asr = String(a?.asrText ?? '').trim();
1110
+ return asr ? `🎵 被引用的语音转写: ${asr}` : `🎵 被引用的语音${name ? ': ' + name : ''}`;
1111
+ }
1112
+ if (isImg) {
1113
+ // 与历史图片瘦身同一条解析链(缓存 → 图库台账 → 当前真实路径); 命中就给本地路径, 否则保留 QQ 原链
1114
+ const local = url ? resolveImageLocalPath(url, stickerDir) : undefined;
1115
+ const target = local ?? url;
1116
+ return target ? `📷 被引用的图片: ${target}` : `📷 被引用的图片${name ? ': ' + name : ''}`;
1117
+ }
1118
+ if (isVideo)
1119
+ return `🎬 被引用的视频${name ? ': ' + name : ''}${url ? ' → ' + url : ''}`;
1120
+ return `📎 被引用的文件${name ? ': ' + name : ''}${url ? ' → ' + url : ''}`;
923
1121
  }
924
1122
  /**
925
1123
  * (转义实现已搬到 `markers.ts` 的 `escapeBlockMarkers` —— 新旧两套标记一起转义, 见那里的注释)
@@ -972,6 +1170,9 @@ function localizeHistoryImages(text, stickerDir) {
972
1170
  * 截断切错位置、外层的 `[Quoted message ends]` 反而留在后面。
973
1171
  * 现在: ①取**第一个 begin + 最后一个 end**(认外层) ②引用原文里的标记先转义(见 escapeQuoteMarkers)。
974
1172
  */
1173
+ /** 引用块里的"附件行"(`📷 被引用的图片: …` 等)—— 截断豁免标记,别被 60 字上限挤掉
1174
+ * ⚠️ 别写成字符类 `[📷🎵🎬📎]` —— emoji 是代理对,在字符类里会被拆成单个 UTF-16 码元,匹配不上。 */
1175
+ const QUOTE_ATT_LINE_RE = /^\s*(?:📷|🎵|🎬|📎)\s*被引用的/;
975
1176
  function trimQuoteBlock(quotePart, dataRoot, msg, logger) {
976
1177
  if (!quotePart)
977
1178
  return quotePart;
@@ -981,13 +1182,19 @@ function trimQuoteBlock(quotePart, dataRoot, msg, logger) {
981
1182
  if (!b || !e || e.idx <= b.idx)
982
1183
  return quotePart;
983
1184
  const full = quotePart.slice(b.idx + b.len, e.idx).trim();
984
- if (full.length <= QUOTE_KEEP)
1185
+ // ⚠️ 2026-10-01: 附件行**不参与 60 字截断** —— 那是"被引用的是哪张图"的唯一线索,
1186
+ // 被前面的文字挤掉就等于白修(见 buildQuotePart 的注释)。
1187
+ const allLines = full.split('\n');
1188
+ const attLines = allLines.filter((l) => QUOTE_ATT_LINE_RE.test(l.trim()));
1189
+ const textPart = allLines.filter((l) => !QUOTE_ATT_LINE_RE.test(l.trim())).join('\n').trim();
1190
+ // 文字部分没超长 → 整块原样返回(附件行照旧留着,也不必打"已缓存"提示)
1191
+ if (textPart.length <= QUOTE_KEEP)
985
1192
  return quotePart;
986
1193
  const key = `${msg.kind === 'group' ? 'group' : 'c2c'}:${(msg.kind === 'group' ? msg.groupOpenid : undefined) ?? msg.senderId}`;
987
1194
  try {
988
1195
  const hit = pushQuote(dataRoot, key, full, msg.senderName);
989
- const head = full.slice(0, QUOTE_KEEP).replace(/\s+/g, ' ');
990
- logger.debug(`[引用] 原文 ${full.length} 字 → 只给前 ${QUOTE_KEEP} 字(缓存 #${hit.id})`);
1196
+ const head = [textPart.slice(0, QUOTE_KEEP).replace(/\s+/g, ' '), ...attLines].filter(Boolean).join('\n');
1197
+ logger.debug(`[引用] 原文 ${full.length} 字 → 只给前 ${QUOTE_KEEP} 字(缓存 #${hit.id}); 附件行 ${attLines.length} 条不截断`);
991
1198
  return `${quotePart.slice(0, b.idx)}${quotePart.slice(b.idx, b.idx + b.len)}\n${head}…[引用#${hit.id}: 全文 ${full.length} 字已缓存, 需要时用 quote_view 查]\n${quotePart.slice(e.idx)}`;
992
1199
  }
993
1200
  catch {
@@ -1032,12 +1239,14 @@ export function buildUserMessage(userContent, quotePart, senderId, senderName, i
1032
1239
  const idPart = wasMentioned ? ` (${senderId})` : '';
1033
1240
  const refPart = msgRef ? ` #${msgRef}` : '';
1034
1241
  const senderTag = `[${displayName}${idPart}${refPart}]`;
1035
- return `${quotePart}${senderTag} ${userContent}${mentionTag}`;
1242
+ // 排版: 多行正文才缩进(单行保持原样, 不徒增视觉噪声)
1243
+ const body = userContent.includes('\n') ? userContent.split('\n').map((l) => ' ' + l).join('\n') : userContent;
1244
+ return `${quotePart}${senderTag} ${body}${mentionTag}`;
1036
1245
  }
1037
1246
  /**
1038
1247
  * Layer 4: 媒体元数据上下文
1039
1248
  */
1040
- function buildDynamicCtx(msg, state, downloaded) {
1249
+ function buildDynamicCtx(msg, downloaded) {
1041
1250
  const lines = [];
1042
1251
  if (!msg.attachments || msg.attachments.length === 0)
1043
1252
  return '';
@@ -1078,14 +1287,6 @@ function buildDynamicCtx(msg, state, downloaded) {
1078
1287
  }
1079
1288
  if (lines.length === 0)
1080
1289
  return '';
1081
- const quoteAttachments = state.quote?.attachments;
1082
- if (quoteAttachments && quoteAttachments.length > 0) {
1083
- lines.push('[Reference attachments]');
1084
- for (const qa of quoteAttachments) {
1085
- const label = qa.asrText ? `Voice: ${qa.asrText}` : (qa.filename ?? qa.contentType ?? 'attachment');
1086
- lines.push(` - ${label}`);
1087
- }
1088
- }
1089
1290
  return lines.join('\n') + '\n\n';
1090
1291
  }
1091
1292
  /**
@@ -1109,7 +1310,10 @@ function buildAgentBody(base, history, isGroup, wasMentioned, batchDispatch = fa
1109
1310
  // 2026-09-12 token 瘦身(主人定): 历史行默认**只给昵称**; 只有"当时 @ 过 bot 的那条"带 openid ——
1110
1311
  // 32 位 openid 每行占 20+ token, limit=20 时每轮白烧 ~640; 要 id 时用 session_list / 台账反查。
1111
1312
  const mentioned = h.mentioned === true;
1112
- return mentioned && h.senderId ? `[${name} (${h.senderId})] ${h.content}` : `[${name}] ${h.content}`;
1313
+ // 排版: 多行 content 退 2 格 —— 让"一条消息"视觉成块
1314
+ const raw = String(h.content ?? '');
1315
+ const c = raw.includes('\n') ? raw.split('\n').map((l) => ' ' + l).join('\n') : raw;
1316
+ return mentioned && h.senderId ? `[${name} (${h.senderId})] ${c}` : `[${name}] ${c}`;
1113
1317
  });
1114
1318
  return [
1115
1319
  aggregated ? '[系统提示] 以下是上次回复前群友所发(非对你的回应), 通读后综合回应。' : '',