@xmanrui/dsh-im 2.4.0 → 2.5.0

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 (47) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/lib/client.js +321 -109
  4. package/lib/index.js +214 -207
  5. package/package.json +1 -1
  6. package/plugin-src/client/channel-card-meta.js +36 -1
  7. package/plugin-src/client/channels/dingtalk/api.js +2 -0
  8. package/plugin-src/client/channels/dingtalk/index.js +9 -1
  9. package/plugin-src/client/channels/feishu/api.js +3 -0
  10. package/plugin-src/client/channels/feishu/index.js +9 -1
  11. package/plugin-src/client/channels/qq/api.js +2 -0
  12. package/plugin-src/client/channels/qq/index.js +9 -1
  13. package/plugin-src/client/channels/shared/token-api.js +2 -0
  14. package/plugin-src/client/channels/shared/token-channel.js +9 -1
  15. package/plugin-src/client/channels/wecom/api.js +2 -0
  16. package/plugin-src/client/channels/wecom/index.js +9 -1
  17. package/plugin-src/client/channels/weixin/api.js +2 -10
  18. package/plugin-src/client/channels/weixin/index.js +9 -5
  19. package/plugin-src/client/channels/whatsapp/api.js +2 -0
  20. package/plugin-src/client/channels/whatsapp/index.js +9 -1
  21. package/plugin-src/client/i18n.js +4 -0
  22. package/plugin-src/client/index.js +16 -2
  23. package/plugin-src/client/last-message-error.js +17 -0
  24. package/plugin-src/client/styles.js +5 -1
  25. package/plugin-src/host/channels/feishu/rpc.mjs +3 -0
  26. package/src/channels/dingtalk/dingtalk-api.mjs +1 -1
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +49 -16
  28. package/src/channels/dingtalk/dingtalk-card-stream.mjs +2 -2
  29. package/src/channels/dingtalk/dingtalk-controller.mjs +2 -0
  30. package/src/channels/feishu/bridge.mjs +99 -43
  31. package/src/channels/feishu/multi-bot-controller.mjs +2 -0
  32. package/src/channels/qq/qq-bridge.mjs +83 -28
  33. package/src/channels/qq/qq-controller.mjs +2 -0
  34. package/src/channels/shared/harness-client.mjs +40 -4
  35. package/src/channels/shared/i18n-en/shared-a.mjs +77 -0
  36. package/src/channels/shared/message-failure.mjs +244 -0
  37. package/src/channels/shared/semantic/artifact-delivery.mjs +9 -2
  38. package/src/channels/shared/text-harness-bridge.mjs +65 -60
  39. package/src/channels/shared/token-bot-controller.mjs +2 -0
  40. package/src/channels/slack/slack-controller.mjs +2 -0
  41. package/src/channels/wecom/wecom-bridge.mjs +49 -15
  42. package/src/channels/wecom/wecom-controller.mjs +2 -0
  43. package/src/channels/weixin/weixin-api.mjs +47 -0
  44. package/src/channels/weixin/weixin-bridge.mjs +261 -43
  45. package/src/channels/weixin/weixin-controller.mjs +2 -15
  46. package/src/channels/weixin/weixin-runtime.mjs +1 -0
  47. package/src/channels/whatsapp/whatsapp-controller.mjs +2 -0
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import { connectionTestMessage } from '../shared/connection-test.mjs';
4
4
  import { t } from '../shared/i18n.mjs';
5
+ import { publicMessageFailure } from '../shared/message-failure.mjs';
5
6
  import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
6
7
 
7
8
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
@@ -319,6 +320,7 @@ export class QqController {
319
320
  messagesReceived: runtimeStatus?.messagesReceived ?? 0,
320
321
  messagesReplied: runtimeStatus?.messagesReplied ?? 0,
321
322
  },
323
+ lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
322
324
  error: structuredClone(this.#errors.get(config.botId) ?? null),
323
325
  };
324
326
  });
@@ -532,6 +532,41 @@ export class HarnessInteractionError extends Error {
532
532
  }
533
533
  }
534
534
 
