@zaofan/dsh-qqbot 1.5.1 → 1.5.4

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 (56) hide show
  1. package/README_EN.md +7 -7
  2. package/client/qqbot-settings.js +36 -5
  3. package/dist/channel-tools.d.ts.map +1 -1
  4. package/dist/channel-tools.js +43 -1
  5. package/dist/channel-tools.js.map +1 -1
  6. package/dist/config.d.ts +34 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +17 -1
  9. package/dist/config.js.map +1 -1
  10. package/dist/features/agg-score.d.ts +1 -1
  11. package/dist/features/agg-score.js +1 -1
  12. package/dist/features/extension-store.d.ts.map +1 -1
  13. package/dist/features/extension-store.js +19 -4
  14. package/dist/features/extension-store.js.map +1 -1
  15. package/dist/features/four-source.d.ts +1 -1
  16. package/dist/features/four-source.js +1 -1
  17. package/dist/features/local-signals.js +2 -2
  18. package/dist/features/local-signals.js.map +1 -1
  19. package/dist/features/people-memo.js +1 -1
  20. package/dist/features/tendency-samples.js +1 -1
  21. package/dist/features/value-sample.d.ts +3 -1
  22. package/dist/features/value-sample.d.ts.map +1 -1
  23. package/dist/features/value-sample.js +3 -1
  24. package/dist/features/value-sample.js.map +1 -1
  25. package/dist/features/value-score.d.ts +27 -0
  26. package/dist/features/value-score.d.ts.map +1 -1
  27. package/dist/features/value-score.js +42 -3
  28. package/dist/features/value-score.js.map +1 -1
  29. package/dist/gateway/debounce.d.ts.map +1 -1
  30. package/dist/gateway/debounce.js +40 -6
  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 +3 -0
  34. package/dist/gateway/middleware-setup.js.map +1 -1
  35. package/dist/middleware/media-history.d.ts +2 -0
  36. package/dist/middleware/media-history.d.ts.map +1 -1
  37. package/dist/middleware/media-history.js +32 -3
  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 +13 -2
  41. package/dist/session/session-manager.js.map +1 -1
  42. package/dist/shared/mention-clean.d.ts +13 -0
  43. package/dist/shared/mention-clean.d.ts.map +1 -1
  44. package/dist/shared/mention-clean.js +19 -0
  45. package/dist/shared/mention-clean.js.map +1 -1
  46. package/dist/transport/inbound.d.ts +10 -0
  47. package/dist/transport/inbound.d.ts.map +1 -1
  48. package/dist/transport/inbound.js +131 -53
  49. package/dist/transport/inbound.js.map +1 -1
  50. package/dist/transport/markers.d.ts +59 -0
  51. package/dist/transport/markers.d.ts.map +1 -0
  52. package/dist/transport/markers.js +97 -0
  53. package/dist/transport/markers.js.map +1 -0
  54. package/docs/USER-GUIDE.md +461 -461
  55. package/package.json +124 -124
  56. package/settings-host.js +3095 -3071
@@ -39,4 +39,23 @@ export function replaceBotMention(content, mentions, wasMentioned) {
39
39
  }
40
40
  return text;
41
41
  }
42
+ /**
43
+ * 这条消息 @ 的是**别人**(不是她)吗?
44
+ *
45
+ * 2026-09-15 主人修正过一次语义:"**不是**没人@她的时候降低评分, 是**有人@别人**的时候降低评分"。
46
+ * · 没被 @ ≠ 不该回话 —— 那恰恰是她该主动挑话插的常态,扣分会把她变成"等点名才说话";
47
+ * · 真正该扣的是"这句 @ 的是别人":那轮对话的方向是那个人,她基本不该插嘴。
48
+ *
49
+ * 为什么用 QQ 的 mentions 元数据而不是正文里的 `@某某` 字样:
50
+ * 正文里 @ 别人的标记形式不固定,而 mentions 是平台给的权威列表。
51
+ * ⚠️ 两个平台细节:① 她被 @ 时(GROUP_AT_MESSAGE_CREATE)QQ 的 mentions **不含 bot 自身**,
52
+ * 所以要先看 wasMentioned;② 没被 @ 时(GROUP_MESSAGE_CREATE)mentions = 这条 @ 的全部人。
53
+ */
54
+ export function mentionsOthers(mentions, wasMentioned) {
55
+ if (wasMentioned)
56
+ return false; // @ 的就是她 → 走"必回"那条路,不扣
57
+ if (!Array.isArray(mentions) || mentions.length === 0)
58
+ return false;
59
+ return mentions.some((m) => m && m.is_you !== true);
60
+ }
42
61
  //# sourceMappingURL=mention-clean.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"mention-clean.js","sourceRoot":"","sources":["../../src/shared/mention-clean.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,MAAM,UAAU,iBAAiB,CAC/B,OAAkC,EAClC,QAA+B,EAC/B,YAAsB;IAEtB,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,0CAA0C;IAC1C,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5B,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;YACzB,IAAI,CAAC,EAAE,MAAM,IAAI,CAAC,CAAC,EAAE;gBAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC3C,CAAC;IACH,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,IAAI,GAAG,GAAG,IAAI,CAAC;QACf,KAAK,MAAM,GAAG,IAAI,MAAM,EAAE,CAAC;YACzB,IAAI,CAAC;gBACH,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,GAAG,GAAG,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC;YAC5D,CAAC;YAAC,MAAM,CAAC,CAAC,cAAc,CAAC,CAAC;QAC5B,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED,uDAAuD;IACvD,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,sBAAsB,CAAC,CAAC,CAAC;QACvD,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC,OAAO,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;IAC1E,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC"}
