@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.
@@ -4,10 +4,14 @@
4
4
  * `im.message.create` API expects as `content` for `msg_type: interactive`
5
5
  * (card schema 2.0; callback buttons live inside a column_set/column layout).
6
6
  *
7
- * Buttons carry a small `{ action }` callback behavior that
8
- * `card.action.trigger` events echo back (when the app subscribes that
9
- * callback); every button also carries a numeric label so the number-reply
10
- * fallback stays usable without button callbacks.
7
+ * The session list lays each row out as a `column_set` (fixed-width ⭐
8
+ * watch-toggle column + weighted session-button column), which is how V2
9
+ * expresses a row of buttons.
10
+ *
11
+ * Buttons carry a small `{ action }` value object that `card.action.trigger`
12
+ * events echo back (when the app subscribes that event); every numbered
13
+ * button also has a numeric label so the number-reply fallback stays usable
14
+ * without button callbacks.
11
15
  */
12
16
 
13
17
  export const MENU_PAGE_SIZE = 10;
@@ -39,6 +43,17 @@ function button(content, actionValue) {
39
43
  };
40
44
  }
41
45
 
46
+ /** The raw button element (without the full-width column_set wrapper). */
47
+ function buttonElement(content, actionValue) {
48
+ return {
49
+ tag: 'button',
50
+ text: plainText(content),
51
+ type: 'default',
52
+ width: 'fill',
53
+ behaviors: [{ type: 'callback', value: { action: actionValue } }],
54
+ };
55
+ }
56
+
42
57
  function safeTitle(value) {
43
58
  const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
44
59
  return title || '暂无标题';
@@ -65,6 +80,7 @@ export function menuCard() {
65
80
  // have card.action.trigger yet, so rendering it as a callback button would
66
81
  // send the user straight back to Feishu's broken callback setup popup.
67
82
  { tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
83
+ button('7 · 关注列表', 'watchlist'),
68
84
  ]);
69
85
  }
70
86
 
@@ -101,23 +117,43 @@ export function cardActionProbeCard(nonce) {
101
117
  }
102
118
 
103
119
  /**
104
- * One page of the workspace's sessions. Each row is a bind button; the
105
- * number label equals the reply-number for the same action (fallback).
120
+ * One page of the workspace's sessions. Each row is a `column_set` pair:
121
+ * the fixed-width ⭐ watch toggle (`⭐关注` / `⭐取关` for already-watched
122
+ * sessions) followed by the session button that carries the page-local
123
+ * number label (reply-number fallback = bind). Archived sessions are marked
124
+ * in the label. `watchedSessionIds` is a Set-like of ids this conversation
125
+ * already watches.
106
126
  */
107
- export function sessionListCard(workspace, sessions, page, total) {
127
+ export function sessionListCard(workspace, sessions, page, total, watchedSessionIds = new Set()) {
108
128
  const start = page * MENU_PAGE_SIZE;
109
129
  const slice = sessions.slice(start, start + MENU_PAGE_SIZE);
110
130
  const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE));
131
+ const watched = (id) => typeof watchedSessionIds?.has === 'function' && watchedSessionIds.has(id);
132
+ /** One row: fixed 90px watch toggle + the session button filling the rest. */
133
+ const row = (watchButton, sessionButton) => ({
134
+ tag: 'column_set',
135
+ flex_mode: 'none',
136
+ horizontal_spacing: 'default',
137
+ columns: [
138
+ { tag: 'column', width: '90px', vertical_align: 'center', elements: [watchButton] },
139
+ { tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [sessionButton] },
140
+ ],
141
+ });
111
142
  const elements = [
112
143
  { tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
113
- ...slice.map((session, offset) => button(
114
- `${offset + 1}. ${safeTitle(session.title)}`,
115
- `use:${session.sessionId}`,
116
- )),
144
+ ...slice.map((session, offset) => {
145
+ // Page-local numbering: number replies resolve against this page.
146
+ const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
147
+ const watching = watched(session.sessionId);
148
+ return row(
149
+ buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
150
+ buttonElement(label, `use:${session.sessionId}`),
151
+ );
152
+ }),
117
153
  ];
118
154
  if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
119
155
  if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
120
- elements.push({ tag: 'div', text: markdown('回复数字(1~N)同样可以绑定本页会话。') });
156
+ elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
121
157
  return cardWith('📂 会话列表', elements);
122
158
  }