535
+ export class HarnessTurnError extends Error {
536
+ constructor(code, { reason, providerCode } = {}) {
537
+ super(`Harness turn failed (${code})`);
538
+ this.name = 'HarnessTurnError';
539
+ this.code = code;
540
+ this.promptAccepted = true;
541
+ if (reason && typeof reason === 'object') this.reason = reason;
542
+ if (typeof providerCode === 'string' && providerCode) this.providerCode = providerCode;
543
+ }
544
+ }
545
+
546
+ function harnessTurnError(reason) {
547
+ const kind = nonEmptyText(reason?.kind) ?? nonEmptyText(reason);
548
+ if (kind === 'error') {
549
+ const failure = reason?.error ?? reason?.failure;
550
+ return new HarnessTurnError('harness-turn-failed', {
551
+ reason,
552
+ providerCode: nonEmptyText(failure?.code) ?? undefined,
553
+ });
554
+ }
555
+ if (kind === 'max-tokens') return new HarnessTurnError('model-max-tokens', { reason });
556
+ if (kind === 'blocked') return new HarnessTurnError('turn-blocked', { reason });
557
+ if (['interrupted', 'stopped', 'cancelled', 'canceled'].includes(kind)) {
558
+ return new HarnessTurnError('turn-interrupted', { reason });
559
+ }
560
+ if (kind === 'aborted') return new HarnessTurnError('turn-aborted', { reason });
561
+ if (kind === 'completed') return new HarnessTurnError('model-empty-response', { reason });
562
+ return new HarnessTurnError('harness-turn-failed', { reason });
563
+ }
564
+
565
+ function harnessTurnSucceeded(reason) {
566
+ if (reason === null || reason === undefined) return true;
567
+ return (nonEmptyText(reason?.kind) ?? nonEmptyText(reason)) === 'completed';
568
+ }
569
+
535
570
  export class HarnessClient {
536
571
  #baseUrl;
537
572
  #workspace;
@@ -1282,6 +1317,9 @@ export class HarnessClient {
1282
1317
  }
1283
1318
  if (!tracker.finished) continue;
1284
1319
  turnFinished = true;
1320
+ if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
1321
+ throw harnessTurnError(tracker.reason);
1322
+ }
1285
1323
  // An accepted /stop revokes attachment delivery even when Harness
1286
1324
  // preserved a useful partial text answer for the existing UX.
1287
1325
  const artifactCount = ownership?.stopRequested
@@ -1292,11 +1330,9 @@ export class HarnessClient {
1292
1330
  }
1293
1331
  if (artifactCount > 0) return '';
1294
1332
  if (ownership?.stopRequested) throw turnStoppedError();