1
+ {"version":3,"file":"mention-clean.js","sourceRoot":"","sources":["../../src/shared/mention-clean.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,MAAM,UAAU,iBAAiB,CAC/B,OAAkC,EAClC,QAA+B,EAC/B,YAAsB;IAEtB,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,0CAA0C;IAC1C,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5B,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;YACzB,IAAI,CAAC,EAAE,MAAM,IAAI,CAAC,CAAC,EAAE;gBAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC3C,CAAC;IACH,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,IAAI,GAAG,GAAG,IAAI,CAAC;QACf,KAAK,MAAM,GAAG,IAAI,MAAM,EAAE,CAAC;YACzB,IAAI,CAAC;gBACH,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,GAAG,GAAG,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC;YAC5D,CAAC;YAAC,MAAM,CAAC,CAAC,cAAc,CAAC,CAAC;QAC5B,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED,uDAAuD;IACvD,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,sBAAsB,CAAC,CAAC,CAAC;QACvD,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC,OAAO,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;IAC1E,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,cAAc,CAAC,QAA0C,EAAE,YAAqB;IAC9F,IAAI,YAAY;QAAE,OAAO,KAAK,CAAC,CAA0B,uBAAuB;IAChF,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACpE,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC;AACtD,CAAC"}
@@ -5,4 +5,14 @@ import type { Logger } from '../types.js';
5
5
  * 处理 QQ 入站消息(已经过 SDK 中间件链)
6
6
  */
7
7
  export declare function handleInbound(rawMsg: unknown, manager: SessionManager, config: ImQQBotConfig, logger: Logger, state?: Record<string, unknown>): Promise<void>;
8
+ /**
9
+ * Layer 3: 带发送者标签的用户消息
10
+ * 引用消息功能开启时, 每条入站都带**短消息号**(msgRef, 形如 0913a; 群聊挂发送者标签, 私聊独立一行)
11
+ * —— AI 想引用对方时在正文写 [rf:短号](2026-09-13 主人定: 短号省 token, 台账见 msg-index.ts)。
12
+ * 群聊标签的形状(2026-09-15 定稿, 与历史行同口径):
13
+ * 没被 @ → `[昵称 #短号]` ← 不带 openid(省 token; 32 位 id 每行 20+ token)
14
+ * 被 @ 了 → `[昵称 (openid) #短号]` + 正文后 ` (@you)` ← 要回 @ 他/认人才给 id
15
+ * 短号两边都留(引用标记 + id_lookup 反查 openid 的入口, 不能省)。
16
+ */
17
+ export declare function buildUserMessage(userContent: string, quotePart: string, senderId: string, senderName: string | undefined, isGroup: boolean, wasMentioned: boolean, msgRef: string): string;
8
18
  //# sourceMappingURL=inbound.d.ts.map
@@ -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;AAgGjF;;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,CA+lBf"}
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;AAmGjF;;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,CAiqBf;AAkPD;;;;;;;;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"}
@@ -4,10 +4,11 @@ import { existsSync, readFileSync } from 'node:fs';
4
4
  import { clearGroupHistory } from '../features/history-store.js';
5
5
  import { applyInjectRules } from './inject-rules.js';
6
6
  import { inferMediaKind, mediaKindLabel } from './media-kind.js';
7
- import { replaceBotMention } from '../shared/mention-clean.js';
7
+ import { replaceBotMention, mentionsOthers } from '../shared/mention-clean.js';
8
+ import { MK, QUOTE_BEGIN_ALL, QUOTE_END_ALL, findFirstMarker, findLastMarker, escapeBlockMarkers, stripBlockMarkers } from './markers.js';
8
9
  import { dataRootOf, stickerDirOf } from '../gateway/data-root.js';
9
10
  import { registerMsgIndex } from './msg-index.js';
10
- import { createValueScorer, appendScoreLog } from '../features/value-score.js';
11
+ import { createValueScorer, appendScoreLog, OTHER_MENTION_PENALTY } from '../features/value-score.js';
11
12
  import { getStickerStore, computeDHash } from '../features/sticker-store.js';
12
13
  import { lookupImagePath, rememberImagePath } from '../features/image-path-cache.js';
13
14
  import { recordImageUrl, lookupStickerIdByUrl } from '../features/image-url-ledger.js';
@@ -111,11 +112,12 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
111
112
  catch { /* 提醒失败不影响主链 */ }
112
113
  // 群聊时间戳(原"群守则"拼接位): 守则已迁 systemPrompt.section(session-manager 装配期注册,
113
114
  // 每请求进 system, 不再每轮塞 user 历史); 此处改为注入当前系统时间, 让 AI 每轮知道日期/星期/时刻。
115
+ // 2026-09-15 省 token: 去掉"当前时间"四个字(上下文里一看就懂), 一条省 ~4 token。
114
116
  if (scope === 'group') {
115
117
  const _now = new Date();
116
118
  const _p = (n) => String(n).padStart(2, '0');
117
119
  const _wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][_now.getDay()];
118
- agentBody = `[当前时间 ${_now.getFullYear()}-${_p(_now.getMonth() + 1)}-${_p(_now.getDate())} ${_wd} ${_p(_now.getHours())}:${_p(_now.getMinutes())}]\n\n${agentBody}`;
120
+ agentBody = `[${_now.getFullYear()}-${_p(_now.getMonth() + 1)}-${_p(_now.getDate())} ${_wd} ${_p(_now.getHours())}:${_p(_now.getMinutes())}]\n\n${agentBody}`;
119
121
  }
