@zaofan/dsh-qqbot 1.10.4 → 1.10.6

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.
@@ -30,8 +30,25 @@
30
30
  * 一律走 **`globalThis` 符号槽(`publishShared`/`adoptShared`)+ 磁盘(`saveState`/`loadState`)**,
31
31
  * 模块变量只当本地缓存。
32
32
  */
33
- const PAGE = 20; // 一页 20 个选项(4 行 × 5 列;第 5 行留给导航)
34
- const LETTERS = 'ABCDEFGHIJKLMNOPQRST';
33
+ /**
34
+ * ★★ 一页几个选项 = 由 **QQ 内联键盘的硬上限**倒推出来的,不是随便定的(2026-10-14 真机踩中)
35
+ *
36
+ * QQ 官方对内联键盘的限制:**总行数 ≤ 5**、每行 ≤ 5 个按钮。
37
+ * 超了会被直接拒绝,原话见诊断日志:
38
+ * `HTTP 400: {"message":"请求错误,内联键盘行/列超限","code":40034029}`
39
+ * 更坑的是:tier① 被拒后会**静默退化成纯文本兜底** —— 现象就是主人反馈的
40
+ * 「文字发出来了,按钮选项消失」。
41
+ *
42
+ * 所以布局固定为 **5 行**:
43
+ * 第 1~3 行 = 选项(**一页最多 15 个**)
44
+ * 第 4 行 = 翻页(◀ 上一页 / 下一页 ▶),没得翻时放一个占位按钮
45
+ * 第 5 行 = 取消
46
+ * (2026-10-14 之前是 PAGE=20 ⇒ 4 行选项 + 2 行尾巴 = **6 行** ⇒ 必然被拒。)
47
+ */
48
+ const PAGE = 15;
49
+ const OPT_ROWS = 3; // 选项最多 3 行(3×5 = 15)
50
+ const MAX_KB_ROWS = 5; // QQ 内联键盘总行数硬上限
51
+ const LETTERS = 'ABCDEFGHIJKLMNO'; // 15 个
35
52
  const CARD_EXPIRE_MS = 10 * 60_000;
36
53
  const MAX_PAGE_MS = 30 * 60_000;
37
54
  const TOOL = '会话列表'; // ★ 必须与文件名/注册名一致
@@ -174,7 +191,11 @@ async function regCardSelf(ctx, cardId, buttonIds) {
174
191
  } catch { return false; }
175
192
  }
176
193
 
177
- /** 发卡(markdown + 按钮键盘);四级兜底,与 /子会话 同款 */
194
+ /** 发卡(markdown + 按钮键盘);四级兜底,与 /子会话 同款。
195
+ * ★ 返回值要说实话(2026-10-14 修):`'kb'`=带按钮发出去了 / `'text'`=**退化成纯文本**(按钮没了)
196
+ * / `'fail'`=什么都没发出去。之前只会返回 true/false,**退化被当成成功**,
197
+ * 于是"文字出来了、按钮消失"这种事故在日志里显示 `发出=ok`,白排查半天。 */
198
+ let LAST_SEND_ERR = '';
178
199
  async function sendCard(ctx, text, keyboard) {
179
200
  const caps = ctx?.caps ?? ctx ?? {};
180
201
  const mc = ctx?.message ?? {};
@@ -189,24 +210,25 @@ async function sendCard(ctx, text, keyboard) {
189
210
  const body = { msg_type: 2, markdown: { content: text }, keyboard, ...(gid ? {} : { content: text }) };
190
211
  try {
191
212
  const r = await api(path, { method: 'POST', body });
192
- void diag(`[sendCard] ①api=${JSON.stringify(r ?? null).slice(0, 140)}`);
193
- if (r && r.ok !== false) return true;
194
- } catch (e) { void diag(`[sendCard] ①抛错=${short(e?.message ?? e, 140)}`); }
213
+ void diag(`[sendCard] ①api=${JSON.stringify(r ?? null).slice(0, 200)}`);
214
+ if (r && r.ok !== false) { LAST_SEND_ERR = ''; return 'kb'; }
215
+ LAST_SEND_ERR = cn(r?.err?.human ?? r?.err?.message ?? r?.err?.code ?? 'ok=false');
216
+ } catch (e) { LAST_SEND_ERR = cn(e?.message ?? e); void diag(`[sendCard] ①抛错=${short(e?.message ?? e, 200)}`); }
195
217
  }
196
218
  try {
197
219
  if (keyboard && typeof caps.markdownCard === 'function') {
198
220
  const r2 = await caps.markdownCard(text, keyboard);
199
- if (r2) return true;
221
+ if (r2) return 'kb';
200
222
  }
201
223
  } catch { /* ignore */ }