1295
- throw new Error(
1296
- `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
1297
- );
1333
+ throw harnessTurnError(tracker.reason);
1298
1334
  }
1299
- throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
1335
+ throw new HarnessTurnError('harness-reply-timeout');
1300
1336
  } catch (error) {
1301
1337
  // Once cancellation was accepted, transport/poll failures and timeouts
1302
1338
  // describe the convergence of that stop, not an unrelated ask failure.
@@ -27,6 +27,83 @@ export default {
27
27
  '结果文件「{name}」已生成,但当前渠道暂时未能发送,请稍后重试。':
28
28
  'The result file "{name}" was generated, but this channel could not send it right now. Please try again later.',
29
29
  '消息处理失败,请稍后重试。': 'Failed to process the message. Please try again later.',
30
+ '卡片已结束,请查看后续消息。': 'This card has ended. Please check the next message.',
31
+ '工具调用「{name}」未成功,请检查工具配置或稍后重试。': 'Tool call "{name}" did not succeed. Check the tool configuration or try again later.',
32
+ '无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。':
33
+ 'Could not connect to the processing service, so the message was not submitted. Make sure DeepSeek Harness is running, then try again.',
34
+ '处理服务响应超时,消息尚未开始处理。请稍后重试。':
35
+ 'The processing service timed out before the message started. Please try again later.',
36
+ '暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。':
37
+ 'The task status could not be confirmed and the task may have started. Wait or send /stop before submitting it again.',
38
+ '消息提交结果未能确认,任务可能已经开始。请先等待或发送 /status 查看状态,不要立即重复提交。':
39
+ 'The message submission could not be confirmed and the task may have started. Wait or send /status before submitting it again.',
40
+ '暂时无法读取任务进度,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
41
+ 'Task progress is temporarily unavailable and the task may still be running. Wait or send /stop before submitting it again.',
42
+ '处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。':
43
+ 'The processing service rejected the bot connection. Ask an administrator to check the Harness address, proxy, or access settings.',
44
+ '机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。':
45
+ 'The bot and DeepSeek Harness use incompatible APIs. Ask an administrator to check the Harness address and update the related components.',
46
+ 'DeepSeek Harness 暂时无法完成请求,请稍后重试。':
47
+ 'DeepSeek Harness could not complete the request. Please try again later.',
48
+ '等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
49
+ 'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.',
50
+ '模型凭据缺失或已失效。请管理员检查模型配置后重试。':
51
+ 'The model credentials are missing or invalid. Ask an administrator to check the model settings.',
52
+ '模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。':
53
+ 'The model quota or balance is insufficient. Ask an administrator to add quota or switch models, then try again.',
54
+ '模型服务正在限流,本次任务未完成。请稍后重试。':
55
+ 'The model service is rate-limiting requests. Please try again later.',
56
+ '当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。':
57
+ 'This conversation exceeds the model context limit. Send /compact or /new, then try again.',
58
+ '当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
59
+ 'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
60
+ '当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。':
61
+ 'The current model does not exist or does not support the selected settings. Send /models and use /model to choose again.',
62
+ '当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。':
63
+ 'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.',
64
+ '当前模型不支持所选配置。请切换模型或推理等级后重试。':
65
+ 'The current model does not support the selected settings. Switch the model or reasoning effort, then try again.',
66
+ '模型服务响应超时,本次任务未完成。请稍后重试。':
67
+ 'The model service timed out and the task did not finish. Please try again later.',
68
+ '暂时无法连接模型服务,本次任务未完成。请稍后重试。':
69
+ 'The model service is temporarily unreachable and the task did not finish. Please try again later.',
70
+ '模型服务暂时异常,本次任务未完成。请稍后重试。':
71
+ 'The model service is temporarily unavailable and the task did not finish. Please try again later.',
72
+ '模型回复中断或格式异常,本次任务未完成。请重试。':
73
+ 'The model reply was interrupted or malformed, so the task did not finish. Please try again.',
74
+ '模型没有返回可显示的内容。请重试;若持续发生,请切换模型。':
75
+ 'The model returned no displayable content. Try again, or switch models if this keeps happening.',
76
+ '模型拒绝处理当前内容。请调整问题内容后重试。':
77
+ 'The model rejected the current content. Revise the request and try again.',
78
+ '模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。':
79
+ 'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.',
80
+ '任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。':
81
+ 'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.',
82
+ '任务被意外中断,本次未完成。请重试。':
83
+ 'The task was unexpectedly interrupted and did not finish. Please try again.',
84
+ '当前会话已不存在。请发送 /new 创建新会话后重试。':
85
+ 'The current Session no longer exists. Send /new to create one, then try again.',
86
+ '当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。':
87
+ 'The current Session is still processing the previous task. Wait for it to finish, or send /stop before trying again.',
88
+ '工作区或会话状态刚刚发生变化。请重新发送这条消息。':
89
+ 'The Workspace or Session state just changed. Please send this message again.',
90
+ '当前工作区不存在或暂不可用。请重新选择工作区后重试。':
91
+ 'The current Workspace does not exist or is unavailable. Select another Workspace and try again.',
92
+ '当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。':
93
+ 'The current Agent Preset does not exist or is unavailable. Send /presetlist and select another one.',
94
+ '回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。':
95
+ 'The reply was generated, but the bot cannot send it. Ask an administrator to check channel permissions or reconnect the bot.',
96
+ '回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。':
97
+ 'The reply was generated, but this channel is rate-limiting messages. Please try again later.',
98
+ '回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。':
99
+ 'Reply delivery could not be confirmed. Check whether it already arrived before submitting the task again.',
100
+ '回复已经生成,但当前渠道暂时无法发送。请稍后重试。':
101
+ 'The reply was generated, but this channel could not send it. Please try again later.',
102
+ '当前消息包含无法处理的图片或文件。请调整后重新发送。':
103
+ 'This message contains an image or file that cannot be processed. Adjust it and send it again.',
104
+ '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。':
105
+ 'The task did not finish and the cause could not be determined. Try again; if it persists, give the reference ID to an administrator.',
106
+ '错误码:{code};参考号:{referenceId}': 'Error code: {code}; reference: {referenceId}',
30
107
  '{label}机器人': '{label} bot',
31
108
  '目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
32
109
  '目前支持文字、图片和文件消息。':
@@ -0,0 +1,244 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { t } from './i18n.mjs';
4
+
5
+ const PROVIDER_FAILURES = Object.freeze({
6
+ AUTH: 'MODEL_AUTH',
7
+ MISSING_CREDENTIAL: 'MODEL_AUTH',
8
+ INVALID_CREDENTIAL: 'MODEL_AUTH',
9
+ QUOTA: 'MODEL_QUOTA',
10
+ RATE_LIMIT: 'MODEL_RATE_LIMIT',
11
+ CONTEXT_WINDOW_EXCEEDED: 'MODEL_CONTEXT_LIMIT',
12
+ UNKNOWN_MODEL: 'MODEL_UNAVAILABLE',
13
+ NO_ADAPTER: 'MODEL_UNAVAILABLE',
14
+ UNSUPPORTED_OPTION: 'MODEL_CONFIG',
15
+ UNSUPPORTED_REASONING_EFFORT: 'MODEL_CONFIG',
16
+ TIMEOUT: 'MODEL_TIMEOUT',
17
+ TRANSPORT: 'MODEL_TRANSPORT',
18
+ SERVER: 'MODEL_SERVICE',
19
+ STREAM_CLOSED: 'MODEL_STREAM',
20
+ MALFORMED_RESPONSE: 'MODEL_STREAM',
21
+ EMPTY_RESPONSE: 'MODEL_EMPTY_REPLY',
22
+ CONTENT_FILTER: 'MODEL_CONTENT_REJECTED',
23
+ UNSUPPORTED_CONTENT: 'MODEL_CONFIG',
24
+ });
25
+
26
+ const FAILURE_MESSAGES = Object.freeze({
27
+ HARNESS_CONNECT:
28
+ '无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
29
+ HARNESS_TIMEOUT:
30
+ '处理服务响应超时,消息尚未开始处理。请稍后重试。',
31
+ HARNESS_RESULT_UNCERTAIN:
32
+ '暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。',
33
+ HARNESS_ACCESS:
34
+ '处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。',
35
+ HARNESS_PROTOCOL:
36
+ '机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。',
37
+ HARNESS_SERVICE:
38
+ 'DeepSeek Harness 暂时无法完成请求,请稍后重试。',
39
+ MODEL_REPLY_TIMEOUT:
40
+ '等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。',
41
+ MODEL_AUTH:
42
+ '模型凭据缺失或已失效。请管理员检查模型配置后重试。',
43
+ MODEL_QUOTA:
44
+ '模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。',
45
+ MODEL_RATE_LIMIT:
46
+ '模型服务正在限流,本次任务未完成。请稍后重试。',
47
+ MODEL_CONTEXT_LIMIT:
48
+ '当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
49
+ MODEL_UNAVAILABLE:
50
+ '当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。',
51
+ MODEL_CONFIG:
52
+ '当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
53
+ MODEL_TIMEOUT:
54
+ '模型服务响应超时,本次任务未完成。请稍后重试。',
55
+ MODEL_TRANSPORT:
56
+ '暂时无法连接模型服务,本次任务未完成。请稍后重试。',
57
+ MODEL_SERVICE:
58
+ '模型服务暂时异常,本次任务未完成。请稍后重试。',
59
+ MODEL_STREAM:
60
+ '模型回复中断或格式异常,本次任务未完成。请重试。',
61
+ MODEL_EMPTY_REPLY:
62
+ '模型没有返回可显示的内容。请重试;若持续发生,请切换模型。',
63
+ MODEL_CONTENT_REJECTED:
64
+ '模型拒绝处理当前内容。请调整问题内容后重试。',
65
+ MODEL_OUTPUT_LIMIT:
66
+ '模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
67
+ TURN_BLOCKED:
68
+ '任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
69
+ TURN_INTERRUPTED:
70
+ '任务被意外中断,本次未完成。请重试。',
71
+ SESSION_NOT_FOUND:
72
+ '当前会话已不存在。请发送 /new 创建新会话后重试。',
73
+ SESSION_BUSY:
74
+ '当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
75
+ SESSION_STALE:
76
+ '工作区或会话状态刚刚发生变化。请重新发送这条消息。',
77
+ WORKSPACE_UNAVAILABLE:
78
+ '当前工作区不存在或暂不可用。请重新选择工作区后重试。',
79
+ PRESET_UNAVAILABLE:
80
+ '当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。',
81
+ CHANNEL_PERMISSION:
82
+ '回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。',
83
+ CHANNEL_RATE_LIMIT:
84
+ '回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。',
85
+ CHANNEL_DELIVERY_UNCERTAIN:
86
+ '回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。',
87
+ CHANNEL_DELIVERY:
88
+ '回复已经生成,但当前渠道暂时无法发送。请稍后重试。',
89
+ INPUT_INVALID:
90
+ '当前消息包含无法处理的图片或文件。请调整后重新发送。',
91
+ INTERNAL_UNKNOWN:
92
+ '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
93
+ });
94
+
95
+ function providerFailureCode(error) {
96
+ if (error?.code !== 'harness-turn-failed') return null;
97
+ const value = error?.providerCode ?? error?.details?.providerCode;
98
+ if (typeof value !== 'string') return null;
99
+ return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
100
+ }
101
+
102
+ function failureCode(error) {
103
+ const code = typeof error?.code === 'string' ? error.code : '';
104
+ const providerCode = providerFailureCode(error);
105
+ if (providerCode) return providerCode;
106
+
107
+ if (code === 'harness-connect-failed') {
108
+ return ['session.prompt', 'session.history'].includes(error?.method)
109
+ ? 'HARNESS_RESULT_UNCERTAIN'
110
+ : 'HARNESS_CONNECT';
111
+ }
112
+ if (code === 'harness-timeout') {
113
+ return ['session.prompt', 'session.history'].includes(error?.method)
114
+ ? 'HARNESS_RESULT_UNCERTAIN'
115
+ : 'HARNESS_TIMEOUT';
116
+ }
117
+ if (code === 'harness-reply-timeout') return 'MODEL_REPLY_TIMEOUT';
118
+ if ([
119
+ 'harness-auth-required', 'harness-proxy-auth-required',
120
+ 'harness-loopback-forbidden', 'harness-host-untrusted',
121
+ 'harness-request-forbidden',
122
+ ].includes(code)) return 'HARNESS_ACCESS';
123
+ if (['harness-api-not-found', 'harness-response-invalid'].includes(code)) {
124
+ return 'HARNESS_PROTOCOL';
125
+ }
126
+ if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
127
+ if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
128
+ if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
129
+ if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
130
+ if (code === 'turn-blocked') return 'TURN_BLOCKED';
131
+ if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
132
+ if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
133
+ if (code === 'agent-busy') return 'SESSION_BUSY';
134
+ if (code === 'workspace-session-stale') return 'SESSION_STALE';
135
+ if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
136
+ if (code.startsWith('agent-preset-')) return 'PRESET_UNAVAILABLE';
137
+ if (code.startsWith('image-') || code.startsWith('inbound-file-')
138
+ || code === 'attachment-error') return 'INPUT_INVALID';
139
+
140
+ const status = Number(error?.status ?? error?.httpStatus);
141
+ if (status === 401 || status === 403
142
+ || [
143
+ 'channel-permission',
144
+ 'permission-required',
145
+ 'artifact-permission-required',
146
+ 'forbidden',
147
+ 'stale-token',
148
+ ].includes(code)) {
149
+ return 'CHANNEL_PERMISSION';
150
+ }
151
+ if (status === 429
152
+ || ['channel-rate-limit', 'rate-limited', 'artifact-rate-limited'].includes(code)) {
153
+ return 'CHANNEL_RATE_LIMIT';
154
+ }
155
+ if (['channel-delivery-uncertain', 'delivery-uncertain', 'artifact-delivery-uncertain']
156
+ .includes(code)) {
157
+ return 'CHANNEL_DELIVERY_UNCERTAIN';
158
+ }
159
+ if (code === 'channel-delivery-failed' || code.startsWith('artifact-')
160
+ || ['network-error', 'timeout'].includes(code)) {
161
+ return 'CHANNEL_DELIVERY';
162
+ }
163
+ return 'INTERNAL_UNKNOWN';
164
+ }
165
+
166
+ function safeReferenceId(value) {
167
+ return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
168
+ ? value
169
+ : `MF-${randomUUID().slice(0, 8).toUpperCase()}`;
170
+ }
171
+
172
+ function safeFailureReason(value) {
173
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(value)) return null;
174
+ return value.toUpperCase().replaceAll('-', '_');
175
+ }
176
+
177
+ export function classifyMessageFailure(error, {
178
+ userMessage,
179
+ reason,
180
+ referenceId,
181
+ at = Date.now(),
182
+ } = {}) {
183
+ const safeReason = safeFailureReason(reason);
184
+ const classifiedCode = failureCode(error);
185
+ const code = classifiedCode === 'INTERNAL_UNKNOWN'
186
+ && safeReason
187
+ && typeof userMessage === 'string'
188
+ && userMessage.trim()
189
+ ? 'INPUT_INVALID'
190
+ : classifiedCode;
191
+ return Object.freeze({
192
+ code,
193
+ reason: safeReason ?? code,
194
+ message: typeof userMessage === 'string' && userMessage.trim()
195
+ ? userMessage.trim()
196
+ : t(FAILURE_MESSAGES[code]),
197
+ referenceId: safeReferenceId(referenceId),
198
+ at: Number.isFinite(at) ? at : Date.now(),
199
+ });
200
+ }
201
+
202
+ export function messageFailureText(failure) {
203
+ return `${failure.message}\n\n${t('错误码:{code};参考号:{referenceId}', failure)}`;
204
+ }
205
+
206
+ export function setLastMessageFailure(status, error, options) {
207
+ const failure = classifyMessageFailure(error, options);
208
+ status.lastMessageError = failure;
209
+ return failure;
210
+ }
211
+
212
+ export function clearLastMessageFailure(status) {
213
+ status.lastMessageError = null;
214
+ }
215
+
216
+ export function channelDeliveryFailure(error, { uncertain = true } = {}) {
217
+ const wrapped = new Error('Channel message delivery failed', { cause: error });
218
+ const status = Number(error?.status ?? error?.httpStatus);
219
+ wrapped.code = status === 401 || status === 403
220
+ ? 'channel-permission'
221
+ : status === 429
222
+ ? 'channel-rate-limit'
223
+ : uncertain
224
+ ? 'channel-delivery-uncertain'
225
+ : 'channel-delivery-failed';
226
+ if (Number.isInteger(status)) wrapped.status = status;
227
+ return wrapped;
228
+ }
229
+
230
+ export function publicMessageFailure(value) {
231
+ if (!value || typeof value !== 'object'
232
+ || typeof value.code !== 'string' || !value.code
233
+ || typeof value.reason !== 'string' || !value.reason
234
+ || typeof value.message !== 'string' || !value.message
235
+ || typeof value.referenceId !== 'string' || !value.referenceId
236
+ || !Number.isFinite(value.at)) return null;
237
+ return {
238
+ code: value.code.slice(0, 64),
239
+ reason: value.reason.slice(0, 64),
240
+ message: value.message.slice(0, 500),
241
+ referenceId: value.referenceId.slice(0, 40),
242
+ at: value.at,
243
+ };
244
+ }
@@ -73,6 +73,7 @@ export async function deliverOutboundArtifacts({
73
73
  sendFile,
74
74
  sendImage,
75
75
  sendFailureNotice,
76
+ onFailure,
76
77
  logger,
77
78
  }) {
78
79
  const receipts = baseReceipt ? [baseReceipt] : [];
@@ -107,14 +108,20 @@ export async function deliverOutboundArtifacts({
107
108
  } catch (error) {
108
109
  if (isAbort(error, signal)) throw error;
109
110
  artifactSendErrors += 1;
111
+ const failure = typeof onFailure === 'function'
112
+ ? await onFailure(artifact, error)
113
+ : null;
114
+ const reference = typeof failure?.referenceId === 'string'
115
+ ? ` [${failure.referenceId}]`
116
+ : '';
110
117
  logger?.warn?.(
111
- `[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
118
+ `[dsh-im:${channelKey}] result artifact delivery failed${reference} (${error?.code ?? 'unknown'})`,
112
119
  );
113
120
  let messageIds = [];
114
121
  if (typeof sendFailureNotice === 'function') {
115
122
  try {
116
123
  signal?.throwIfAborted();
117
- const notice = await sendFailureNotice(artifact, error);
124
+ const notice = await sendFailureNotice(artifact, error, failure);
118
125
  signal?.throwIfAborted();
119
126
  messageIds = providerIds(notice);
120
127
  failureNoticeVisible = true;
@@ -27,6 +27,7 @@ import {
27
27
  } from './batch-input.mjs';
28
28
  import {
29
29
  hasInboundImages,
30
+ imagePromptDiagnostic,
30
31
  imagePromptUserMessage,
31
32
  promptContentForMessage,
32
33
  } from './image-prompt.mjs';
@@ -45,6 +46,12 @@ import {
45
46
  createTextDeliveryBlock,
46
47
  providerMessageIdsFor,
47
48
  } from './semantic/delivery.mjs';
49
+ import {
50
+ channelDeliveryFailure,
51
+ clearLastMessageFailure,
52
+ messageFailureText,
53
+ setLastMessageFailure,
54
+ } from './message-failure.mjs';
48
55
 
49
56
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
50
57
  const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
@@ -106,6 +113,7 @@ export function createTextBridgeStatus() {
106
113
  lastReplyAt: null,
107
114
  lastRejectedAt: null,
108
115
  lastError: null,
116
+ lastMessageError: null,
109
117
  };
110
118
  }
111
119
 
@@ -320,8 +328,9 @@ export class TextHarnessBridge {
320
328
  })().catch(async (error) => {
321
329
  if (this.#signal?.aborted) return;
322
330
  this.#status.lastError = error?.message ?? String(error);
331
+ const failure = setLastMessageFailure(this.#status, error);
323
332
  this.#logger.error?.(
324
- `[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
333
+ `[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
325
334
  error,
326
335
  );
327
336
  }).finally(() => {
@@ -399,8 +408,12 @@ export class TextHarnessBridge {
399
408
  } catch (error) {
400
409
  if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
401
410
  this.#status.lastError = error?.message ?? String(error);
402
- this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error);
403
- await this.#bot.sendText(target, t('消息处理失败,请稍后重试。')).catch(() => undefined);
411
+ const failure = setLastMessageFailure(this.#status, error);
412
+ this.#logger.error?.(
413
+ `[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
414
+ error,
415
+ );
416
+ await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
404
417
  }
405
418
  }
406
419
 
@@ -426,9 +439,13 @@ export class TextHarnessBridge {
426
439
  sendFile: typeof this.#bot.sendFile === 'function'
427
440
  ? (file) => this.#bot.sendFile(target, file)
428
441
  : undefined,
429
- sendFailureNotice: (artifact, error) => this.#bot.sendText(
442
+ onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
443
+ userMessage: artifactFailureText(artifact?.fileName, error, this.#descriptor),
444
+ reason: error?.code,
445
+ }),
446
+ sendFailureNotice: (_artifact, _error, failure) => this.#bot.sendText(
430
447
  target,
431
- artifactFailureText(artifact?.fileName, error, this.#descriptor),
448
+ messageFailureText(failure),
432
449
  ),
433
450
  logger: this.#logger,
434
451
  });
@@ -436,7 +453,11 @@ export class TextHarnessBridge {
436
453
  + delivery.artifactsSent;
437
454
  this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
438
455
  + delivery.artifactSendErrors;
439
- return { receipt: delivery.receipt, userVisible: delivery.userVisible };
456
+ return {
457
+ receipt: delivery.receipt,
458
+ userVisible: delivery.userVisible,
459
+ artifactSendErrors: delivery.artifactSendErrors,
460
+ };
440
461
  }
441
462
 
442
463
  async #process(message, messageId, senderId, conversationKey, {
@@ -643,25 +664,37 @@ export class TextHarnessBridge {
643
664
  reason: result?.reason,
644
665
  });
645
666
  } catch (error) {
646
- textDeliveryError = error;
667
+ textDeliveryError = channelDeliveryFailure(error);
647
668
  }
648
669
  }
670
+ const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
649
671
  if (textReceipt?.deliveryOutcome === 'failed') {
650
- const reason = textReceipt.reason ?? 'text-delivery-failed';
651
- textDeliveryError = new Error(`Final text delivery failed (${reason})`);
652
- textDeliveryError.code = reason;
672
+ textDeliveryError = channelDeliveryFailure(
673
+ new Error(`Final text delivery failed (${textReceipt.reason ?? 'unknown'})`),
674
+ { uncertain: false },
675
+ );
676
+ } else if (finalDeliveryUnknown) {
677
+ textDeliveryError = channelDeliveryFailure(
678
+ new Error(`Final text delivery outcome is unknown (${textReceipt.reason ?? 'unknown'})`),
679
+ );
653
680
  }
654
681
  // A failed final text must not discard an already registered result file.
655
682
  // Settle the independent attachment path before surfacing the text error.
656
683
  const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
657
- if (textDeliveryError && !delivery.userVisible) {
684
+ if (textDeliveryError && (!delivery.userVisible || finalDeliveryUnknown)) {
658
685
  textDeliveryError.deliveryReceipt = delivery.receipt;
659
686
  throw textDeliveryError;
660
687
  }
688
+ if (textDeliveryError && delivery.artifactSendErrors === 0) {
689
+ setLastMessageFailure(this.#status, textDeliveryError);
690
+ }
661
691
  if (delivery.userVisible) {
662
692
  this.#status.messagesReplied += 1;
663
693
  this.#status.lastReplyAt = new Date().toISOString();
664
694
  this.#status.lastError = null;
695
+ if (!textDeliveryError && delivery.artifactSendErrors === 0) {
696
+ clearLastMessageFailure(this.#status);
697
+ }
665
698
  }
666
699
  return delivery.receipt;
667
700
  } catch (error) {
@@ -688,10 +721,15 @@ export class TextHarnessBridge {
688
721
  }
689
722
  this.#status.lastError = error?.message ?? String(error);
690
723
  const presentStreamFailure = async (text) => {
691
- if (typeof stream?.fail !== 'function') return false;
724
+ const method = typeof stream?.fail === 'function'
725
+ ? 'fail'
726
+ : (typeof stream?.finish === 'function' ? 'finish' : null);
727
+ if (!method) return false;
692
728
  try {
693
- const result = await stream.fail(text);
694
- return Boolean(result) && result.deliveryOutcome !== 'failed';
729
+ const result = await stream[method](text);
730
+ return method === 'fail'
731
+ ? Boolean(result) && result.deliveryOutcome !== 'failed'
732
+ : result !== false && result?.deliveryOutcome !== 'failed';
695
733
  } catch (streamError) {
696
734
  this.#logger.warn?.(
697
735
  `[dsh-im:${this.#descriptor.key}] unable to finalize the failed stream:`,
@@ -700,58 +738,25 @@ export class TextHarnessBridge {
700
738
  return false;
701
739
  }
702
740
  };
703
- if (failedBatch?.retained) {
704
- this.#logger.error?.(
705
- `[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
706
- error,
707
- );
708
- if (await presentStreamFailure(failedBatch.message)) return;
709
- stream?.cancel?.();
710
- try {
711
- await this.#bot.sendText(target, failedBatch.message);
712
- } catch (sendError) {
713
- this.#logger.error?.(
714
- `[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
715
- sendError,
716
- );
717
- }
718
- return;
719
- }
720
741
  const imageErrorMessage = imagePromptUserMessage(error);
721
- if (imageErrorMessage) {
722
- if (await presentStreamFailure(imageErrorMessage)) return;
723
- stream?.cancel?.();
724
- try {
725
- await this.#bot.sendText(target, imageErrorMessage);
726
- } catch (sendError) {
727
- this.#logger.error?.(
728
- `[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
729
- sendError,
730
- );
731
- }
732
- return;
733
- }
734
742
  const fileErrorMessage = inboundFileUserMessage(error);
735
- if (fileErrorMessage) {
736
- if (await presentStreamFailure(fileErrorMessage)) return;
737
- stream?.cancel?.();
738
- try {
739
- await this.#bot.sendText(target, fileErrorMessage);
740
- } catch (sendError) {
741
- this.#logger.error?.(
742
- `[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
743
- sendError,
744
- );
745
- }
746
- return;
747
- }
748
- this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
749
- if (await presentStreamFailure('消息处理失败,请稍后重试。')) {
743
+ const failure = setLastMessageFailure(this.#status, error, {
744
+ userMessage: fileErrorMessage ?? imageErrorMessage,
745
+ reason: imagePromptDiagnostic(error)?.reason,
746
+ });
747
+ const failureText = failedBatch?.retained
748
+ ? `${messageFailureText(failure)}\n\n${failedBatch.message}`
749
+ : messageFailureText(failure);
750
+ this.#logger.error?.(
751
+ `[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
752
+ error,
753
+ );
754
+ if (await presentStreamFailure(failureText)) {
750
755
  return error.deliveryReceipt;
751
756
  }
752
757
  stream?.cancel?.();
753
758
  try {
754
- await this.#bot.sendText(target, t('消息处理失败,请稍后重试。'));
759
+ await this.#bot.sendText(target, failureText);
755
760
  } catch (sendError) {
756
761
  this.#logger.error?.(
757
762
  `[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,