@zaofan/dsh-qqbot 1.10.3 → 1.10.5

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.
@@ -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.3",
3
+ "version": "1.10.5",
4
4
  "description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
5
5
  "type": "module",
6
6
  "main": "./entry.js",