@xmanrui/dsh-im 0.16.0 → 0.17.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -45,6 +45,15 @@ function normalizeTestMessage(value) {
45
45
  return { sent: false, code };
46
46
  }
47
47
 
48
+ function normalizeMessageError(value) {
49
+ if (!isRecord(value)) return null;
50
+ const code = string(value.code).slice(0, 64);
51
+ const reason = string(value.reason).slice(0, 128);
52
+ const message = string(value.message).slice(0, 500);
53
+ const at = timestamp(value.at);
54
+ return code && reason && message && at !== null ? { code, reason, message, at } : null;
55
+ }
56
+
48
57
  export function unwrapRpcResult(result) {
49
58
  if (!isRecord(result) || typeof result.ok !== 'boolean') {
50
59
  throw new Error('微信服务返回了无法识别的响应');
@@ -128,6 +137,7 @@ function normalizeBot(value) {
128
137
  messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0),
129
138
  messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0),
130
139
  },
140
+ lastMessageError: normalizeMessageError(value.lastMessageError),
131
141
  error: isRecord(value.error)
132
142
  ? {
133
143
  code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),
@@ -230,6 +230,10 @@ export function AccountCard({
230
230
  }),
231
231
  h('div', { className: 'dxw-accountFooter dim-cardFooter' },
232
232
  summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
233
+ account.lastMessageError ? h('div', {
234
+ className: 'dxw-summary dim-cardSummary',
235
+ role: 'status',
236
+ }, `最近一条消息处理失败:${account.lastMessageError.message}`) : null,
233
237
  feedback ? h('div', {
234
238
  className: 'dxw-summary dim-cardSummary',
235
239
  role: 'status',
@@ -449,6 +449,20 @@ const EN = Object.freeze({
449
449
  '微信绑定没有完成': 'WeChat setup did not complete',
450
450
  '微信连接正常': 'WeChat connection is healthy',
451
451
  '微信连接未就绪': 'WeChat connection is not ready',
452
+ '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。': 'The current model does not support images. Use /models to list models, then /model <number> to switch and resend.',
453
+ '图片超过宿主允许的大小,请压缩后重试。': 'The image exceeds the Host size limit. Compress it and try again.',
454
+ '图片分辨率过高,请压缩后重试。': 'The image resolution is too high. Compress it and try again.',
455
+ '图片内容无效或格式不受支持,请重新发送。': 'The image is invalid or unsupported. Send it again.',
456
+ '未能读取图片内容,请重新发送。': 'The image could not be read. Send it again.',
457
+ '图片格式与实际内容不一致,请重新发送。': 'The declared image format does not match its content. Send it again.',
458
+ '一次发送的图片数量超过宿主限制,请减少后重试。': 'The message exceeds the Host image-count limit. Remove some images and try again.',
459
+ '图片总大小超过宿主限制,请减少图片或压缩后重试。': 'The images exceed the Host total-size limit. Remove or compress some images and try again.',
460
+ '图片下载地址发生了重定向,暂时无法读取。': 'The image download redirected and cannot be read.',
461
+ '图片超过 5 MB,请压缩后重试。': 'The image exceeds 5 MB. Compress it and try again.',
462
+ '一次发送的图片总大小过大,请减少图片数量或压缩后重试。': 'The images are too large in total. Remove or compress some images and try again.',
463
+ '图片下载失败,请重新发送后再试。': 'The image download failed. Send it again.',
464
+ '暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。': 'This image format is unsupported. Send a JPEG, PNG, WebP, or GIF image.',
465
+ '消息处理失败,请稍后重试。': 'Message processing failed. Try again later.',
452
466
  '微信服务没有返回有效的账号列表': 'WeChat did not return a valid account list',
453
467
  '尚未绑定微信': 'No WeChat account connected yet',
454
468
  '用于把微信机器人绑定到 DeepSeek Harness 的一次性二维码': 'One-time QR code for connecting a WeChat bot to DeepSeek Harness',
@@ -522,6 +536,12 @@ function translateDynamic(text) {
522
536
  }
523
537
  match = /^二维码剩余 (.+)$/.exec(text);
524
538
  if (match) return `QR code expires in ${match[1]}`;
539
+ match = /^最近一条消息处理失败:(.+)$/.exec(text);
540
+ if (match) return `Latest message failed: ${localizeText(match[1])}`;
541
+ match = /^图片下载失败(HTTP (.+)),请重新发送后再试。$/.exec(text);
542
+ if (match) return `The image download failed (HTTP ${match[1]}). Send it again.`;
543
+ match = /^一次最多只能处理 (\d+) 张图片。$/.exec(text);
544
+ if (match) return `A message can contain at most ${match[1]} images.`;
525
545
  match = /^状态刷新失败:(.+)$/.exec(text);
526
546
  if (match) return `Status refresh failed: ${match[1]}`;
527
547
  match = /^状态自动刷新失败:(.+)$/.exec(text);
@@ -108,7 +108,7 @@ export class DiscordApi {
108
108
  headers: {
109
109
  authorization: `Bot ${this.#token}`,
110
110
  'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.16.0)',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.17.1)',
112
112
  },
113
113
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
114
  signal: requestSignal(signal, timeoutMs),
@@ -32,11 +32,14 @@ import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot
32
32
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
33
33
  import {
34
34
  MENU_PAGE_SIZE,
35
+ completionCard,
35
36
  menuCard,
36
37
  menuHelpText,
37
38
  sessionListCard,
39
+ watchListCard,
38
40
  workspaceListCard,
39
41
  } from './feishu-cards.mjs';
42
+ import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
40
43
 
41
44
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
42
45
  const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
@@ -44,6 +47,9 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
44
47
  const MENU_COMMAND = /^\/m(?:enu)?$/i;
45
48
  const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
46
49
  const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
50
+ const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
51
+ const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
52
+ const WATCHLIST_COMMAND = /^\/watchlist$/i;
47
53
  const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
48
54
  const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
49
55
  const NUMBER_REPLY = /^\d{1,2}$/;
@@ -83,9 +89,15 @@ const HELP_TEXT = [
83
89
  '/status 检查连接状态',
84
90
  '/repair 修复卡片按钮回调',
85
91
  '/m(或 /menu) 打开交互卡片菜单',
92
+ '/watch [Session ID 或序号] 关注会话,任务完成自动推送',
93
+ '/unwatch [Session ID 或序号] 取消关注',
94
+ '/watchlist 查看关注列表',
95
+ '/archived on|off 会话列表是否包含归档会话',
86
96
  '/help 显示本帮助',
87
97
  ].join('\n');
88
98
 
99
+ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
100
+
89
101
  /** Safe user-facing text for bind/workspace failures (no raw messages). */
90
102
  function safeErrorText(error) {
91
103
  switch (error?.code) {
@@ -106,6 +118,13 @@ function nonEmptyString(value) {
106
118
  return typeof value === 'string' && value.trim() ? value.trim() : null;
107
119
  }
108
120
 
121
+ function orderedHistoryEvents(history) {
122
+ return (Array.isArray(history?.events) ? history.events : [])
123
+ .map((entry) => entry?.event ?? entry)
124
+ .filter((entry) => entry && typeof entry === 'object' && Number.isFinite(entry.seq))
125
+ .sort((left, right) => left.seq - right.seq);
126
+ }
127
+
109
128
  function senderOpenId(event) {
110
129
  return nonEmptyString(event?.sender?.sender_id?.open_id)
111
130
  ?? nonEmptyString(event?.sender?.sender_id?.user_id);
@@ -240,6 +259,12 @@ export class FeishuHarnessBridge {
240
259
  #menus = new Map();
241
260
  /** Interactive-card message id → route context for button callbacks. */
242
261
  #cardKeys = new Map();
262
+ /** The global event-mux watcher (one per bridge). */
263
+ #eventWatcher = null;
264
+ /** Serializes live completions and reconnect compensation. */
265
+ #eventTail = Promise.resolve();
266
+ /** Earliest completion that still needs delivery for each watch. */
267
+ #failedWatchSeqs = new Map();
243
268
 
244
269
  constructor({
245
270
  client,
@@ -295,6 +320,11 @@ export class FeishuHarnessBridge {
295
320
  this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
296
321
  this.#signal = signal;
297
322
  ensureStatus(this.#status);
323
+ // Persisted watches must resume at runtime start, not on the first
324
+ // message. Older hosts without the mux watcher simply skip this.
325
+ if (typeof this.#harness?.watchHarnessEvents === 'function') {
326
+ queueMicrotask(() => this.#ensureEventWatcher());
327
+ }
298
328
  }
299
329
 
300
330
  accept(event) {
@@ -519,6 +549,7 @@ export class FeishuHarnessBridge {
519
549
  )),
520
550
  ...this.#interactionTasks,
521
551
  ...this.#commandTasks,
552
+ this.#eventTail,
522
553
  ]);
523
554
  }
524
555
 
@@ -604,6 +635,36 @@ export class FeishuHarnessBridge {
604
635
  await this.#showWorkspaces({ chatId: event.message.chat_id, key });
605
636
  return;
606
637
  }
638
+ if (WATCH_COMMAND.test(commandText)) {
639
+ const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
640
+ await this.#runWatch(key, event.message.chat_id, target);
641
+ return;
642
+ }
643
+ if (UNWATCH_COMMAND.test(commandText)) {
644
+ const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
645
+ await this.#runUnwatch(key, event.message.chat_id, target);
646
+ return;
647
+ }
648
+ if (WATCHLIST_COMMAND.test(commandText)) {
649
+ await this.#showWatchList(key, event.message.chat_id);
650
+ return;
651
+ }
652
+ if (ARCHIVED_COMMAND.test(commandText)) {
653
+ const match = ARCHIVED_COMMAND.exec(commandText);
654
+ const value = match[1]?.toLowerCase();
655
+ if (value !== 'on' && value !== 'off') {
656
+ await this.#send(event.message.chat_id, '用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)');
657
+ return;
658
+ }
659
+ if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
660
+ await this.#state.setIncludeArchivedSessions(value === 'on');
661
+ }
662
+ await this.#send(
663
+ event.message.chat_id,
664
+ value === 'on' ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。',
665
+ );
666
+ return;
667
+ }
607
668
  if (NUMBER_REPLY.test(commandText)) {
608
669
  const menu = this.#takeMenu(key);
609
670
  if (menu) {
@@ -991,7 +1052,15 @@ export class FeishuHarnessBridge {
991
1052
  if (!action) return Promise.resolve();
992
1053
  const messageId = nonEmptyString(event?.context?.open_message_id);
993
1054
  const entry = messageId ? this.#cardKeys.get(messageId) : null;
994
- if (!entry) return Promise.resolve();
1055
+ if (!entry) {
1056
+ // The card predates this process (the in-memory mapping resets on
1057
+ // restart) or never came from us: nudge instead of staying silent.
1058
+ const chatId = nonEmptyString(event?.context?.open_chat_id);
1059
+ if (chatId) {
1060
+ this.#send(chatId, '这个菜单已过期,请回复 /m 重新打开。').catch(() => undefined);
1061
+ }
1062
+ return Promise.resolve();
1063
+ }
995
1064
  // The promise is returned so tests (and future callers) can await the
996
1065
  // action; the runtime dispatcher ignores it.
997
1066
  return this.#handleCardAction(action, entry).catch((error) => {
@@ -1009,6 +1078,10 @@ export class FeishuHarnessBridge {
1009
1078
  await this.#showWorkspaces({ chatId, key });
1010
1079
  return;
1011
1080
  }
1081
+ if (action === 'watchlist') {
1082
+ await this.#showWatchList(key, chatId);
1083
+ return;
1084
+ }
1012
1085
  if (action === 'new') {
1013
1086
  await this.#state.clearSession(key);
1014
1087
  await this.#send(chatId, '已开启全新 Harness 会话。');
@@ -1029,6 +1102,14 @@ export class FeishuHarnessBridge {
1029
1102
  }
1030
1103
  if (action.startsWith('workspace:')) {
1031
1104
  await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length));
1105
+ return;
1106
+ }
1107
+ if (action.startsWith('unwatch:')) {
1108
+ await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
1109
+ return;
1110
+ }
1111
+ if (action.startsWith('watch:')) {
1112
+ await this.#runWatch(key, chatId, action.slice('watch:'.length));
1032
1113
  }
1033
1114
  }
1034
1115
 
@@ -1053,7 +1134,7 @@ export class FeishuHarnessBridge {
1053
1134
 
1054
1135
  async #handleMenuPick(menu, number, { chatId, key, event }) {
1055
1136
  if (menu.kind === 'menu') {
1056
- const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair'][number - 1];
1137
+ const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair', 'watchlist'][number - 1];
1057
1138
  if (!action) {
1058
1139
  await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
1059
1140
  return;
@@ -1071,6 +1152,7 @@ export class FeishuHarnessBridge {
1071
1152
  await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
1072
1153
  return;
1073
1154
  }
1155
+ // The number label sits on the session (bind) button of the row.
1074
1156
  await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
1075
1157
  return;
1076
1158
  }
@@ -1081,9 +1163,26 @@ export class FeishuHarnessBridge {
1081
1163
  return;
1082
1164
  }
1083
1165
  await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
1166
+ return;
1167
+ }
1168
+ if (menu.kind === 'watches') {
1169
+ const entry = menu.entries[number - 1];
1170
+ if (!entry?.sessionId) {
1171
+ await this.#send(chatId, `关注列表只有 ${menu.entries.length} 个会话。`);
1172
+ return;
1173
+ }
1174
+ await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
1084
1175
  }
1085
1176
  }
1086
1177
 
1178
+ /** The sessions visible under the bot's archived policy. */
1179
+ #visibleSessions(sessions) {
1180
+ if (this.#state?.includesArchivedSessions?.() === false) {
1181
+ return sessions.filter((session) => session.archived !== true);
1182
+ }
1183
+ return sessions;
1184
+ }
1185
+
1087
1186
  async #showSessions({ chatId, key }, selector, page = 0) {
1088
1187
  try {
1089
1188
  const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
@@ -1092,7 +1191,7 @@ export class FeishuHarnessBridge {
1092
1191
  return;
1093
1192
  }
1094
1193
  const listed = await this.#harness.listWorkspaceSessions(resolved.workspace);
1095
- const sessions = Array.isArray(listed?.sessions) ? listed.sessions : [];
1194
+ const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
1096
1195
  const workspace = listed?.workspace ?? resolved.workspace;
1097
1196
  if (sessions.length === 0) {
1098
1197
  await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`);
@@ -1100,16 +1199,24 @@ export class FeishuHarnessBridge {
1100
1199
  }
1101
1200
  const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
1102
1201
  const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
1202
+ const watchedSet = new Set(
1203
+ (this.#state.watchEntries?.(key) ?? []).map((entry) => entry.sessionId),
1204
+ );
1205
+ const pageSlice = sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE);
1103
1206
  this.#rememberMenu(key, {
1104
1207
  kind: 'sessions',
1105
- sessions: sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE),
1106
- });
1107
- await this.#sendCard(chatId, sessionListCard(workspace, sessions, safePage, sessions.length), {
1108
- key,
1109
- // Keep the canonical selector result for later page callbacks. The
1110
- // list response's workspace is display data and is not authoritative.
1111
- sessionWorkspace: resolved.workspace,
1208
+ sessions: pageSlice.map((session) => ({ ...session, watched: watchedSet.has(session.sessionId) })),
1112
1209
  });
1210
+ await this.#sendCard(
1211
+ chatId,
1212
+ sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
1213
+ {
1214
+ key,
1215
+ // Keep the canonical selector result for later page callbacks. The
1216
+ // list response's workspace is display data and is not authoritative.
1217
+ sessionWorkspace: resolved.workspace,
1218
+ },
1219
+ );
1113
1220
  } catch (error) {
1114
1221
  this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
1115
1222
  await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');
@@ -1171,6 +1278,256 @@ export class FeishuHarnessBridge {
1171
1278
  return messageId;
1172
1279
  }
1173
1280
 
1281
+ // ── Watches: read-only session tracking + completion pushes ─────────────
1282
+
1283
+ #ensureEventWatcher() {
1284
+ if (this.#eventWatcher) return;
1285
+ if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
1286
+ if (this.#signal?.aborted) return;
1287
+ const signal = this.#signal ?? new AbortController().signal;
1288
+ try {
1289
+ this.#eventWatcher = this.#harness.watchHarnessEvents({
1290
+ signal,
1291
+ onSessionEvent: (payload) => this.#onHarnessEvent(payload),
1292
+ onReconnect: () => {
1293
+ void this.#queueEventTask(() => this.#compensateMissedEvents());
1294
+ },
1295
+ });
1296
+ Promise.resolve(this.#eventWatcher).catch((error) => {
1297
+ if (!signal.aborted) {
1298
+ this.#logger.warn?.('[dsh-feishu] event watcher stopped:', error.message);
1299
+ }
1300
+ });
1301
+ } catch (error) {
1302
+ this.#eventWatcher = null;
1303
+ this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message);
1304
+ }
1305
+ }
1306
+
1307
+ #queueEventTask(task) {
1308
+ const next = this.#eventTail.then(task, task).catch((error) => {
1309
+ if (!this.#signal?.aborted) {
1310
+ this.#logger.warn?.('[dsh-feishu] completion event failed:', error.message);
1311
+ }
1312
+ });
1313
+ this.#eventTail = next;
1314
+ return next;
1315
+ }
1316
+
1317
+ /**
1318
+ * Resolve a /watch target READ-ONLY: a session id is validated against
1319
+ * the registered workspaces' listings, an index against the current
1320
+ * workspace. Nothing is bound and no workspace is switched.
1321
+ */
1322
+ async #resolveWatchTarget(target) {
1323
+ if (typeof target !== 'string' || target === '') {
1324
+ return { error: '用法:/watch <Session ID 或当前工作区序号>' };
1325
+ }
1326
+ const numeric = /^\d{1,4}$/.test(target) ? Number(target) : null;
1327
+ const currentPath = typeof this.#harness?.currentWorkspace === 'function'
1328
+ ? this.#harness.currentWorkspace()
1329
+ : null;
1330
+ const listSessions = async (workspace) => {
1331
+ const listed = await this.#harness.listWorkspaceSessions(workspace);
1332
+ return Array.isArray(listed?.sessions) ? listed.sessions : [];
1333
+ };
1334
+ if (numeric !== null) {
1335
+ if (!currentPath) return { error: '当前机器人没有可用的工作区,无法按序号解析会话。' };
1336
+ const sessions = this.#visibleSessions(await listSessions(currentPath));
1337
+ const session = sessions[numeric - 1];
1338
+ if (!session?.sessionId) {
1339
+ return { error: `当前工作区只有 ${sessions.length} 个会话。` };
1340
+ }
1341
+ return { sessionId: session.sessionId, title: session.title ?? '暂无标题' };
1342
+ }
1343
+ const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
1344
+ ? (await this.#harness.listWorkspaces()).filter((path) => path !== currentPath)
1345
+ : [];
1346
+ const paths = [currentPath, ...extraPaths].filter(Boolean);
1347
+ for (const workspace of paths) {
1348
+ const sessions = await listSessions(workspace);
1349
+ const session = sessions.find((candidate) => candidate.sessionId === target);
1350
+ if (session) return { sessionId: target, title: session.title ?? '暂无标题' };
1351
+ }
1352
+ return { error: '没有找到这个会话,请用 /sessionlist 查看可用会话。' };
1353
+ }
1354
+
1355
+ async #latestSessionSeq(sessionId) {
1356
+ if (typeof this.#harness?.rpc !== 'function') return null;
1357
+ const history = await this.#harness.rpc(
1358
+ 'session.history',
1359
+ { sessionId, maxMessages: 20 },
1360
+ 30_000,
1361
+ { signal: this.#signal },
1362
+ );
1363
+ return orderedHistoryEvents(history).at(-1)?.seq ?? -1;
1364
+ }
1365
+
1366
+ async #runWatch(key, chatId, target) {
1367
+ this.#ensureEventWatcher();
1368
+ if (typeof this.#state?.setWatch !== 'function') {
1369
+ await this.#send(chatId, '当前状态存储不支持关注。');
1370
+ return;
1371
+ }
1372
+ let resolved;
1373
+ try {
1374
+ resolved = await this.#resolveWatchTarget(target);
1375
+ } catch (error) {
1376
+ await this.#send(chatId, `无法解析会话:${safeErrorText(error)}`);
1377
+ return;
1378
+ }
1379
+ if (resolved.error) {
1380
+ await this.#send(chatId, resolved.error);
1381
+ return;
1382
+ }
1383
+ const existing = this.#state.watchEntries?.(key) ?? [];
1384
+ const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
1385
+ if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
1386
+ await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`);
1387
+ return;
1388
+ }
1389
+ try {
1390
+ const lastSeq = typeof existingEntry?.lastSeq === 'number'
1391
+ ? existingEntry.lastSeq
1392
+ : await this.#latestSessionSeq(resolved.sessionId);
1393
+ await this.#state.setWatch(key, {
1394
+ sessionId: resolved.sessionId,
1395
+ title: resolved.title,
1396
+ chatId,
1397
+ lastSeq,
1398
+ });
1399
+ await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`);
1400
+ await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
1401
+ } catch (error) {
1402
+ await this.#send(chatId, `关注失败:${safeErrorText(error)}`);
1403
+ }
1404
+ }
1405
+
1406
+ async #runUnwatch(key, chatId, target) {
1407
+ if (typeof this.#state?.removeWatch !== 'function') return;
1408
+ const entries = this.#state.watchEntries?.(key) ?? [];
1409
+ const entry = typeof target === 'string' && /^\d{1,4}$/.test(target)
1410
+ ? entries[Number(target) - 1]
1411
+ : entries.find((candidate) => candidate.sessionId === target);
1412
+ if (!entry) {
1413
+ await this.#send(chatId, '关注列表里没有这个会话,回复 /watchlist 查看。');
1414
+ return;
1415
+ }
1416
+ try {
1417
+ await this.#state.removeWatch(key, entry.sessionId);
1418
+ this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
1419
+ await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`);
1420
+ } catch (error) {
1421
+ await this.#send(chatId, `取消失败:${safeErrorText(error)}`);
1422
+ }
1423
+ }
1424
+
1425
+ async #showWatchList(key, chatId) {
1426
+ const entries = this.#state.watchEntries?.(key) ?? [];
1427
+ this.#rememberMenu(key, { kind: 'watches', entries });
1428
+ await this.#sendCard(chatId, watchListCard(entries), { key });
1429
+ }
1430
+
1431
+ /** Queue live turn completions behind any reconnect compensation. */
1432
+ #onHarnessEvent({ sessionId, event }) {
1433
+ if (this.#signal?.aborted
1434
+ || !sessionId
1435
+ || !event
1436
+ || typeof event !== 'object'
1437
+ || event.type !== 'turn/end'
1438
+ || !Number.isFinite(event.seq)) return;
1439
+ void this.#queueEventTask(async () => {
1440
+ const hasFailedDelivery = (this.#state.keysWatching?.(sessionId) ?? [])
1441
+ .some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
1442
+ if (hasFailedDelivery) await this.#compensateSession(sessionId);
1443
+ await this.#deliverCompletion(sessionId, event);
1444
+ });
1445
+ }
1446
+
1447
+ async #deliverCompletion(sessionId, event) {
1448
+ if (this.#signal?.aborted || typeof this.#state?.keysWatching !== 'function') return;
1449
+ const reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
1450
+ for (const key of this.#state.keysWatching(sessionId)) {
1451
+ if (this.#signal?.aborted) return;
1452
+ const entry = this.#state.watchEntry?.(key, sessionId);
1453
+ const deliveryKey = `${key}\0${sessionId}`;
1454
+ let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
1455
+ if (typeof failedSeq === 'number'
1456
+ && typeof entry?.lastSeq === 'number'
1457
+ && entry.lastSeq >= failedSeq) {
1458
+ this.#failedWatchSeqs.delete(deliveryKey);
1459
+ failedSeq = undefined;
1460
+ }
1461
+ if (!entry?.chatId
1462
+ || (typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq)
1463
+ || (typeof failedSeq === 'number' && event.seq > failedSeq)) continue;
1464
+ try {
1465
+ await this.#sendCard(
1466
+ entry.chatId,
1467
+ completionCard(sessionId, entry.title, reason),
1468
+ { key },
1469
+ );
1470
+ const current = this.#state.watchEntry?.(key, sessionId);
1471
+ if (!current
1472
+ || current.chatId !== entry.chatId
1473
+ || (typeof current.lastSeq === 'number' && current.lastSeq >= event.seq)) continue;
1474
+ await this.#state.setWatch(key, { ...current, lastSeq: event.seq });
1475
+ if (failedSeq === event.seq) this.#failedWatchSeqs.delete(deliveryKey);
1476
+ } catch (error) {
1477
+ this.#failedWatchSeqs.set(
1478
+ deliveryKey,
1479
+ typeof failedSeq === 'number' ? Math.min(failedSeq, event.seq) : event.seq,
1480
+ );
1481
+ this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
1482
+ }
1483
+ }
1484
+ }
1485
+
1486
+ async #compensateSession(sessionId) {
1487
+ if (this.#signal?.aborted || typeof this.#harness?.rpc !== 'function') return;
1488
+ try {
1489
+ const history = await this.#harness.rpc(
1490
+ 'session.history',
1491
+ { sessionId, maxMessages: 20 },
1492
+ 30_000,
1493
+ { signal: this.#signal },
1494
+ );
1495
+ const events = orderedHistoryEvents(history);
1496
+ const latestSeq = events.at(-1)?.seq ?? -1;
1497
+ const keys = typeof this.#state?.keysWatching === 'function'
1498
+ ? this.#state.keysWatching(sessionId)
1499
+ : [];
1500
+
1501
+ // Watches created by older versions have no baseline. Establish one
1502
+ // without replaying completions that predate the watch.
1503
+ for (const key of keys) {
1504
+ const entry = this.#state.watchEntry?.(key, sessionId);
1505
+ if (entry && typeof entry.lastSeq !== 'number') {
1506
+ await this.#state.setWatch(key, { ...entry, lastSeq: latestSeq });
1507
+ }
1508
+ }
1509
+
1510
+ for (const event of events) {
1511
+ if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event);
1512
+ }
1513
+ } catch (error) {
1514
+ if (!this.#signal?.aborted) {
1515
+ this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
1516
+ }
1517
+ }
1518
+ }
1519
+
1520
+ /** Replay recent turn completions missed while the mux was disconnected. */
1521
+ async #compensateMissedEvents() {
1522
+ const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
1523
+ ? this.#state.watchedSessionIds()
1524
+ : [];
1525
+ for (const sessionId of sessionIds) {
1526
+ if (this.#signal?.aborted) return;
1527
+ await this.#compensateSession(sessionId);
1528
+ }
1529
+ }
1530
+
1174
1531
  #interactionAskOptions(event, key) {
1175
1532
  return {
1176
1533
  timeoutMs: this.#replyTimeoutMs,