123
159
 
@@ -146,10 +182,49 @@ export function menuHelpText() {
146
182
  '4 · /status 连接状态',
147
183
  '5 · /help 本帮助',
148
184
  '6 · /repair 修复卡片按钮(请回复数字 6)',
185
+ '7 · /watchlist 关注列表',
149
186
  '',
150
187
  '直接发送文字/图片即继续当前会话。',
151
188
  '/session ID 或序号 绑定已有会话',
189
+ '/watch ID 或序号 关注会话(完成后推送)',
152
190
  '/compact 压缩上下文',
153
191
  '/workspace 绝对路径 切换工作区',
154
192
  ].join('\n');
155
193
  }
194
+
195
+ /** The watch list for one conversation (unwatch buttons + reply fallback). */
196
+ export function watchListCard(entries) {
197
+ const elements = entries.length === 0
198
+ ? [{ tag: 'div', text: markdown('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。') }]
199
+ : [
200
+ { tag: 'div', text: markdown('任务完成会自动推送,回复数字或点按钮取消关注:') },
201
+ ...entries.map((entry, index) => button(
202
+ `${index + 1}. ${safeTitle(entry.title)}`,
203
+ `unwatch:${entry.sessionId}`,
204
+ )),
205
+ ];
206
+ return cardWith('👁 关注列表', elements);
207
+ }
208
+
209
+ /**
210
+ * The completion push card. `title` is the session title, `reason` the
211
+ * turn-end kind (completed / stopped / aborted).
212
+ */
213
+ export function completionCard(sessionId, title, reason) {
214
+ const reasonText = reason === 'completed'
215
+ ? '已完成'
216
+ : reason === 'stopped'
217
+ ? '已停止'
218
+ : reason === 'aborted'
219
+ ? '已中止'
220
+ : reason === 'cancelled'
221
+ ? '已取消'
222
+ : '已结束';
223
+ return cardWith('✅ 任务完成', [
224
+ { tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
225
+ { tag: 'div', text: markdown(`**状态**:${reasonText}`) },
226
+ button('打开会话列表', 'sessions'),
227
+ button('工作区', 'workspaces'),
228
+ { tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
229
+ ]);
230
+ }
@@ -1,7 +1,24 @@
1
1
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
 
4
- const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
4
+ const EMPTY_STATE = Object.freeze({
5
+ version: 1,
6
+ sessions: {},
7
+ seenMessageIds: [],
8
+ watches: {},
9
+ includeArchivedSessions: false,
10
+ });
11
+
12
+ /** One conversation key may watch at most this many sessions. */
13
+ export const MAX_WATCHES_PER_KEY = 20;
14
+
15
+ /** A persisted watch entry: the watched session plus its delivery target. */
16
+ function validWatchEntry(value) {
17
+ return value
18
+ && typeof value === 'object'
19
+ && typeof value.sessionId === 'string' && value.sessionId.length > 0
20
+ && typeof value.chatId === 'string' && value.chatId.length > 0;
21
+ }
5
22
 
6
23
  export class StateStore {
7
24
  #path;
@@ -19,6 +36,10 @@ export class StateStore {
19
36
  version: 1,
20
37
  sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
21
38
  seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
39
+ watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
40
+ includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
41
+ ? parsed.includeArchivedSessions
42
+ : false,
22
43
  };
23
44
  } catch (error) {
24
45
  if (error?.code !== 'ENOENT') throw error;
@@ -63,6 +84,69 @@ export class StateStore {
63
84
  return structuredClone(this.#state);
64
85
  }
65
86
 
87
+ // ── Watches (persisted: surviving restarts) ─────────────────────────────
88
+
89
+ watchEntries(key) {
90
+ const list = this.#state.watches[key];
91
+ return Array.isArray(list) ? list.filter(validWatchEntry) : [];
92
+ }
93
+
94
+ watchEntry(key, sessionId) {
95
+ return this.watchEntries(key).find((entry) => entry.sessionId === sessionId) ?? null;
96
+ }
97
+
98
+ async setWatch(key, entry) {
99
+ const list = this.#state.watches[key] ?? [];
100
+ const index = list.findIndex((existing) => existing.sessionId === entry.sessionId);
101
+ if (index === -1) {
102
+ if (list.length >= MAX_WATCHES_PER_KEY) list.shift();
103
+ list.push(entry);
104
+ } else {
105
+ list[index] = entry;
106
+ }
107
+ this.#state.watches[key] = list;
108
+ await this.#persist();
109
+ }
110
+
111
+ async removeWatch(key, sessionId) {
112
+ const list = this.#state.watches[key] ?? [];
113
+ this.#state.watches[key] = list.filter((entry) => entry.sessionId !== sessionId);
114
+ await this.#persist();
115
+ }
116
+
117
+ async clearWatches(key) {
118
+ delete this.#state.watches[key];
119
+ await this.#persist();
120
+ }
121
+
122
+ /** Every conversation key currently watching the given session. */
123
+ keysWatching(sessionId) {
124
+ return Object.entries(this.#state.watches)
125
+ .filter(([, list]) => Array.isArray(list) && list.some((entry) => validWatchEntry(entry) && entry.sessionId === sessionId))
126
+ .map(([key]) => key);
127
+ }
128
+
129
+ /** Unique watched session ids across all keys (restart compensation). */
130
+ watchedSessionIds() {
131
+ const ids = new Set();
132
+ for (const list of Object.values(this.#state.watches)) {
133
+ if (!Array.isArray(list)) continue;
134
+ for (const entry of list) if (validWatchEntry(entry)) ids.add(entry.sessionId);
135
+ }
136
+ return [...ids];
137
+ }
138
+
139
+ // ── Session-list archived policy (per bot) ───────────────────
140
+
141
+ includesArchivedSessions() {
142
+ return this.#state.includeArchivedSessions === true;
143
+ }
144
+
145
+ async setIncludeArchivedSessions(include) {
146
+ this.#state.includeArchivedSessions = include === true;
147
+ await this.#persist();
148
+ }
149
+
66
150
  async #persist() {
67
151
  const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
68
152
  this.#writeQueue = this.#writeQueue.then(async () => {
@@ -1223,6 +1223,125 @@ export class HarnessClient {
1223
1223
  });
1224
1224
  }
1225
1225
 
1226
+ /**
1227
+ * Watch the global Harness event mux (all sessions) until `signal`
1228
+ * aborts, reconnecting on drop. The Desktop host serves the mux as a
1229
+ * WebSocket downlink; frames are `server-request` envelopes whose payload
1230
+ * is a `session/event` — only those are forwarded. `onReconnect` (when
1231
+ * provided) fires after every (re)connection so callers can compensate
1232
+ * for events missed while offline.
1233
+ */
1234
+ async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
1235
+ if (typeof onSessionEvent !== 'function') {
1236
+ throw new TypeError('watchHarnessEvents requires onSessionEvent');
1237
+ }
1238
+ if (!signal || typeof signal.addEventListener !== 'function') {
1239
+ throw new TypeError('watchHarnessEvents requires an AbortSignal');
1240
+ }
1241
+ if (onReconnect !== undefined && typeof onReconnect !== 'function') {
1242
+ throw new TypeError('onReconnect must be a function');
1243
+ }
1244
+ const url = new URL('/api/events.mux', this.#baseUrl);
1245
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1246
+ while (!signal.aborted) {
1247
+ try {
1248
+ await this.#watchHarnessEventSocket(url.toString(), {
1249
+ signal,
1250
+ onSessionEvent,
1251
+ onReconnect,
1252
+ });
1253
+ } catch (error) {
1254
+ if (signal.aborted) return;
1255
+ console.warn(`[${this.#logPrefix}] Harness event mux disconnected:`, error.message);
1256
+ }
1257
+ if (signal.aborted) return;
1258
+ try {
1259
+ await sleep(this.#interactionReconnectDelayMs, signal);
1260
+ } catch {
1261
+ if (signal.aborted) return;
1262
+ throw new Error('Harness event mux reconnect wait failed');
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ #watchHarnessEventSocket(url, { signal, onSessionEvent, onReconnect }) {
1268
+ return new Promise((resolve, reject) => {
1269
+ let socket;
1270
+ try {
1271
+ socket = this.#createWebSocket(url);
1272
+ } catch (error) {
1273
+ reject(error);
1274
+ return;
1275
+ }
1276
+ let opened = false;
1277
+ let finished = false;
1278
+ const close = () => {
1279
+ try {
1280
+ socket.close();
1281
+ } catch {
1282
+ // Already closed.
1283
+ }
1284
+ };
1285
+ const finish = (error) => {
1286
+ if (finished) return;
1287
+ finished = true;
1288
+ socket.removeEventListener('open', handleOpen);
1289
+ socket.removeEventListener('message', handleMessage);
1290
+ socket.removeEventListener('close', handleClose);
1291
+ socket.removeEventListener('error', handleError);
1292
+ signal.removeEventListener('abort', handleAbort);
1293
+ if (error) reject(error);
1294
+ else resolve();
1295
+ };
1296
+ const handleOpen = () => {
1297
+ opened = true;
1298
+ try {
1299
+ onReconnect?.();
1300
+ } catch (error) {
1301
+ console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
1302
+ }
1303
+ };
1304
+ const handleMessage = (event) => {
1305
+ try {
1306
+ if (typeof event.data !== 'string') return;
1307
+ const envelope = JSON.parse(event.data);
1308
+ const payload = envelope?.payload;
1309
+ if (envelope?.type !== 'server-request'
1310
+ || !payload
1311
+ || typeof payload !== 'object'
1312
+ || envelope.method !== payload.type
1313
+ || payload.type !== 'session/event'
1314
+ || typeof payload.sessionId !== 'string'
1315
+ || !payload.event
1316
+ || typeof payload.event !== 'object') return;
1317
+ onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
1318
+ } catch (error) {
1319
+ console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
1320
+ }
1321
+ };
1322
+ const handleClose = () => finish(opened ? null : new Error(
1323
+ 'Harness event mux WebSocket closed before opening',
1324
+ ));
1325
+ const handleError = () => {
1326
+ finish(new Error(opened
1327
+ ? 'Harness event mux WebSocket failed'
1328
+ : 'Harness event mux WebSocket failed before opening'));
1329
+ close();
1330
+ };
1331
+ const handleAbort = () => {
1332
+ close();
1333
+ finish();
1334
+ };
1335
+
1336
+ socket.addEventListener('open', handleOpen);
1337
+ socket.addEventListener('message', handleMessage);
1338
+ socket.addEventListener('close', handleClose, { once: true });
1339
+ socket.addEventListener('error', handleError, { once: true });
1340
+ signal.addEventListener('abort', handleAbort, { once: true });
1341
+ if (signal.aborted) handleAbort();
1342
+ });
1343
+ }
1344
+
1226
1345
  stopManagedProcess() {
1227
1346
  if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM');
1228
1347
  }
@@ -13,6 +13,18 @@ export class ImagePromptError extends Error {
13
13
  }
14
14
  }
15
15
 
16
+ const HOST_ATTACHMENT_USER_MESSAGES = Object.freeze({
17
+ MODEL_DOES_NOT_SUPPORT_IMAGES:
18
+ '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
19
+ IMAGE_TOO_LARGE: '图片超过宿主允许的大小,请压缩后重试。',
20
+ IMAGE_TOO_MANY_PIXELS: '图片分辨率过高,请压缩后重试。',
21
+ INVALID_IMAGE: '图片内容无效或格式不受支持,请重新发送。',
22
+ INVALID_IMAGE_BASE64: '未能读取图片内容,请重新发送。',
23
+ IMAGE_TYPE_MISMATCH: '图片格式与实际内容不一致,请重新发送。',
24
+ TOO_MANY_IMAGES: '一次发送的图片数量超过宿主限制,请减少后重试。',
25
+ IMAGES_TOO_LARGE: '图片总大小超过宿主限制,请减少图片或压缩后重试。',
26
+ });
27
+
16
28
  function requestSignal(signal, timeoutMs) {
17
29
  const timeout = AbortSignal.timeout(timeoutMs);
18
30
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -263,6 +275,25 @@ export async function promptContentForMessage(message, {
263
275
  return content;
264
276
  }
265
277
 
278
+ /** Return only allowlisted, user-safe image failure details. */
279
+ export function imagePromptDiagnostic(error) {
280
+ if (error instanceof ImagePromptError) {
281
+ return {
282
+ code: 'image-prompt-error',
283
+ reason: error.code,
284
+ userMessage: error.userMessage,
285
+ };
286
+ }
287
+ if (error?.code !== 'attachment-error' || typeof error?.details?.reason !== 'string') {
288
+ return null;
289
+ }
290
+ const reason = error.details.reason;
291
+ const userMessage = Object.hasOwn(HOST_ATTACHMENT_USER_MESSAGES, reason)
292
+ ? HOST_ATTACHMENT_USER_MESSAGES[reason]
293
+ : null;
294
+ return userMessage ? { code: 'attachment-error', reason, userMessage } : null;
295
+ }
296
+
266
297
  export function imagePromptUserMessage(error) {
267
- return error instanceof ImagePromptError ? error.userMessage : null;
298
+ return imagePromptDiagnostic(error)?.userMessage ?? null;
268
299
  }
@@ -184,7 +184,7 @@ function authenticatedHeaders(token) {
184
184
  function baseInfo() {
185
185
  return {
186
186
  channel_version: WEIXIN_PROTOCOL_VERSION,
187
- bot_agent: 'DeepSeekHarness/0.16.0',
187
+ bot_agent: 'DeepSeekHarness/0.17.1',
188
188
  };
189
189
  }
190
190
 
@@ -23,12 +23,14 @@ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
23
23
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
24
24
  import {
25
25
  hasInboundImages,
26
+ imagePromptDiagnostic,
26
27
  imagePromptUserMessage,
27
28
  promptContentForMessage,
28
29
  } from '../shared/image-prompt.mjs';
29
30
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
30
31
 
31
32
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
33
+ const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
32
34
 
33
35
  const HELP_TEXT = [
34
36
  '微信已连接 DeepSeek Harness。',
@@ -69,6 +71,16 @@ function canClaimInteractionReply(message, pending) {
69
71
  && nonEmptyString(extractWeixinText(message));
70
72
  }
71
73
 
74
+ function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
75
+ const diagnostic = imagePromptDiagnostic(error);
76
+ return {
77
+ code: diagnostic?.code ?? 'message-processing-failed',
78
+ reason: diagnostic?.reason ?? 'UNKNOWN',
79
+ message: diagnostic?.userMessage ?? userMessage,
80
+ at: Date.now(),
81
+ };
82
+ }
83
+
72
84
  export function createWeixinBridgeStatus() {
73
85
  return {
74
86
  messagesReceived: 0,
@@ -78,6 +90,7 @@ export function createWeixinBridgeStatus() {
78
90
  lastReplyAt: null,
79
91
  lastRejectedAt: null,
80
92
  lastError: null,
93
+ lastMessageError: null,
81
94
  };
82
95
  }
83
96
 
@@ -168,8 +181,9 @@ export class WeixinHarnessBridge {
168
181
  ).catch((error) => {
169
182
  if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
170
183
  this.#status.lastError = error?.message ?? String(error);
184
+ this.#status.lastMessageError = safeMessageError(error);
171
185
  this.#logger.error?.('[dsh-weixin] failed to process a command:', error);
172
- return this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId)
186
+ return this.#send(sender, GENERIC_PROCESSING_ERROR, contextToken, runId)
173
187
  .catch(() => undefined);
174
188
  }).finally(() => {
175
189
  this.#acceptedMessageIds.delete(messageId);
@@ -289,6 +303,7 @@ export class WeixinHarnessBridge {
289
303
  if (reply) await this.#send(sender, reply, contextToken, runId);
290
304
  }
291
305
  this.#status.lastError = null;
306
+ this.#status.lastMessageError = null;
292
307
  }
293
308
 
294
309
  async #process(message, key, { alreadyRecorded = false } = {}) {
@@ -401,6 +416,7 @@ export class WeixinHarnessBridge {
401
416
  this.#status.messagesReplied += 1;
402
417
  this.#status.lastReplyAt = new Date().toISOString();
403
418
  this.#status.lastError = null;
419
+ this.#status.lastMessageError = null;
404
420
  } catch (error) {
405
421
  if (error?.code === 'turn-stopped') {
406
422
  await this.#state.markSeen(messageId);
@@ -408,11 +424,13 @@ export class WeixinHarnessBridge {
408
424
  }
409
425
  if (this.#signal?.aborted) return;
410
426
  this.#status.lastError = error?.message ?? String(error);
427
+ const userMessage = imagePromptUserMessage(error) ?? GENERIC_PROCESSING_ERROR;
428
+ this.#status.lastMessageError = safeMessageError(error, userMessage);
411
429
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
412
430
  try {
413
431
  await this.#send(
414
432
  sender,
415
- imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
433
+ userMessage,
416
434
  contextToken,
417
435
  runId,
418
436
  );
@@ -526,6 +544,7 @@ export class WeixinHarnessBridge {
526
544
  });
527
545
  this.#clearPendingInteraction(key, pending.interactionId);
528
546
  this.#status.lastError = null;
547
+ this.#status.lastMessageError = null;
529
548
  } catch (error) {
530
549
  if (this.#signal?.aborted) return;
531
550
  if (error?.code === 'interaction-not-pending') {
@@ -719,13 +738,14 @@ export class WeixinHarnessBridge {
719
738
  async #handleInteractionFailure(message, messageId, error) {
720
739
  if (this.#signal?.aborted) return;
721
740
  this.#status.lastError = error?.message ?? String(error);
741
+ this.#status.lastMessageError = safeMessageError(error);
722
742
  this.#logger.error?.('[dsh-weixin] failed to process an interaction reply:', error);
723
743
  if (!this.#state.hasSeen(messageId)) {
724
744
  await this.#state.markSeen(messageId).catch(() => undefined);
725
745
  }
726
746
  await this.#send(
727
747
  nonEmptyString(message?.from_user_id),
728
- '消息处理失败,请稍后重试。',
748
+ GENERIC_PROCESSING_ERROR,
729
749
  nonEmptyString(message?.context_token) ?? undefined,
730
750
  nonEmptyString(message?.run_id) ?? undefined,
731
751
  ).catch(() => undefined);
@@ -62,6 +62,20 @@ function safeAccountError(code, message) {
62
62
  return Object.freeze({ code, message });
63
63
  }
64
64
 
65
+ function publicMessageError(value) {
66
+ if (!value || typeof value !== 'object'
67
+ || typeof value.code !== 'string' || !value.code
68
+ || typeof value.reason !== 'string' || !value.reason
69
+ || typeof value.message !== 'string' || !value.message
70
+ || !Number.isFinite(value.at)) return null;
71
+ return {
72
+ code: value.code.slice(0, 64),
73
+ reason: value.reason.slice(0, 128),
74
+ message: value.message.slice(0, 500),
75
+ at: value.at,
76
+ };
77
+ }
78
+
65
79
  function activationStageError(code, cause) {
66
80
  const error = new Error(`Weixin activation failed during ${code}`, { cause });
67
81
  error.name = 'WeixinActivationStageError';
@@ -351,6 +365,7 @@ export class WeixinController {
351
365
  messagesReceived: runtimeStatus?.messagesReceived ?? 0,
352
366
  messagesReplied: runtimeStatus?.messagesReplied ?? 0,
353
367
  },
368
+ lastMessageError: publicMessageError(runtimeStatus?.lastMessageError),
354
369
  error: error ? structuredClone(error) : null,
355
370
  };
356
371
  });