120
122
  logger.debug(`Processing: scope=${scope} peerId=${peerId} body="${agentBody.slice(0, 200)}"`);
121
123
  // ── 获取或创建会话 ──
@@ -194,6 +196,8 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
194
196
  const lmOn = (ovRaw.enabled ?? lmCfg?.enabled) !== false;
195
197
  if (scope === 'group' && lmOn && gate !== 'off') {
196
198
  const mentioned = mwState.mention?.wasMentioned === true;
199
+ // 这条消息 @ 的是**别人**(不是她)吗?—— 2026-09-15 主人修正的扣分依据(判据见 mentionsOthers 的注释)
200
+ const mentionedOthers = mentionsOthers(msg.mentions, mentioned);
197
201
  try {
198
202
  const scorer = createValueScorer({
199
203
  dataRoot: dataRootOf(config),
@@ -204,10 +208,24 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
204
208
  // 评分输入清洗(2026-09-13 主人定): 剥掉合并转发/引用块的结构标记, 只留真实语义。
205
209
  // 引用消息的**被引用原文**单独算一次分, 与当前消息取较高者(她在回应那句话 → 往往也需要她参与)。
206
210
  const rawContent = String(msg.content || '');
207
- const qm = /\[Quoted message begins\]([\s\S]*?)\[Quoted message ends\]/i.exec(rawContent);
208
- const currentRaw = qm ? rawContent.replace(qm[0], ' ') : rawContent;
209
- const plain = cleanTextForScore(currentRaw);
210
- const quotedPlain = qm ? cleanTextForScore(qm[1] ?? '') : '';
211
+ // 引用块切分(认新旧标记: 新 [引]…[/引], 老 [Quoted message begins]…[ends])
212
+ // ⚠️ 用"第一个 begin + 最后一个 end"(不用非贪婪) —— 否则原文里自带标记时会截错(2026-09-13 踩过)
213
+ const _qb = findFirstMarker(rawContent, QUOTE_BEGIN_ALL);
214
+ const _qe = findLastMarker(rawContent, QUOTE_END_ALL);
215
+ const _quoteOk = Boolean(_qb && _qe && _qe.idx > _qb.idx);
216
+ const quotedInner = _quoteOk ? rawContent.slice(_qb.idx + _qb.len, _qe.idx) : undefined;
217
+ const quotedFull = _quoteOk ? rawContent.slice(_qb.idx, _qe.idx + _qe.len) : undefined;
218
+ const currentRaw = quotedFull ? rawContent.replace(quotedFull, ' ') : rawContent;
219
+ // ⚠️ 2026-09-15 修(主人:"语音转文字消息没有参与评分吗"):
220
+ // 转录文字原来**只进 userContent**(给 AI 看的那份),而评分用的是 msg.content →
221
+ // 语音消息在评分链里永远是"无文字"(score=无),哪怕它被清清楚楚转成了文字 ✗
222
+ // 现在把转录并入评分文本:语音里说的话,跟打字说的话**一样参与"值不值得接"的判断**。
223
+ const voiceForScore = extractVoiceTexts(msg.attachments, mwState.processedAttachments, logger)
224
+ .map((v) => v.text)
225
+ .filter((t) => t.trim() !== '')
226
+ .join(' ');
227
+ const plain = cleanTextForScore([currentRaw, voiceForScore].filter((s) => s !== '').join(' '));
228
+ const quotedPlain = quotedInner !== undefined ? cleanTextForScore(quotedInner) : '';
211
229
  // ── 图片消息(2026-09-13 主人定 a+b + B预检) ──
212
230
  // 小模型只认文字 → 图片本身没法直接评分。三条路都用上:
213
231
  // a) 纯图片也**记一条**(标 📷, 不可评分, 默认不拦)
@@ -227,20 +245,38 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
227
245
  imgUrls.push(u);
228
246
  }
229
247
  };
230
- // ① 当前消息的附件
248
+ /**
249
+ * 附件按**类型**归类(2026-09-15 主人:"视频和文件不算是图片,为什么也默认唤醒了?")。
250
+ *
251
+ * 原来只有 `imgUrls`,收集条件是「content_type 含 image」**或**「URL 长得像 QQ 多媒体下载链接」——
252
+ * 那个 `||` 让视频/语音/文件全被当成"图",一起触发了"只要带图就一律不拦" ✗
253
+ * 现在分工:只有**真图片**进 imgUrls;四种类型各自记一笔,放行时按类型开关走。
254
+ */
255
+ const attKinds = { image: false, video: false, voice: false, file: false };
256
+ // ① 当前消息的附件(分类收集:非图片不进 imgUrls)
231
257
  for (const a of (Array.isArray(msg.attachments) ? msg.attachments : [])) {
232
258
  const at = a;
233
259
  const u = String(at?.url ?? '').trim();
234
- if (u && (/image/i.test(String(at?.content_type ?? '')) || /download\?/.test(u)) && !imgUrls.includes(u)) {
260
+ const ct = String(at?.content_type ?? '').toLowerCase();
261
+ if (/image/.test(ct))
262
+ attKinds.image = true;
263
+ else if (/video/.test(ct))
264
+ attKinds.video = true;
265
+ else if (/voice|audio|record|amr|silk/.test(ct))
266
+ attKinds.voice = true;
267
+ else if (/file/.test(ct))
268
+ attKinds.file = true;
269
+ if (u && /image/.test(ct) && !imgUrls.includes(u))
235
270
  imgUrls.push(u);
236
- }
237
271
  }
238
- // ② 当前消息正文(合并转发/引用会把图写在文本里)
272
+ // ② 当前消息正文(合并转发/引用会把图写在文本里) —— 文本里认不出类型,按"图片"处理(历史行为)
239
273
  collectImg(String(msg.content || ''));
240
274
  // ③ 历史里(聚合把图算进 history 时)
241
275
  for (const h of (Array.isArray(mwState.history) ? mwState.history : [])) {
242
276
  collectImg(String(h?.content ?? ''));
243
277
  }
278
+ if (imgUrls.length > 0)
279
+ attKinds.image = true;
244
280
  const firstImg = imgUrls[0] || '';
245
281
  let libItem;
246
282
  if (firstImg) {
@@ -319,7 +355,7 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
319
355
  * 聚合窗口里"取最高分那条"时,**被计分的那句话可能不是最后一个人说的**。
320
356
  * 原来 `scText` 换成了别人的话,而下面所有归因(熟识度 / 好感度 / 语气 /
321
357
  * 评分日志的 sender)仍用 `msg.senderId`(窗口里**最后**发言的人)→
322
- * 实测把「大肥鱼怎么不插话了」(一只路过的路人甲说的)算到了「愤怒的小鸟」头上,
358
+ * 实测把「大肥鱼怎么不插话了」(某位路人说的)算到了「愤怒的小鸟」头上,
323
359
  * 还用**那个路人的语气**当他的语气,最后扣了他 −0.12。
324
360
  * 现在:scText 换成谁的话,发送者就跟着换成谁。
325
361
  */
@@ -328,6 +364,9 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
328
364
  let srcFromWindow = false;
329
365
  /** 加权综合分(只在加权模式下有值;含各条自己的好感偏移) */
330
366
  let aggScore;
367
+ /** 2026-09-15 修回归 bug: 聚合窗口历史里任一条 @ 了她也算本轮被点名 ——
368
+ * 原来豁免只看当前那条(mwState.mention), 历史里 @ 了而当前那条没 @ 时会被低估拦下。 */
369
+ let aggMentioned = false;
331
370
  /** 加权明细(进日志,便于事后核对"这个分是怎么平均出来的") */
332
371
  let aggParts;
333
372
  // B) 预检提示: 图片消息附一句 —— ⚠️ **只在真·有信息量时才提示**(2026-09-13 主人定):
@@ -380,10 +419,14 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
380
419
  text: cleanTextForScore(String(h.content ?? '')),
381
420
  senderId: h.senderId,
382
421
  senderName: h.senderName,
422
+ mentioned: h.mentioned === true,
383
423
  }))