202
224
  try {
203
225
  if (keyboard && typeof ctx?.sender?.sendMarkdownWithKeyboard === 'function') {
204
226
  await ctx.sender.sendMarkdownWithKeyboard(ctx.replyTarget, text, keyboard);
205
- return true;
227
+ return 'kb';
206
228
  }
207
229
  } catch { /* ignore */ }
208
- try { if (typeof caps.markdown === 'function' && (await caps.markdown(text))) return true; } catch { /* ignore */ }
209
- return false;
230
+ try { if (typeof caps.markdown === 'function' && (await caps.markdown(text))) return 'text'; } catch { /* ignore */ }
231
+ return 'fail';
210
232
  }
211
233
 
212
234
  /** 按钮(id 不能带冒号) */
@@ -514,7 +536,7 @@ function cardKeyboard(st) {
514
536
  const opts = st.options;
515
537
  const start = st.page * PAGE;
516
538
  const cur = opts.slice(start, start + PAGE);
517
- for (let r = 0; r * 5 < cur.length && r < 4; r++) {
539
+ for (let r = 0; r * 5 < cur.length && r < OPT_ROWS; r++) {
518
540
  const row = [];
519
541
  for (let c = 0; c < 5; c++) {
520
542
  const i = r * 5 + c;
@@ -528,6 +550,8 @@ function cardKeyboard(st) {
528
550
  if (start + PAGE < opts.length) nav.push(btn('next', '下一页 ▶', 1));
529
551
  rows.push(nav.length ? nav : [btn('noop', '·', 1)]);
530
552
  rows.push([btn('cancel', '取消', 4)]);
553
+ // ★ 保底:QQ 只收 ≤5 行;真超了就砍尾巴(正常布局下正好 5 行,走不到这里)
554
+ while (rows.length > MAX_KB_ROWS) rows.splice(rows.length - 2, 1);
531
555
  return keyboardOf(rows, st.cardId);
532
556
  }
533
557
 
@@ -535,9 +559,11 @@ function cardKeyboard(st) {
535
559
  async function paint(ctx, st) {
536
560
  st.options = await optionsOf(ctx, st.step, st);
537
561
  if (st.page * PAGE >= st.options.length) st.page = Math.max(0, Math.ceil(st.options.length / PAGE) - 1);
562
+ st.error = '';
538
563
  st.cardId = `${TOOL}-${++seq}-${Date.now().toString(36)}`;
539
564
  const kb = cardKeyboard(st);
540
- const ok = await sendCard(ctx, cardText(st), kb);
565
+ const mode = await sendCard(ctx, cardText(st), kb);
566
+ const ok = mode === 'kb';
541
567
  const caps = ctx?.caps ?? ctx ?? {};
542
568
  const allIds = [...st.options.map((_, i) => `p${i}`), 'prev', 'next', 'cancel', 'noop'];
543
569
  try {
@@ -552,8 +578,12 @@ async function paint(ctx, st) {
552
578
  }
553
579
  publishShared({ dataRoot: dataRootOf(ctx) });
554
580
  try { await saveState(ctx, st.cardId, st); } catch { /* ignore */ } // ★ 落盘:回调侧可能是**另一个模块实例**
555
- void diag(`[paint] step=${st.step} page=${st.page} 选项=${st.options.length} 发出=${ok ? 'ok' : 'FAIL'} 表=${W.size}`);
556
- if (!ok) st.error = '卡片没发出去(当前环境可能不支持富文本卡片)';
581
+ void diag(`[paint] step=${st.step} page=${st.page} 选项=${st.options.length} 发出=${mode}${LAST_SEND_ERR ? ' err=' + short(LAST_SEND_ERR, 80) : ''} 表=${W.size}`);
582
+ if (mode !== 'kb') {
583
+ st.error = mode === 'text'
584
+ ? `按钮没发出去(QQ 拒了键盘:${short(LAST_SEND_ERR, 50) || '未知'}),这一步只能纯文本显示`
585
+ : '卡片完全没发出去(当前环境可能不支持富文本卡片)';
586
+ }
557
587
  return ok;
558
588
  }
559
589
 
@@ -228,3 +228,31 @@ export default { name: 'x', onInbound, onDecide, dispose() {} }; // 对象默
228
228
  | 低分消息还是回话了 | 日志里搜 `强制入站(入站扩展 forceWake…` —— 可能是你自己的扩展要求的;也可能是 `forceWake` 之后 debounce 把标记带下去了(正常) |
229
229
  | 禁言没生效 | `isMuted()` 是不是写在"消灭"之前却被后面的分支覆盖了;名单文件在 `.state/<你的文件名>.json` |
230
230
  | 熔断了 | 日志搜 `⚠ 熔断「…」`;复位 = 改文件 + `/tools-reload` |
231
+
232
+ ---
233
+
234
+ ## 附:`annihilate({ ownerAck: true })` —— 唯一能吞"主人消息"的通道(v1.10.5 新增)
235
+
236
+ **背景**:白名单写着「主人的消息永不可被入站扩展吞掉」,`ctx.annihilate()` 遇到主人发送者会**直接返回 false**
237
+ (审计 `refused-owner`),引擎还有第二道兜底(扩展没调 `next()` 时替它放行)。这两道一起保证了
238
+ "用户脚本坏了也吞不掉主人的话"。
239
+
240
+ **但有一类是主人自己的意图**:例如**二档路由隔绝** —— 群里那条 `[s:c0246642] 来我房间` 已经被扩展
241
+ 投递到目标会话了,主人明确要求**不要再留在本会话上下文里**。这时扩展可以:
242
+
243
+ ```js
244
+ const ok = ctx.annihilate({ reason: '二档路由隔绝:已投到目标会话', ownerAck: true });
245
+ if (!ok) return next(); // 没吞掉 ⇒ 交回原路径,别双投
246
+ // ★ 吞掉之后照样要 return next():annihilate 置位会让 next() 变成空操作、链停在这里
247
+ return next();
248
+ ```
249
+
250
+ **三条纪律**(写在 `ExtInboundCaps.annihilate` 的类型注释里):
251
+
252
+ 1. **只在"你确实已经把内容投递到别处"时传** —— 否则主人的话会凭空消失;
253
+ 2. 用了它**一定落审计** `result: 'ok-owner-ack'`(外加一条 warn),事后可查"哪条主人的消息被谁吞了、为什么";
254
+ 3. 它只解开**这一条**的白名单 —— 挂点检查、熔断等其它保护一律照旧。
255
+
256
+ **向后兼容**:老版本插件不认识 `ownerAck`,会照旧按白名单拒绝 ⇒ 行为退回"主人的消息照常放行",不会更糟。
257
+
258
+ **参考实现**:同目录 `route-isolate.mjs`。
@@ -0,0 +1,132 @@
1
+ /**
2
+ * route-isolate.mjs — 入站中间件:**二档绑定的路由消息,不进父会话上下文(彻底隔绝)**
3
+ *
4
+ * 放在 `{数据根}/.qqbot-extensions/inbound/route-isolate.mjs`;改完 `/tools-reload` 立即生效。
5
+ *
6
+ * ── 它解决什么(主人 2026-10-14 需求,原话)────────────────────────────────
7
+ * 「如果是挡位2的情况下,所有的路由的消息,不进入父会话的上下文,彻底隔绝」
8
+ * 「我是说,用户发的消息,如果是路由用的,不进入你的上下文,不是说回灌的消息」
9
+ * 即:群里那条 `[s:c0249642] 喜欢你` —— 只该出现在**目标会话**里,
10
+ * 不该同时躺进鲸鱼娘本尊(父会话)的上下文。二档本就意味着"子会话内容只去 QQ、不进父上下文",
11
+ * 本扩展把**反方向**补齐:**父上下文 ↔ 子会话 彻底互不可见**。
12
+ *
13
+ * ── 为什么只能写在**早挂点 onInbound** ──────────────────────────────────────
14
+ * 链上位置:`… → quoteRef → ★onInbound(本文件)★ → 引用图预取 → 记群历史 → … → AI`
15
+ * 只有这里在"记群历史之前",所以只有这里的 `ctx.annihilate()` 才真的等于"这条没来过"
16
+ * (不进历史、不进上下文、不占冷却)。晚挂点调它会**被拒 + 告警**。
17
+ *
18
+ * ── 三个必须这么写的地方 ───────────────────────────────────────────────────
19
+ * ① **消灭之后必须自己投递**:`session_watch_lib` 原来那条路由投递挂在 `session/event` 的
20
+ * `user/message` 上;消息都被消灭了,那个事件**永远不会发生** ⇒ 这里消灭前先解析目标、
21
+ * 消灭后直接调 `lib.routeToSession()`。
22
+ * ② **`return next()`,不能"消灭完就 return"**:插件引擎有条兜底 —— 扩展"既没调 next、
23
+ * 又没有成功的消灭"时**替它放行**,且白名单写着"主人的消息永远不被入站扩展吞掉"。
24
+ * 但这条兜底**只在 `!onward` 时触发**(=扩展压根没调 next);调了 next,
25
+ * `annihilate` 的置位会**直接吃掉 next**(链停在这里 + 跳过兜底)⇒ **主人的消息也真的被消灭**。
26
+ * 这正是主人要的。⚠️ 该语义照 `src/features/ext-inbound.ts` 的
27
+ * `runEarlyInboundExtensions` 确认;哪天插件收紧它,这里会退化成"主人消息照进上下文"(不会更糟)。
28
+ * ③ **只有二档才隔绝**:`modeOfRoute(key) === 2`。没绑过 / 一档 / 三档 ⇒ 原样放行。
29
+ *
30
+ * ── 安全阀(宁可"不隔绝",也不要"消息凭空消失")──────────────────────────
31
+ * · 没路由标记 ⇒ 放行;· 不是二档 ⇒ 放行;
32
+ * · **路由 id 解析不出会话**(写错了)⇒ 放行;· `annihilate` 返回 false(熔断/挂点不允许)⇒ 放行,
33
+ * 交回原路径投递,避免"我投一次 + 原路径再投一次"双投;
34
+ * · 已消灭但投递失败(目标不在线且唤不醒)⇒ 不回父上下文(这是隔绝的代价),只记诊断日志。
35
+ *
36
+ * ── 诊断 ──────────────────────────────────────────────────────────────────
37
+ * 全程写 `{数据根}/.qqbot-extensions/data/route-isolate-diag.log`。
38
+ * (教训:一开始只往 logger 写,而**从来没人注入过 logger** ⇒ 真机上完全静默、无法定位。)
39
+ */
40
+
41
+ /** 只在插件日志里显示的名字 */
42
+ export const name = 'route-isolate';
43
+
44
+ /** ★ 诊断必须**落盘**:logger 没人注入过,只往 logger 写等于瞎的 */
45
+ const DIAG_FILE = 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot/.qqbot-extensions/data/route-isolate-diag.log';
46
+ let LOG = null;
47
+
48
+ async function note(msg) {
49
+ try { LOG?.(msg); } catch { /* ignore */ }
50
+ try {
51
+ const { appendFile, mkdir } = await import('node:fs/promises');
52
+ const { dirname } = await import('node:path');
53
+ await mkdir(dirname(DIAG_FILE), { recursive: true });
54
+ const line = new Date().toISOString() + ' ' + String(msg) + '\n';
55
+ await appendFile(DIAG_FILE, line);
56
+ } catch { /* 诊断失败绝不影响主流程 */ }
57
+ }
58
+
59
+ async function loadLib() {
60
+ return await import(new URL('../tools/lib/session_watch_lib.mjs?v=' + Date.now(), import.meta.url).href);
61
+ }
62
+
63
+ /** 早挂点:链上唯一"还没记群历史"的位置 */
64
+ export async function onInbound(ctx, next) {
65
+ try {
66
+ // ★★ 正文从哪读:**`ctx.text` 才是插件给的规范读法**(它内部 = `textOf(ctx)`),
67
+ // 实际存在 `ctx.message.content` 里。**不存在 `ctx.message.text`** ——
68
+ // 2026-10-14 就是因为写成 `.text` 读到空串、第一行就 return next(),
69
+ // 整条链路静默放行(审计里一条记录都没有)才排查了半天。
70
+ const text = String(ctx?.text ?? ctx?.message?.content ?? ctx?.message?.text ?? '');
71
+ if (!text) return next();
72
+ const lib = await loadLib();
73
+
74
+ // ★★ 2026-10-14:**顺手把 sender/replyTarget 交给回灌库**(`rememberFwd`)。
75
+ // 为什么必须挂在入站链上:QQ 转发能力 `fwd` 是**内存态**,插件一重启就丢;
76
+ // 唯一的自愈路径是"父会话发言时 hydrateFwd",而它需要 sender ——
77
+ // 工具侧只有"被调用过"才注入(`session_watch.mjs` 里的 rememberFwd),
78
+ // 于是重启后如果没人记得调工具,转发就一直断(实测踩到)。
79
+ // 挂在这里 ⇒ **每条群消息都刷新一次** ⇒ 重启后父会话随便说一句话转发就自动恢复。
80
+ // 注意用 `ctx.caps.*` 而不是 `ctx.*`:ctx 上那个 `sender` 已被插件定义成"发送者 openid"(字符串)。
81
+ try {
82
+ lib.rememberFwd?.(ctx?.caps?.sender, ctx?.caps?.replyTarget);
83
+ } catch { /* 注入失败不影响隔绝 */ }
84
+
85
+ const key = lib.parseRouteKey(text);
86
+ // 每次都记"手上有什么" —— kernel / annihilate 缺任一个都会走放行分支,先看清
87
+ void note('[enter] text=' + JSON.stringify(text.slice(0, 50))
88
+ + ' key=' + (key || '-')
89
+ + ' kernel=' + typeof ctx?.kernel
90
+ + ' annihilate=' + typeof ctx?.annihilate
91
+ + ' next=' + typeof next);
92
+
93
+ if (!key) return next();
94
+
95
+ // ① 只有二档才隔绝(没绑 = 0,一/三档 = 1/3,都放行)
96
+ const mode = lib.modeOfRoute(key);
97
+ void note('[mode] key=' + key + ' mode=' + mode);
98
+ if (mode !== 2) return next();
99
+
100
+ // ② 目标解析不出来 ⇒ 不吞(写错 id 时别让消息凭空消失)
101
+ const kernel = ctx?.kernel ?? null;
102
+ let full = '';
103
+ try { full = lib.resolveShortId(kernel, key) ?? ''; } catch { full = ''; }
104
+ if (!full) {
105
+ try { full = lib.findSessionOnDisk(key)?.id ?? ''; } catch { full = ''; }
106
+ }
107
+ if (!full) { void note('[skip] id 解析不到会话 key=' + key); return next(); }
108
+
109
+ // ③ 先消灭再投递 —— 顺序不能反:先投再吞的话,一旦消灭被熔断拒绝就会双投
110
+ // ★ `ownerAck: true` —— 唯一能吞"主人消息"的通道(2026-10-14 为此专门给插件加了显式例外)。
111
+ // 语义:这条我已经**另行投递到目标会话**了,请允许对主人生效;插件会落审计 ok-owner-ack。
112
+ const acked = ctx?.annihilate?.({ reason: '二档路由隔绝:目标 ' + key, ownerAck: true });
113
+ if (!acked) { void note('[skip] annihilate 被拒 key=' + key + ' target=' + full.slice(0, 20)); return next(); }
114
+
115
+ // ④ 已消灭 ⇒ 自己投递(原路径的 user/message 钩子不会跑了)
116
+ let r = null;
117
+ try {
118
+ r = await lib.routeToSession(kernel, text, { message: ctx?.message });
119
+ } catch (e) {
120
+ r = { ok: false, reason: 'throw:' + (e?.message ?? e) };
121
+ }
122
+ void note('[done] key=' + key + ' target=' + full.slice(0, 20)
123
+ + ' 已从父上下文移除;投递=' + (r?.ok ? 'ok(' + r.reason + ')' : 'FAIL(' + r?.reason + ')'));
124
+ } catch (e) {
125
+ void note('[error] ' + (e?.message ?? e));
126
+ }
127
+ // ★ 必须调 next():跳过"发送者是主人 ⇒ 引擎替它放行"那条兜底(见文件头 ②)
128
+ return next();
129
+ }
130
+
131
+ /** 插件可用它注入日志(没注入也会落盘诊断) */
132
+ export function setLogger(fn) { LOG = typeof fn === 'function' ? fn : null; }
@@ -56,7 +56,7 @@
56
56
  * 版本标记:供调用方打印,用来确认**热刷后加载到的到底是哪一版**。
57
57
  * 改动本文件时顺手把这个号 +1(或换日期),排查"改了不生效"时一眼就能看出来。
58
58
  */
59
- export const __LIB_VERSION__ = 'lib-v13-20261014-通用路由([s:短id] / 【会话 id】)+扫盘解析+会话列表';
59
+ export const __LIB_VERSION__ = 'lib-v14-20261014-通用路由+扫盘解析+会话列表+父会话跟QQ对象走+二档路由隔绝';
60
60
 
61
61
  // ── ★ 2026-10-13 持久化改造(主人指示:**以本地存档为准,别只靠内存**)──────────────
62
62
  // 本文件的风格是"用到时动态 import";这里用**顶层 await** 一次性取到同步版 fs,
@@ -97,6 +97,68 @@ export function rememberFwd(sender, replyTarget) {
97
97
  }
98
98
  const watching = (globalThis[WATCHING_KEY] ??= new Map()); // childSessionId -> entry
99
99
 
100
+ /**
101
+ * ★★ 2026-10-14:**父会话跟着「QQ 对象」走**(主人定:绑的应该是 QQ 对象,不是那一刻的会话 id)
102
+ *
103
+ * 病根:原来 `parentAgentId` 是绑定时写死的 session id,回灌直接 `agents.get(那个 id)`。
104
+ * 在 QQ 里发 `/bot-new` 会**新开一个会话**(新 id),名单里那条记录却还指着旧会话
105
+ * ⇒ 回灌继续落进旧会话;而"转发到 QQ"那一路用的是 openid、照旧正常 ⇒ 表面上完全看不出错。
106
+ *
107
+ * 修法(不需要插件内部 API):记一张 `peerKey = scope:peerId`(QQ 对象)→ **最近一次为该对象发言的
108
+ * 会话 id** 的表;回灌前先按 peerKey 取最新会话,取不到才回落到写死的旧 id。
109
+ * `/bot-new` 之后该对象的下一条消息必然落到新会话 ⇒ 这张表立刻学到新 id ⇒ 回灌自动跟过去。
110
+ *
111
+ * 自愈:老记录(没有 peerKey)在"它的父会话下一次发言"时自动补上 peerKey —— 与 fwd 补回同一套路数。
112
+ */
113
+ const PEER_KEY = Symbol.for('dsh-qqbot.session-watch.peerLatest');
114
+ const peerLatest = (globalThis[PEER_KEY] ??= new Map()); // peerKey -> sessionId
115
+
116
+ /** 从**会话对象**里取 QQ 对象键(scope:peerId)。刻意不用 `o.id` —— 那是会话 id,不是 peer */
117
+ function peerKeyOfSession(s) {
118
+ try {
119
+ if (!s) return '';
120
+ const peerId = String(s.peerId ?? s.groupOpenid ?? s.groupId ?? '');
121
+ if (!peerId) return '';
122
+ const scope = String(s.scope ?? s.peerScope ?? '') || 'group';
123
+ return scope + ':' + peerId;
124
+ } catch { return ''; }
125
+ }
126
+
127
+ /** 从 **replyTarget / peer 视图**里取 QQ 对象键 */
128
+ function peerKeyOfTarget(rt) {
129
+ try {
130
+ if (!rt) return '';
131
+ const id = String(rt.peerId ?? rt.targetId ?? rt.groupOpenid ?? rt.openid ?? rt.groupId ?? rt.id ?? '');
132
+ if (!id) return '';
133
+ const scope = String(rt.scope ?? rt.kind ?? '') || 'group';
134
+ return scope + ':' + id;
135
+ } catch { return ''; }
136
+ }
137
+
138
+ /** 学到"这个 QQ 对象最近在哪个会话里说话",顺手给老盯梢补 peerKey(自愈) */
139
+ export function notePeerSeen(session, sid) {
140
+ try {
141
+ const pk = peerKeyOfSession(session);
142
+ if (!pk || !sid) return false;
143
+ let changed = peerLatest.get(pk) !== sid;
144
+ if (changed) peerLatest.set(pk, sid);
145
+ for (const w of watching.values()) {
146
+ if (!w.peerKey && w.parentAgentId === sid) { w.peerKey = pk; changed = true; }
147
+ }
148
+ if (changed) void persist(); // 落盘失败静默,不影响监听
149
+ return changed;
150
+ } catch { return false; }
151
+ }
152
+
153
+ /** 回灌该投给谁:优先"该 QQ 对象当前所在的会话",其次绑定时写死的父会话(老行为) */
154
+ function resolveParentId(w) {
155
+ try {
156
+ const pk = String(w?.peerKey ?? '');
157
+ const latest = pk ? String(peerLatest.get(pk) ?? '') : '';
158
+ return latest || String(w?.parentAgentId ?? '');
159
+ } catch { return String(w?.parentAgentId ?? ''); }
160
+ }
161
+
100
162
  /**
101
163
  * 默认存档路径:`{dataRoot}/.qqbot-extensions/tools/data/session-watch-watching.json`
102
164
  * (**与 `vote.json` 同一个 data 目录** —— 对齐既有约定:扩展各类的存档都放
@@ -126,6 +188,8 @@ export function ensureLoaded() {
126
188
  if (!file) return watching.size;
127
189
  const j = JSON.parse(readFileSync(file, 'utf8'));
128
190
  const rows = Array.isArray(j?.rows) ? j.rows : [];
191
+ // ★ 2026-10-14:读回"QQ 对象 → 最近发言会话"表(父会话跟着 QQ 对象走的依据)
192
+ try { for (const [k, v] of Object.entries(j?.peers ?? {})) if (k && v) peerLatest.set(String(k), String(v)); } catch { /* ignore */ }
129
193
  for (const r of rows) {
130
194
  const child = String(r?.child ?? '').trim();
131
195
  const parent = String(r?.parent ?? '').trim();
@@ -140,6 +204,7 @@ export function ensureLoaded() {
140
204
  preset: String(r?.preset ?? ''),
141
205
  workspace: String(r?.workspace ?? ''),
142
206
  peerName: String(r?.peerName ?? ''),
207
+ peerKey: String(r?.peerKey ?? ''),
143
208
  // ⚠️ 重启后拿不到 sender/replyTarget ⇒ 先恢复"回灌父会话",
144
209
  // QQ 转发等下次 register(或主人重挂)时补上 ⇒ 这里如实置空、不假装就绪。
145
210
  fwd: null,
@@ -199,8 +264,12 @@ async function persist() {
199
264
  workspace: w.workspace ?? '',
200
265
  mode: w.mode ?? 1,
201
266
  peerName: w.peerName ?? '',
267
+ peerKey: w.peerKey ?? '',
202
268
  }));
203
- writeFileSync(file, JSON.stringify({ v: 1, at: Date.now(), rows }, null, 2), 'utf8');
269
+ // ★ 2026-10-14:把"QQ 对象 → 最近发言会话"表一起落盘(父会话跟着 QQ 对象走的依据)
270
+ const peers = {};
271
+ for (const [k, v] of peerLatest.entries()) if (k && v) peers[k] = v;
272
+ writeFileSync(file, JSON.stringify({ v: 1, at: Date.now(), rows, peers }, null, 2), 'utf8');
204
273
  } catch { /* ignore */ }
205
274
  }
206
275
 
@@ -665,7 +734,12 @@ function parentEntryOf(sid) {
665
734
  * 只取文本会把图丢掉,所以这里额外把附件 URL/语音转写一并拼进载荷。
666
735
  * @returns {Promise<{ok: boolean, reason: string, id?: string, workspace?: string, atts?: number}>}
667
736
  */
668
- async function routeToSession(root, text, ev) {
737
+ /**
738
+ * ★ 对外导出(2026-10-14):入站扩展要**在钩子之前**自己投递 —— 因为"二档路由隔绝"会把这条消息
739
+ * 在早挂点就 annihilate 掉,一旦消灭成功,`session/event` 的 `user/message` 就永远不会发生,
740
+ * 原来那条投递路径(本文件里的监听器)根本不会跑。所以投递逻辑必须能被扩展直接调。
741
+ */
742
+ export async function routeToSession(root, text, ev) {
669
743
  try {
670
744
  const key = parseRouteKey(text);
671
745
  if (!key) return { ok: false, reason: 'no-key' };
@@ -838,6 +912,9 @@ async function handler(root, session, event) {
838
912
 
839
913
  // ── 引用回流:只关心**入站 user 消息**,且只在"某个盯梢对象的父会话"里认 ──
840
914
  if (type === 'user/message') {
915
+ // ★ 2026-10-14:先学"这个 QQ 对象最近在哪个会话说话"(父会话跟着 QQ 对象走的依据)。
916
+ // 必须放在路由分支**之前** —— 否则主人只发路由消息时,这张表永远学不到东西。
917
+ notePeerSeen(session, sid);
841
918
  const t = textOf(event);
842
919
  // ★ 2026-10-14:**通用路由优先** —— 正文里出现会话 id(`[s:xxx]` / `【会话 xxx】`)就直接投给
843
920
  // 那个会话。**不限已盯梢、不限是不是父会话**(主人要的"任何已有会话都能被点名")。
@@ -919,7 +996,10 @@ async function handler(root, session, event) {
919
996
  w.lastSent = text;
920
997
 
921
998
  const agents = root.get('agents');
922
- const parent = agents?.get?.(w.parentAgentId) ?? null;
999
+ // ★ 2026-10-14:**父会话跟着 QQ 对象走** —— 该 QQ 对象当前所在的会话优先(/bot-new 之后自动跟过去),
1000
+ // 没学到才回落到绑定时写死的那个 id(老行为,向后兼容)。
1001
+ const parentId = resolveParentId(w);
1002
+ const parent = agents?.get?.(parentId) ?? null;
923
1003
  if (!parent) return; // 父不在(不 live)→ 静默跳过,不制造错误
924
1004
 
925
1005
  const candidate = buildSettlementNotice({ sessionId: sid, label: w.label, rawReason, text });
@@ -990,6 +1070,9 @@ export async function register(root, opts = {}) {
990
1070
  if (HOOK_DISABLED) return '未挂(回灌钩子当前被 HOOK_DISABLED 主动关闭,属预期;恢复见 installHook 注释)';
991
1071
  if (!hooked) return '未挂(root.on 抛错)';
992
1072
  const fwd = (opts.sender && opts.replyTarget) ? { sender: opts.sender, replyTarget: opts.replyTarget } : null;
1073
+ // ★ 2026-10-14:记下这条盯梢属于哪个 **QQ 对象**(scope:peerId)—— 父会话要跟着它走。
1074
+ // 优先级:调用方显式给的 peerKey > replyTarget 里能抠出来的 > 空(空则退回写死的 parentAgentId)。
1075
+ const pk = String(opts.peerKey ?? '').trim() || peerKeyOfTarget(opts.replyTarget);
993
1076
  watching.set(childSessionId, {
994
1077
  parentAgentId,
995
1078
  label,
@@ -1000,6 +1083,7 @@ export async function register(root, opts = {}) {
1000
1083
  preset: String(opts.preset ?? '').trim(),
1001
1084
  workspace: String(opts.workspace ?? '').trim(),
1002
1085
  peerName: String(opts.peerName ?? ''),
1086
+ peerKey: pk,
1003
1087
  fwd,
1004
1088
  lastText: '',
1005
1089
  texts: [],
@@ -1008,6 +1092,8 @@ export async function register(root, opts = {}) {
1008
1092
  firedAt: 0,
1009
1093
  lastForwarded: '',
1010
1094
  });
1095
+ // 绑定时先按"当时那个会话"给这张表起步;**只在没记录时写**,别把已经学到的新会话覆盖回旧的。
1096
+ if (pk && parentAgentId && !peerLatest.has(pk)) peerLatest.set(pk, parentAgentId);
1011
1097
  await persist();
1012
1098
  const modeText = mode === 2 ? '二档(全部正文+报错→QQ,不进父上下文)'
1013
1099
  : mode === 3 ? '三档(进父上下文但不唤醒 + 全转QQ)'
@@ -1032,13 +1118,47 @@ export async function unregister(childSessionId) {
1032
1118
  }
1033
1119
 
1034
1120
  /** 给别的工具(如 session_watch 状态查询)读当前盯着的清单 */
1035
- export function listWatching() {
1036
- ensureLoaded(); // ★ 先保证存档已读回内存(重启后第一次调它就能看到完整名单)
1121
+ /**
1122
+ * ★ 某个路由短 id 对应的**回灌挡位**(读内存名单;没绑过返回 0)。
1123
+ *
1124
+ * 入站扩展用它判断"这条路由消息要不要在父上下文里彻底消失":
1125
+ * 挡位 2(二档)= 双向隔绝 —— 子会话的正文只去 QQ、**不进父上下文**(原有语义),
1126
+ * 那么反向也一样:**用户发进群的路由消息也不该落进父上下文**(2026-10-14 主人定)。
1127
+ * 没绑过的(纯通用路由)返回 0 ⇒ **不进隔绝**,保持原样投递 + 照常进上下文。
1128
+ */
1129
+ export function modeOfRoute(key) {
1130
+ try {
1131
+ ensureLoaded();
1132
+ const k = String(key ?? '').trim();
1133
+ if (!k) return 0;
1134
+ for (const [sid, w] of watching.entries()) {
1135
+ const short = shortSessionId(sid); // 例:fc871e99
1136
+ if (short === k || sid === k || sid === `session-${k}`) return Number(w?.mode ?? 1) || 1;
1137
+ }
1138
+ } catch { /* ignore */ }
1139
+ return 0;
1140
+ }
1141
+
1142
+ /**
1143
+ * 供工具/命令展示:这条盯梢**当前**会把回灌投给谁(= 跟着 QQ 对象动态解析之后的结果)。
1144
+ * 与 `parentAgentId`(绑定时写死的那个)不同:发过 `/bot-new` 之后这两个会不一样。
1145
+ */
1146
+ export function effectiveParentOf(childSessionId) {
1147
+ ensureLoaded();
1148
+ const w = watching.get(String(childSessionId ?? ''));
1149
+ return w ? resolveParentId(w) : '';
1150
+ }
1151
+
1152
+ export function listWatching() { ensureLoaded(); // ★ 先保证存档已读回内存(重启后第一次调它就能看到完整名单)
1037
1153
  return [...watching.entries()].map(([sid, w]) => ({
1038
1154
  childSessionId: sid,
1039
1155
  shortId: shortSessionId(sid),
1040
1156
  parentAgentId: w.parentAgentId,
1041
1157
  parentShortId: shortSessionId(w.parentAgentId),
1158
+ /** QQ 对象键(scope:peerId);父会话会跟着它动态解析 */
1159
+ peerKey: w.peerKey ?? '',
1160
+ /** 当前实际会投给的父会话(发过 /bot-new 后会与 parentAgentId 不同) */
1161
+ effectiveParentShortId: shortSessionId(resolveParentId(w)),
1042
1162
  label: w.label,
1043
1163
  mode: w.mode ?? 1,
1044
1164
  /** 归属行三件套(会随转发消息发到 QQ) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaofan/dsh-qqbot",
3
- "version": "1.10.4",
3
+ "version": "1.10.6",
4
4
  "description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
5
5
  "type": "module",
6
6
  "main": "./entry.js",
@@ -94,13 +94,15 @@
94
94
  "@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.1",
95
95
  "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.1",
96
96
  "@deepseek-ai/schemastery": "3.18.4",
97
- "@huggingface/transformers": "^4.3.0",
98
97
  "@tencent-connect/qqbot-connector": "1.2.0",
99
98
  "@tencent-connect/qqbot-nodejs": "^1.0.4",
100
99
  "js-yaml": "^4.1.0",
101
100
  "lamejs-fixed": "^1.2.2",
102
101
  "silk-wasm": "^3.7.1"
103
102
  },
103
+ "optionalDependencies": {
104
+ "@huggingface/transformers": "^4.3.0"
105
+ },
104
106
  "peerDependencies": {
105
107
  "@deepseek-ai/cordis": ">=4.0.1",
106
108
  "@deepseek-ai/dsh-agent": ">=0.1.0-rc.6",
@@ -112,19 +114,19 @@
112
114
  },
113
115
  "peerDependenciesMeta": {
114
116
  "@deepseek-ai/cordis": {
115
- "optional": false
117
+ "optional": true
116
118
  },
117
119
  "@deepseek-ai/schemastery": {
118
- "optional": false
120
+ "optional": true
119
121
  },
120
122
  "@deepseek-ai/dsh-agent": {
121
- "optional": false
123
+ "optional": true
122
124
  },
123
125
  "@deepseek-ai/dsh-session": {
124
- "optional": false
126
+ "optional": true
125
127
  },
126
128
  "@deepseek-ai/dsh-llm": {
127
- "optional": false
129
+ "optional": true
128
130
  }
129
131
  },
130
132
  "devDependencies": {