384
424
  .filter((e) => e.text !== '')
385
425
  .slice(-5);
386
426
  aggCount = extras.length;
427
+ // 2026-09-15: 窗口里任一条 @ 过她 → 本轮算被点名(供下面 !mentioned 豁免与面板标记共用)
428
+ if (extras.some((e) => e.mentioned))
429
+ aggMentioned = true;
387
430
  const others = await Promise.all(extras.map((e) => scorer.score(e.text)));
388
431
  // 候选池 = 当前那条 + 窗口里那几条(每条都带着"是谁说的")
389
432
  const pool = [
@@ -393,7 +436,7 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
393
436
  score: others[i]?.score,
394
437
  senderId: e.senderId,
395
438
  senderName: e.senderName,
396
- mention: false,
439
+ mention: e.mentioned,
397
440
  fromWindow: true,
398
441
  })),
399
442
  ].filter((p) => typeof p.score === 'number');
@@ -447,12 +490,29 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
447
490
  }
448
491
  }
449
492
  // 记录: 有分数→正常记; 纯图片无分数→也记一条(标 img, 让"图片也在观察范围"看得见)
450
- if (sc || firstImg) {
493
+ // ⚠️ 2026-09-15 改:**任何附件**都要进这个块 —— 否则"纯视频 / 纯文件"(既没文字、又不是图)
494
+ // 会整块跳过"值不值得唤醒"的判断,等于无条件放行,附件开关就形同虚设了。
495
+ const hasAnyAttachment = attKinds.image || attKinds.video || attKinds.voice || attKinds.file;
496
+ if (sc || firstImg || hasAnyAttachment) {
451
497
  // 会话级门槛: 用本会话算出的 minScore 判定(不是 scorer 内部的默认值); 无分(纯图)视作放行
452
498
  // 判定分:加权模式用**综合分**(Σ权×有效分 / Σ权,各条的好感偏移已在里面算过);
453
499
  // 单条模式仍是 原始分 + 好感偏移。⚠️ 别重复加偏移(加权时 attOff 只用于日志)。
454
- const effScore = aggScore !== undefined ? aggScore : (sc ? sc.score + attOff : 0);
455
- const worth = sc ? effScore >= minScore : true;
500
+ // 再减"**@了别人**"的惩罚(2026-09-15 主人修正:不是"没人@她"要扣分 ——
501
+ // 没人@她恰恰是她该主动挑话插的常态;真正该扣的是"这句 @ 的是别人",那轮对话方向是那个人)。
502
+ const mentionPen = mentionedOthers ? OTHER_MENTION_PENALTY : 0;
503
+ const effScore = (aggScore !== undefined ? aggScore : (sc ? sc.score + attOff : 0)) - mentionPen;
504
+ // ── 附件唤醒开关(2026-09-15 主人定:图片/视频/语音/文件**分别**决定是否无视分数唤醒)──
505
+ // 现状问题:原来只要是"带图"就一律放行 —— 而"图"里混着视频/语音/文件(收集时不区分格式)。
506
+ // 新规则:
507
+ // · 有分数 → 够分 **或** 该类型允许放行
508
+ // · 没分数(纯附件、没文字可评)→ **只有该类型允许放行**才唤醒
509
+ // ↳ 这条是关键:否则"取消勾选"等于没勾(无分一律放行的话,开关形同虚设)
510
+ const attP = config.localModel?.attachmentPassthrough ?? {};
511
+ const mediaPass = (attKinds.image && attP.image !== false) || // 图片:默认放行(群友发图常是给她看的)
512
+ (attKinds.video && attP.video === true) || // 视频:默认不放行
513
+ (attKinds.voice && attP.voice === true) || // 语音:默认不放行(有转录文字就按文字评)
514
+ (attKinds.file && attP.file === true); // 文件:默认不放行
515
+ const worth = sc ? (effScore >= minScore || mediaPass) : mediaPass;
456
516
  // ① 相关度(观察期, **只记录不参与判定**): 当前消息 ↔ 她上一条发言 / 群里最近 5 条(2026-09-13 主人定)
457
517
  const rel = await computeRelevance({
458
518
  gid: msg.groupOpenid ?? '',
@@ -495,7 +555,7 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
495
555
  sender: srcSenderName || srcSenderId,
496
556
  // ★ 记下"sender 是从聚合窗口里挑出来的"(不是窗口最后那个人)—— 排查归因问题必需
497
557
  senderFromWindow: srcFromWindow || undefined,
498
- mention: mentioned,
558
+ mention: mentioned || aggMentioned || undefined,
499
559
  score: sc ? Math.round(sc.score * 1000) / 1000 : undefined,
500
560
  // 判定真正用的分:加权模式=Σ(权×有效分)/Σ权;单条模式=原始分+好感偏移
501
561
  // 判定分:有原始分**或**有加权综合分,都要记。
@@ -506,18 +566,25 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
506
566
  // 被点名 → **必回**(2026-09-14 主人:"@不是保证触发吗?")
507
567
  // 拦截条件三处都写着 `!mentioned`,所以被 @ 时分数再低也放行;
508
568
  // 但 worth 只表示"分数够不够",日志里会显示成没通过 → 单记一个字段说明白。
509
- mentionForced: mentioned || undefined,
569
+ mentionForced: (mentioned || aggMentioned) || undefined,
510
570
  min: minScore,
511
571
  // 好感度档位与偏移(2026-09-14 定稿:偏移**加在分数上**,门槛保持主人设的值不动)
512
572
  // ⚠️ 加权模式下 attTier/attOff 是**主角那条**的(权重最大的人),分值本身已各算各的
513
573
  attTier,
514
574
  attOff: attOff || undefined,
575
+ // @了别人 → 判定分扣了多少(2026-09-15 加;0/undefined = 没扣)
576
+ pen: mentionPen || undefined,
577
+ penWhy: mentionPen ? 'mentionOther' : undefined,
515
578
  // 加权明细:每条 {n:昵称, s:价值分, r:好感占比, w:权重} —— 事后能复算出综合分
516
579
  aggWeighted: aggParts ? true : undefined,
517
580
  aggParts,
518
581
  minBase: baseMin,
519
582
  gate, // 2026-09-13 加: 记下**当时生效的模式**(排查"为什么低分还回话"必需; 以前只记 min, log/block 分不出来)
520
583
  img: firstImg ? true : undefined,
584
+ // 附件类型(2026-09-15):记下这条带的是视频/语音/文件,便于核对"开关到底生没生效"
585
+ mediaKinds: hasAnyAttachment
586
+ ? Object.entries(attKinds).filter(([, v]) => v).map(([k]) => k).join('+')
587
+ : undefined,
521
588
  lib: libItem ? true : undefined,
522
589
  // 图在库 ≠ 能借它评分:**待整理区(candidate)的图还没打标签/描述**,
523
590
  // 借不到文字 → 评不了分。分开记一个字段,面板好把话说明白
@@ -547,7 +614,11 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
547
614
  // 而且那个分数往往是低分(实测: 【表情: 微笑】+库内图 = 0.022) → 被拦掉。
548
615
  // 这跟"群友发图往往是给她看/求接梗"的初衷完全相反 ⇒ 现在**只要带图就一律不拦**
549
616
  // (分数照记, 便于主人观察; 只是不拿它做拦截判定)。
550
- if (sc && gate === 'block' && !mentioned && !worth && !firstImg) {
617
+ // 2026-09-15 改(主人:"视频和文件不算是图片,为什么也默认唤醒了?"):
618
+ // 入口条件去掉 `sc &&` 与 `!firstImg` —— 无分(纯视频/纯文件)也要能拦,
619
+ // "该不该放行"全交给上面的 worth(含**附件类型开关**),这里只负责执行。
620
+ // (原来"带图不拦"里那个 firstImg 是**不区分格式**的,视频/文件跟着沾光 ✗)
621
+ if (gate === 'block' && !mentioned && !aggMentioned && !worth) {
551
622
  const ag = record.agent;
552
623
  const sess = ag?.session;
553
624
  let appended = false;
@@ -570,7 +641,7 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
570
641
  logger.warn('[价值评分] 低分但会话无 append 能力 —— 仍然不唤醒(这条可能不进上下文)');
571
642
  }
572
643
  record.lastInboundAt = Date.now();
573
- logger.debug(`[价值评分] 低分不唤醒 ${sc.score.toFixed(2)}<${minScore} (gate=block, appended=${appended}): key=${scope}:${peerId} "${plain.slice(0, 30)}"`);
644
+ logger.debug(`[价值评分] 不唤醒 ${sc ? sc.score.toFixed(2) : `无分`}<${minScore} (gate=block, appended=${appended}): key=${scope}:${peerId} "${plain.slice(0, 30)}"`);
574
645
  // ⚠️ 2026-09-13(主人要求"没产生回复就别消耗回复冷却"): 上游派发时戳了一枚群冷却,
575
646
  // 既然这次**没唤醒=没回复**, 就把那枚冷却还回去 —— 否则一条低分闲聊会白让群里静默 90 秒。
576
647
  const rb = mwState.qqCooldownRollback;
@@ -587,8 +658,10 @@ export async function handleInbound(rawMsg, manager, config, logger, state) {
587
658
  clearGroupHistory(config.appId, msg.groupOpenid ?? msg.senderId);
588
659
  return;
589
660
  }
590
- if (sc && gate === 'block' && !mentioned && !worth && firstImg) {
591
- logger.debug(`[价值评分] 带图消息不拦(群友发图常是给她看的) ${sc.score.toFixed(2)}<${minScore}: key=${scope}:${peerId}`);
661
+ // ② 附件放行(图片默认放行、或主人勾了放行的类型):只打日志,分数照记
662
+ if (gate === 'block' && !mentioned && mediaPass && (sc ? sc.score < minScore : true)) {
663
+ const kinds = Object.entries(attKinds).filter(([, v]) => v).map(([k]) => k).join('+');
664
+ logger.debug(`[价值评分] 附件放行(${kinds || '未知'})不看分数 ${sc ? sc.score.toFixed(2) : '无分'}<${minScore}: key=${scope}:${peerId}`);
592
665
  }
593
666
  if (sc && gate !== 'block' && !mentioned && !worth) {
594
667
  // ⚠️ 提到 info(2026-09-13): 主人排查"低分为什么还回话"时, 一眼就能在控制台看到
@@ -667,7 +740,7 @@ function assembleAgentBody(msg, state, scope, logger, downloaded, enableRef, msg
667
740
  if (!quotePart && enableRef) {
668
741
  const quoted = extractQuotedContent(msg);
669
742
  if (quoted)
670
- quotePart = `[Quoted message begins]\n${escapeQuoteMarkers(quoted)}\n[Quoted message ends]\n[Current message]\n`;
743
+ quotePart = `${MK.QUOTE_BEGIN}\n${escapeBlockMarkers(quoted)}\n${MK.QUOTE_END}\n${MK.CURRENT}\n`;
671
744
  }
672
745
  // ⚠️ 2026-09-13 主人要求(省 token): 引用原文只给**前 QUOTE_KEEP 字**,
673
746
  // 完整原文进本地缓存(每会话最多 10 条) → AI 需要时用 `quote_view` 工具取。
@@ -757,11 +830,14 @@ async function fetchImageBuffer(url, maxBytes = 5 * 1024 * 1024) {
757
830
  * 这里只剥"结构与标记", 保留真正的消息正文。
758
831
  */
759
832
  function cleanTextForScore(raw) {
760
- let s = String(raw || '');
833
+ // 块标记(新旧两套)统一交给 markers.ts 剥 —— 别在这里再写一遍字面量
834
+ let s = stripBlockMarkers(String(raw || ''));
761
835
  s = s
762
- .replace(/\[Quoted message begins\]/gi, ' ')
763
- .replace(/\[Quoted message ends\]/gi, ' ')
764
- .replace(/\[Current message\]/gi, ' ')
836
+ // @ 标记一律剥掉(2026-09-15):`<@openid>` / `@bot` 留在打分文本里会让"这条 @ 了她"
837
+ // 变成最大共同特征 → 实测「<@xx> 不知道」拿到 1.00 分。语义交给正文,点名交给 mention 特征。
838
+ .replace(/<@[!&]?\d+>/g, ' ')
839
+ .replace(/<@[0-9A-Za-z_-]{6,}>/g, ' ')
840
+ .replace(/@bot\b/gi, ' ')
765
841
  .replace(/\[群聊的聊天记录\]/g, ' ')
766
842
  .replace(/={2,}\s*消息\s*\d+\s*={2,}/g, ' ') // === 消息 1 ===
767
843
  .replace(/---\s*第\s*\d+\s*条\s*---/g, ' ') // --- 第1条 ---
@@ -772,9 +848,9 @@ function cleanTextForScore(raw) {
772
848
  .replace(/\[附件\d*\][^\n]*/g, ' ') // [附件1] 类型:图片 文件名:… URL:…
773
849
  .replace(/\[图片:\s*https?:\/\/[^\]]*\]/g, ' ') // [图片: URL]
774
850
  .replace(/\[表情:\s*[^\]]*\]/g, ' ')
775
- .replace(/\[当前时间[^\]]*\]/g, ' ')
851
+ .replace(/\[当前时间[^\]]*\]/g, ' ') // 旧时间行
852
+ .replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]/g, ' ') // 新时间行(2026-09-15 起不再带"当前时间"标签)
776
853
  .replace(/\[系统提示\][^\n]*/g, ' ')
777
- .replace(/\[Chat history begins\]|\[Chat history ends\]/gi, ' ')
778
854
  .replace(/^\s*[-·]\s*Image:[^\n]*/gim, ' ') // - Image: xxx.jpg (550×550) → URL
779
855
  .replace(/^\s*\[[\u4e00-\u9fa5A-Za-z]{1,8}\]\s*$/gm, ' '); // 独占一行的 [标签]
780
856
  return s.replace(/[ \t]+/g, ' ').replace(/\n{2,}/g, '\n').trim();
@@ -785,20 +861,13 @@ function cleanTextForScore(raw) {
785
861
  function buildQuotePart(quote) {
786
862
  if (!quote?.text && !quote?.entry?.content)
787
863
  return '';
788
- const quoteText = escapeQuoteMarkers(quote.text || quote.entry?.content || 'Original content unavailable');
789
- return `[Quoted message begins]\n${quoteText}\n[Quoted message ends]\n[Current message]\n`;
864
+ const quoteText = escapeBlockMarkers(quote.text || quote.entry?.content || 'Original content unavailable');
865
+ // 短标记(2026-09-15 省 token): [引]…[/引][当前] —— 见 markers.ts
866
+ return `${MK.QUOTE_BEGIN}\n${quoteText}\n${MK.QUOTE_END}\n${MK.CURRENT}\n`;
790
867
  }
791
868
  /**
792
- * 引用原文里的**块标记转义**(2026-09-13 主人实测抓到的坑):
793
- * AI 可能在代码块里示范 `[Quoted message begins]` 这段字面量, 被引用后它会混进引用块,
794
- * 让"第一个 begin + 第一个 end"这种匹配切错位置。把内层的方括号退化成圆括号:
795
- * 语义照旧看得懂, 但不再参与结构匹配。
869
+ * (转义实现已搬到 `markers.ts` 的 `escapeBlockMarkers` —— 新旧两套标记一起转义, 见那里的注释)
796
870
  */
797
- function escapeQuoteMarkers(text) {
798
- return String(text || '')
799
- .replace(/\[Quoted message begins\]/gi, '(Quoted message begins)')
800
- .replace(/\[Quoted message ends\]/gi, '(Quoted message ends)');
801
- }
802
871
  /** 引用原文保留字数: 超出部分只进本地缓存(2026-09-13 主人定, 短引用就别折腾了) */
803
872
  const QUOTE_KEEP = 60;
804
873
  /**
@@ -847,13 +916,12 @@ function localizeHistoryImages(text, stickerDir) {
847
916
  function trimQuoteBlock(quotePart, dataRoot, msg, logger) {
848
917
  if (!quotePart)
849
918
  return quotePart;
850
- const B = '[Quoted message begins]';
851
- const E = '[Quoted message ends]';
852
- const b = quotePart.indexOf(B);
853
- const e = quotePart.lastIndexOf(E);
854
- if (b < 0 || e < 0 || e <= b)
919
+ // 认**新旧两套**标记(老会话里还留着英文长标记), 取"第一个 begin + 最后一个 end"
920
+ const b = findFirstMarker(quotePart, QUOTE_BEGIN_ALL);
921
+ const e = findLastMarker(quotePart, QUOTE_END_ALL);
922
+ if (!b || !e || e.idx <= b.idx)
855
923
  return quotePart;
856
- const full = quotePart.slice(b + B.length, e).trim();
924
+ const full = quotePart.slice(b.idx + b.len, e.idx).trim();
857
925
  if (full.length <= QUOTE_KEEP)
858
926
  return quotePart;
859
927
  const key = `${msg.kind === 'group' ? 'group' : 'c2c'}:${(msg.kind === 'group' ? msg.groupOpenid : undefined) ?? msg.senderId}`;
@@ -861,7 +929,7 @@ function trimQuoteBlock(quotePart, dataRoot, msg, logger) {
861
929
  const hit = pushQuote(dataRoot, key, full, msg.senderName);
862
930
  const head = full.slice(0, QUOTE_KEEP).replace(/\s+/g, ' ');
863
931
  logger.debug(`[引用] 原文 ${full.length} 字 → 只给前 ${QUOTE_KEEP} 字(缓存 #${hit.id})`);
864
- return `${quotePart.slice(0, b)}${B}\n${head}…[引用#${hit.id}: 全文 ${full.length} 字已缓存, 需要时用 quote_view 查]\n${E}${quotePart.slice(e + E.length)}`;
932
+ 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)}`;
865
933
  }
866
934
  catch {
867
935
  return quotePart;
@@ -886,15 +954,25 @@ function extractQuotedContent(msg) {
886
954
  * Layer 3: 带发送者标签的用户消息
887
955
  * 引用消息功能开启时, 每条入站都带**短消息号**(msgRef, 形如 0913a; 群聊挂发送者标签, 私聊独立一行)
888
956
  * —— AI 想引用对方时在正文写 [rf:短号](2026-09-13 主人定: 短号省 token, 台账见 msg-index.ts)。
957
+ * 群聊标签的形状(2026-09-15 定稿, 与历史行同口径):
958
+ * 没被 @ → `[昵称 #短号]` ← 不带 openid(省 token; 32 位 id 每行 20+ token)
959
+ * 被 @ 了 → `[昵称 (openid) #短号]` + 正文后 ` (@you)` ← 要回 @ 他/认人才给 id
960
+ * 短号两边都留(引用标记 + id_lookup 反查 openid 的入口, 不能省)。
889
961
  */
890
- function buildUserMessage(userContent, quotePart, senderId, senderName, isGroup, wasMentioned, msgRef) {
962
+ export function buildUserMessage(userContent, quotePart, senderId, senderName, isGroup, wasMentioned, msgRef) {
891
963
  if (!isGroup) {
892
964
  const idPart = msgRef ? `[消息号: ${msgRef}]\n` : '';
893
965
  return `${quotePart}${idPart}${userContent}`;
894
966
  }
895
967
  const mentionTag = wasMentioned ? ' (@you)' : '';
896
968
  const displayName = senderName ?? shortSenderId(senderId);
897
- const senderTag = msgRef ? `[${displayName} (${senderId}) #${msgRef}]` : `[${displayName} (${senderId})]`;
969
+ // 省 token(2026-09-12 定, 2026-09-15 主人复查"没 @ 的只显示昵称呢?"):
970
+ // 和**群历史行**一个口径 —— 没被 @ 的消息只给昵称, 32 位 openid 只在"被 @ 了"(要回 @ 他/认人)时才给。
971
+ // ⚠️ 短消息号 `#ref` **两边都留**: 那是引用标记([rf:xxxx] 靠它), 也是 id_lookup 反查 openid 的入口 ——
972
+ // 去掉它引用功能/认人工具就都废了 (主人 2026-09-15 特别强调)。
973
+ const idPart = wasMentioned ? ` (${senderId})` : '';
974
+ const refPart = msgRef ? ` #${msgRef}` : '';
975
+ const senderTag = `[${displayName}${idPart}${refPart}]`;
898
976
  return `${quotePart}${senderTag} ${userContent}${mentionTag}`;
899
977
  }
900
978
  /**
@@ -975,12 +1053,12 @@ function buildAgentBody(base, history, isGroup, wasMentioned, batchDispatch = fa
975
1053
  return mentioned && h.senderId ? `[${name} (${h.senderId})] ${h.content}` : `[${name}] ${h.content}`;
976
1054
  });
977
1055
  return [
978
- aggregated ? '[系统提示] 以下历史与当前消息发生在你上一次回复之前(你思考/输出期间群友所发, 非对你的回应), 请通读后综合回应。' : '',
979
- '[Chat history begins]',
1056
+ aggregated ? '[系统提示] 以下是上次回复前群友所发(非对你的回应), 通读后综合回应。' : '',
1057
+ MK.HISTORY_BEGIN,
980
1058
  ...historyLines,
981
1059
  '',
982
- '[Chat history ends]',
983
- '[Current message]',
1060
+ MK.HISTORY_END,
1061
+ MK.CURRENT,
984
1062
  base,
985
1063
  ].filter(Boolean).join('\n');
986
1064
  }