@xmanrui/dsh-im 4.21.2 → 4.23.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 (141) hide show
  1. package/README.en.md +10 -5
  2. package/README.md +10 -5
  3. package/docs/client-integration.md +81 -0
  4. package/lib/client.js +2565 -1134
  5. package/lib/index.js +292 -301
  6. package/package.json +9 -1
  7. package/plugin-src/client/channel-card-meta.js +2 -0
  8. package/plugin-src/client/channel-logos.js +11 -0
  9. package/plugin-src/client/channels/dingtalk/api.js +5 -1
  10. package/plugin-src/client/channels/dingtalk/index.js +15 -13
  11. package/plugin-src/client/channels/email/api.js +52 -0
  12. package/plugin-src/client/channels/email/index.js +728 -0
  13. package/plugin-src/client/channels/email/styles.js +51 -0
  14. package/plugin-src/client/channels/feishu/api.js +4 -2
  15. package/plugin-src/client/channels/feishu/index.js +16 -3
  16. package/plugin-src/client/channels/imessage/index.js +2 -1
  17. package/plugin-src/client/channels/office/api.js +2 -0
  18. package/plugin-src/client/channels/office/index.js +6 -5
  19. package/plugin-src/client/channels/qq/api.js +7 -0
  20. package/plugin-src/client/channels/qq/index.js +16 -3
  21. package/plugin-src/client/channels/shared/token-api.js +5 -1
  22. package/plugin-src/client/channels/shared/token-channel.js +28 -3
  23. package/plugin-src/client/channels/slack/index.js +2 -1
  24. package/plugin-src/client/channels/wecom/api.js +7 -1
  25. package/plugin-src/client/channels/wecom/index.js +16 -3
  26. package/plugin-src/client/channels/wecom-app/api.js +6 -1
  27. package/plugin-src/client/channels/wecom-app/index.js +16 -3
  28. package/plugin-src/client/channels/weixin/api.js +2 -1
  29. package/plugin-src/client/channels/weixin/connection-error.js +1 -71
  30. package/plugin-src/client/channels/whatsapp/api.js +5 -0
  31. package/plugin-src/client/channels/whatsapp/index.js +16 -3
  32. package/plugin-src/client/connection-error.js +87 -0
  33. package/plugin-src/client/global-settings.js +81 -1
  34. package/plugin-src/client/i18n.js +62 -0
  35. package/plugin-src/client/index.js +122 -28
  36. package/plugin-src/client/last-message-error.js +2 -1
  37. package/plugin-src/client/panel-error-boundary.js +21 -0
  38. package/plugin-src/client/styles.js +6 -0
  39. package/plugin-src/host/build.mjs +4 -0
  40. package/plugin-src/host/channels/dingtalk/production.mjs +5 -1
  41. package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
  42. package/plugin-src/host/channels/email/availability.mjs +60 -0
  43. package/plugin-src/host/channels/email/index.mjs +36 -0
  44. package/plugin-src/host/channels/email/production.mjs +58 -0
  45. package/plugin-src/host/channels/email/rpc.mjs +163 -0
  46. package/plugin-src/host/channels/feishu/production.mjs +6 -1
  47. package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
  48. package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
  49. package/plugin-src/host/channels/office/rpc.mjs +3 -1
  50. package/plugin-src/host/channels/qq/production.mjs +5 -1
  51. package/plugin-src/host/channels/qq/rpc.mjs +5 -3
  52. package/plugin-src/host/channels/shared/production.mjs +55 -6
  53. package/plugin-src/host/channels/shared/rpc.mjs +5 -3
  54. package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
  55. package/plugin-src/host/channels/shared/startup.mjs +11 -6
  56. package/plugin-src/host/channels/slack/production.mjs +5 -1
  57. package/plugin-src/host/channels/slack/rpc.mjs +5 -3
  58. package/plugin-src/host/channels/wecom/production.mjs +5 -1
  59. package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
  60. package/plugin-src/host/channels/wecom-app/production.mjs +5 -1
  61. package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
  62. package/plugin-src/host/channels/weixin/production.mjs +5 -1
  63. package/plugin-src/host/channels/whatsapp/production.mjs +5 -1
  64. package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
  65. package/plugin-src/host/delivery-adapter.mjs +11 -0
  66. package/plugin-src/host/image-input-rpc.mjs +24 -0
  67. package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
  68. package/plugin-src/host/index.mjs +3 -0
  69. package/plugin-src/management-rpc.mjs +12 -2
  70. package/scripts/verify-package.mjs +3 -1
  71. package/src/channels/dingtalk/connection-error.mjs +5 -12
  72. package/src/channels/dingtalk/device-auth.mjs +4 -1
  73. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
  74. package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
  75. package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
  76. package/src/channels/discord/discord-api.mjs +3 -3
  77. package/src/channels/discord/discord-runtime.mjs +18 -12
  78. package/src/channels/email/config-store.mjs +194 -0
  79. package/src/channels/email/email-api.mjs +20 -0
  80. package/src/channels/email/email-bridge.mjs +17 -0
  81. package/src/channels/email/email-controller.mjs +824 -0
  82. package/src/channels/email/email-runtime.mjs +692 -0
  83. package/src/channels/email/harness-client.mjs +7 -0
  84. package/src/channels/email/mail-format.mjs +91 -0
  85. package/src/channels/email/state-store.mjs +176 -0
  86. package/src/channels/email/transport.mjs +64 -0
  87. package/src/channels/email/transports/agent-mail.mjs +593 -0
  88. package/src/channels/email/transports/agently-cli.mjs +288 -0
  89. package/src/channels/email/transports/imap-smtp.mjs +165 -0
  90. package/src/channels/feishu/bridge.mjs +8 -7
  91. package/src/channels/feishu/feishu-runtime.mjs +15 -8
  92. package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
  93. package/src/channels/feishu/message-utils.mjs +3 -2
  94. package/src/channels/feishu/multi-bot-controller.mjs +56 -47
  95. package/src/channels/feishu/registration-manager.mjs +8 -4
  96. package/src/channels/feishu/repair-manager.mjs +2 -1
  97. package/src/channels/imessage/runtime.mjs +9 -5
  98. package/src/channels/office/office-controller.mjs +23 -12
  99. package/src/channels/office/office-runtime.mjs +6 -3
  100. package/src/channels/office/office-transport.mjs +1 -0
  101. package/src/channels/qq/qq-bridge.mjs +7 -5
  102. package/src/channels/qq/qq-controller.mjs +56 -42
  103. package/src/channels/qq/qq-runtime.mjs +16 -11
  104. package/src/channels/shared/bot-workspace-store.mjs +37 -21
  105. package/src/channels/shared/connection-error.mjs +191 -0
  106. package/src/channels/shared/connection-test.mjs +3 -1
  107. package/src/channels/shared/conversation-state-store.mjs +46 -1
  108. package/src/channels/shared/default-workspace.mjs +28 -0
  109. package/src/channels/shared/diagnostic-details.mjs +95 -0
  110. package/src/channels/shared/harness-client.mjs +111 -20
  111. package/src/channels/shared/harness-session-binding.mjs +13 -8
  112. package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
  113. package/src/channels/shared/i18n-en/email.mjs +25 -0
  114. package/src/channels/shared/i18n-en/image-input.mjs +17 -0
  115. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  116. package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
  117. package/src/channels/shared/i18n-en.mjs +6 -0
  118. package/src/channels/shared/image-input-policy.mjs +35 -0
  119. package/src/channels/shared/image-input-settings-store.mjs +60 -0
  120. package/src/channels/shared/image-input.mjs +124 -0
  121. package/src/channels/shared/image-prompt.mjs +15 -7
  122. package/src/channels/shared/message-failure.mjs +12 -0
  123. package/src/channels/shared/semantic/reply-reference.mjs +3 -3
  124. package/src/channels/shared/text-harness-bridge.mjs +90 -11
  125. package/src/channels/shared/token-bot-controller.mjs +52 -47
  126. package/src/channels/slack/slack-api.mjs +3 -3
  127. package/src/channels/slack/slack-controller.mjs +59 -51
  128. package/src/channels/slack/slack-runtime.mjs +18 -12
  129. package/src/channels/telegram/telegram-api.mjs +2 -2
  130. package/src/channels/telegram/telegram-runtime.mjs +17 -13
  131. package/src/channels/wecom/wecom-bridge.mjs +15 -6
  132. package/src/channels/wecom/wecom-controller.mjs +51 -37
  133. package/src/channels/wecom/wecom-runtime.mjs +10 -6
  134. package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
  135. package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
  136. package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
  137. package/src/channels/weixin/connection-error.mjs +26 -92
  138. package/src/channels/weixin/diagnostic-details.mjs +1 -63
  139. package/src/channels/weixin/weixin-bridge.mjs +7 -5
  140. package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
  141. package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
@@ -0,0 +1,124 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+
4
+ import {
5
+ detectedImageMediaType, ImagePromptError, imageStorageName, loadedImage, imageDownloadLimitMessage,
6
+ } from './image-prompt.mjs';
7
+ import { t } from './i18n.mjs';
8
+
9
+ const MAX_INPUT_PIXELS = 64_000_000;
10
+ const COMPRESSION_STEPS = [[2048, 80], [1536, 70], [1024, 60]];
11
+ let sharpPromise;
12
+ async function loadSharp() {
13
+ sharpPromise ??= import('sharp').then((module) => module.default).catch(() => null);
14
+ return sharpPromise;
15
+ }
16
+
17
+ export const IMAGE_ORIGINALS_PROMPT = '图片原文件已保存到工作区,文件清单中的图片序号与本条消息一致。图片内容块可能经过缩放或压缩;需要查看细节时可用工具读取原文件。';
18
+ export const IMAGE_INPUT_FALLBACK_PROMPT = '部分图片未直接提供给模型,图片序号:{indexes}。原文件已保存,请使用可用工具读取或分析;不要假设自己已看到这些图片。';
19
+ export const IMAGE_HOST_LIMIT_FALLBACK_PROMPT = '宿主拒绝了本次图片输入。原图已保存到工作区,请根据文件清单使用可用工具读取或分析,不要假设自己已看到图片。';
20
+
21
+ /** Keep provider loaders intact; stage and validate each original once. */
22
+ export function inboundImagesAsFiles(images, limits) {
23
+ if (images.length > limits.maxImages) {
24
+ throw new ImagePromptError('too-many-images', 'Too many inbound images',
25
+ t('一次最多只能处理 {maxImages} 张图片。', { maxImages: limits.maxImages }));
26
+ }
27
+ const tooLarge = () => new ImagePromptError('image-too-large',
28
+ `Image exceeds ${limits.maxDownloadBytes} bytes`, imageDownloadLimitMessage(limits.maxDownloadBytes));
29
+ // Check declared sizes before any download starts, including later images.
30
+ for (const source of images) {
31
+ if (Number.isFinite(source?.size) && source.size > limits.maxDownloadBytes) throw tooLarge();
32
+ }
33
+ return images.map((source, index) => ({
34
+ async load({ signal } = {}) {
35
+ signal?.throwIfAborted();
36
+ let value;
37
+ try {
38
+ value = source.data === undefined
39
+ ? await source.load?.({ signal, maxBytes: limits.maxDownloadBytes }) : source.data;
40
+ } catch (error) {
41
+ if (signal?.aborted) throw signal.reason ?? error;
42
+ if (error?.code === 'image-too-large') throw tooLarge();
43
+ if (error instanceof ImagePromptError || ['AbortError', 'TimeoutError'].includes(error?.name)) throw error;
44
+ throw new ImagePromptError('image-download-failed', 'Unable to download image',
45
+ t('图片下载失败,请重新发送后再试。'), { cause: error });
46
+ }
47
+ signal?.throwIfAborted();
48
+ const loaded = loadedImage(value);
49
+ if (!loaded?.data.length) throw new ImagePromptError('invalid-image-data', 'Empty image',
50
+ t('未能读取图片内容,请重新发送。'));
51
+ if (loaded.data.length > limits.maxDownloadBytes) throw tooLarge();
52
+ const mediaType = detectedImageMediaType(loaded.data);
53
+ if (!mediaType) throw new ImagePromptError('unsupported-image-type', 'Unsupported image',
54
+ t('暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。'));
55
+ return { data: loaded.data, mediaType,
56
+ name: `${index + 1}-${imageStorageName(loaded.name ?? source.name, mediaType, index)}` };
57
+ },
58
+ }));
59
+ }
60
+
61
+ /** A missing optional codec still permits small native images and file delivery. */
62
+ export async function modelImageFromOriginal(data, mediaType, maxBytes, {
63
+ signal, sharpLoader = loadSharp,
64
+ } = {}) {
65
+ signal?.throwIfAborted();
66
+ if (maxBytes <= 0) return null;
67
+ const sharp = await sharpLoader();
68
+ signal?.throwIfAborted();
69
+ if (!sharp) return data.length <= maxBytes ? { data, mediaType } : null;
70
+ try {
71
+ const metadata = await sharp(data, { limitInputPixels: MAX_INPUT_PIXELS }).metadata();
72
+ signal?.throwIfAborted();
73
+ if (metadata.width * metadata.height > MAX_INPUT_PIXELS) return null;
74
+ if ((metadata.pages ?? 1) > 1 || mediaType === 'image/gif') {
75
+ return data.length <= maxBytes ? { data, mediaType } : null;
76
+ }
77
+ if (data.length <= maxBytes && metadata.width <= 2048 && metadata.height <= 2048) {
78
+ return { data, mediaType };
79
+ }
80
+ for (const [side, quality] of COMPRESSION_STEPS) {
81
+ signal?.throwIfAborted();
82
+ const operation = sharp(data, { limitInputPixels: MAX_INPUT_PIXELS }).rotate()
83
+ .resize({ width: side, height: side, fit: 'inside', withoutEnlargement: true });
84
+ const output = metadata.hasAlpha
85
+ ? await operation.png({ compressionLevel: 9 }).toBuffer()
86
+ : await operation.jpeg({ quality }).toBuffer();
87
+ signal?.throwIfAborted();
88
+ if (output.length <= maxBytes) return { data: output,
89
+ mediaType: metadata.hasAlpha ? 'image/png' : 'image/jpeg' };
90
+ }
91
+ } catch (error) {
92
+ signal?.throwIfAborted();
93
+ // A codec failure cannot discard an original already saved for tools.
94
+ }
95
+ return null;
96
+ }
97
+
98
+ export async function imageContentFromStaged(staged, limits, { signal, sharpLoader } = {}) {
99
+ const content = [];
100
+ const fileOnly = [];
101
+ let remaining = limits.maxTotalImageBytes;
102
+ for (const [index, file] of staged.files.entries()) {
103
+ signal?.throwIfAborted();
104
+ // Staging owns both the directory and basename; never read a user-supplied path.
105
+ const data = await readFile(join(staged.directory, basename(file.path)), { signal });
106
+ const result = await modelImageFromOriginal(data, file.mediaType,
107
+ Math.min(limits.maxImageBytes, remaining), { signal, sharpLoader });
108
+ if (!result) { fileOnly.push(index + 1); continue; }
109
+ remaining -= result.data.length;
110
+ content.push({ type: 'text', text: t('图片 {index}(原图文件:{name})', { index: index + 1, name: file.name }) },
111
+ { type: 'image', mediaType: result.mediaType, data: result.data.toString('base64'),
112
+ name: imageStorageName(file.name.replace(/\.[^.]+$/, ''), result.mediaType, index) });
113
+ }
114
+ content.push({ type: 'text', text: t(IMAGE_ORIGINALS_PROMPT) });
115
+ if (fileOnly.length) content.push({ type: 'text',
116
+ text: t(IMAGE_INPUT_FALLBACK_PROMPT, { indexes: fileOnly.join(', ') }) });
117
+ return content;
118
+ }
119
+
120
+ export function isImageAdmissionRejection(error) {
121
+ return ['attachment-error', 'session/attachment-invalid'].includes(error?.code)
122
+ && ['MODEL_DOES_NOT_SUPPORT_IMAGES', 'IMAGE_TOO_LARGE', 'IMAGES_TOO_LARGE',
123
+ 'IMAGE_TOO_MANY_PIXELS', 'TOO_MANY_IMAGES'].includes(error?.details?.reason);
124
+ }
@@ -12,6 +12,11 @@ export const DEFAULT_IMAGE_PROMPT = '请分析这张图片。';
12
12
  */
13
13
  export const IMAGE_FILE_FALLBACK_PROMPT = '当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方文件清单)。请使用可用工具分析这些图片文件后回答,例如 run_code 或 pwsh 读取字节、解析元数据、调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。';
14
14
 
15
+ export function imageDownloadLimitMessage(maxBytes) {
16
+ return t('图片超过原图接收上限 {maxMb} MB,请压缩后重试,或在通用设置的附件中调整上限。',
17
+ { maxMb: Math.round(maxBytes / (1024 * 1024) * 100) / 100 });
18
+ }
19
+
15
20
  export class ImagePromptError extends Error {
16
21
  constructor(code, message, userMessage, options = {}) {
17
22
  super(message, options);
@@ -92,7 +97,7 @@ export async function fetchImageBuffer(url, {
92
97
  throw new ImagePromptError(
93
98
  'image-too-large',
94
99
  `Image response declares ${declaredLength} bytes; the limit is ${maxBytes}`,
95
- t('图片超过 5 MB,请压缩后重试。'),
100
+ imageDownloadLimitMessage(maxBytes),
96
101
  );
97
102
  }
98
103
 
@@ -107,7 +112,7 @@ export async function fetchImageBuffer(url, {
107
112
  throw new ImagePromptError(
108
113
  'image-too-large',
109
114
  `Image response exceeded ${maxBytes} bytes`,
110
- t('图片超过 5 MB,请压缩后重试。'),
115
+ imageDownloadLimitMessage(maxBytes),
111
116
  );
112
117
  }
113
118
  chunks.push(data);
@@ -120,7 +125,7 @@ export async function fetchImageBuffer(url, {
120
125
  throw new ImagePromptError(
121
126
  'image-too-large',
122
127
  `Image response contains ${data.length} bytes; the limit is ${maxBytes}`,
123
- t('图片超过 5 MB,请压缩后重试。'),
128
+ imageDownloadLimitMessage(maxBytes),
124
129
  );
125
130
  }
126
131
  return data;
@@ -146,7 +151,7 @@ function safeName(value) {
146
151
  return name || undefined;
147
152
  }
148
153
 
149
- function detectedImageMediaType(data) {
154
+ export function detectedImageMediaType(data) {
150
155
  if (data.length >= 8
151
156
  && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
152
157
  && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) {
@@ -167,7 +172,7 @@ function detectedImageMediaType(data) {
167
172
  return null;
168
173
  }
169
174
 
170
- function loadedImage(value) {
175
+ export function loadedImage(value) {
171
176
  if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
172
177
  return { data: Buffer.from(value) };
173
178
  }
@@ -191,12 +196,13 @@ export function hasInboundPrompt(message) {
191
196
 
192
197
  export async function promptContentForMessage(message, {
193
198
  signal,
199
+ deferImages = false,
194
200
  maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
195
201
  maxImages = DEFAULT_MAX_IMAGES,
196
202
  maxTotalImageBytes = DEFAULT_MAX_TOTAL_IMAGE_BYTES,
197
203
  } = {}) {
198
204
  const sources = imageSources(message);
199
- if (sources.length > maxImages) {
205
+ if (!deferImages && sources.length > maxImages) {
200
206
  throw new ImagePromptError(
201
207
  'too-many-images',
202
208
  `Image message contains ${sources.length} images; the limit is ${maxImages}`,
@@ -210,6 +216,8 @@ export async function promptContentForMessage(message, {
210
216
  if (text) content.push({ type: 'text', text });
211
217
  else if (sources.length > 0) content.push({ type: 'text', text: t(DEFAULT_IMAGE_PROMPT) });
212
218
 
219
+ if (deferImages) return content;
220
+
213
221
  for (const [index, source] of sources.entries()) {
214
222
  signal?.throwIfAborted();
215
223
  if (Number.isFinite(source?.size) && source.size > maxImageBytes) {
@@ -315,7 +323,7 @@ const IMAGE_FILE_EXTENSIONS = new Map([
315
323
 
316
324
  const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
317
325
 
318
- function imageStorageName(name, mediaType, index) {
326
+ export function imageStorageName(name, mediaType, index) {
319
327
  const extension = IMAGE_FILE_EXTENSIONS.get(mediaType) ?? '.img';
320
328
  const cleaned = safeName(name);
321
329
  if (cleaned && IMAGE_EXTENSION_PATTERN.test(cleaned)) return cleaned;
@@ -1,3 +1,5 @@
1
+ import { extractConnectionEvidence } from './connection-error.mjs';
2
+ import { diagnosticFields } from './diagnostic-details.mjs';
1
3
  import { randomUUID } from 'node:crypto';
2
4
 
3
5
  import { t } from './i18n.mjs';
@@ -189,7 +191,9 @@ export function classifyMessageFailure(error, {
189
191
  && userMessage.trim()
190
192
  ? 'INPUT_INVALID'
191
193
  : classifiedCode;
194
+ const details = extractConnectionEvidence(error).details;
192
195
  return Object.freeze({
196
+ ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode ? { details } : {}),
193
197
  code,
194
198
  reason: safeReason ?? safeFailureReason(error?.code) ?? code,
195
199
  message: typeof userMessage === 'string' && userMessage.trim()
@@ -204,9 +208,16 @@ export function messageFailureText(failure) {
204
208
  return `${failure.message}\n\n${t('错误码:{code};参考号:{referenceId}', failure)}`;
205
209
  }
206
210
 
211
+ export function messageFailureDiagnostic(error, failure) {
212
+ const evidence = extractConnectionEvidence(error);
213
+ return { code: failure.code, reason: failure.reason, referenceId: failure.referenceId,
214
+ details: evidence.details, errors: evidence.errors };
215
+ }
216
+
207
217
  export function setLastMessageFailure(status, error, options) {
208
218
  const failure = classifyMessageFailure(error, options);
209
219
  status.lastMessageError = failure;
220
+ status.lastError = failure.message;
210
221
  return failure;
211
222
  }
212
223
 
@@ -241,5 +252,6 @@ export function publicMessageFailure(value) {
241
252
  message: value.message.slice(0, 500),
242
253
  referenceId: value.referenceId.slice(0, 40),
243
254
  at: value.at,
255
+ ...(value.details ? diagnosticFields(value) : {}),
244
256
  };
245
257
  }
@@ -138,11 +138,11 @@ export function hasReplyReference(message) {
138
138
  return objectReference(message?.replyTo);
139
139
  }
140
140
 
141
- export async function promptContentForInboundMessage(message, { signal } = {}) {
141
+ export async function promptContentForInboundMessage(message, { signal, deferImages = false } = {}) {
142
142
  if (!hasReplyReference(message)) {
143
- return promptContentForMessage(message, { signal });
143
+ return promptContentForMessage(message, { signal, deferImages });
144
144
  }
145
145
  const reference = normalizeReference(await resolveReference(message.replyTo, signal));
146
- const currentContent = await promptContentForMessage(message, { signal });
146
+ const currentContent = await promptContentForMessage(message, { signal, deferImages });
147
147
  return [{ type: 'text', text: replyBlock(reference) }, ...currentContent];
148
148
  }
@@ -64,6 +64,7 @@ import {
64
64
  providerMessageIdsFor,
65
65
  } from './semantic/delivery.mjs';
66
66
  import {
67
+ messageFailureDiagnostic,
67
68
  channelDeliveryFailure,
68
69
  clearLastMessageFailure,
69
70
  messageFailureText,
@@ -74,6 +75,15 @@ import { beginStatusReaction } from './status-reaction.mjs';
74
75
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
75
76
  const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
76
77
 
78
+ /**
79
+ * How many times one inbound message may be re-delivered after a failed turn.
80
+ *
81
+ * A failure releases the message's `markSeen` so the next poll retries it, which
82
+ * would otherwise loop forever on a mail that always fails. After this many
83
+ * attempts the mark stays and the message is treated as handled.
84
+ */
85
+ const MAX_DELIVERY_ATTEMPTS = 3;
86
+
77
87
  function cleanText(value) {
78
88
  return typeof value === 'string' ? value.trim() : '';
79
89
  }
@@ -83,7 +93,7 @@ function canClaimInteractionReply(message, pending, senderId) {
83
93
  && (message.kind !== 'group' || message.addressed === true)
84
94
  && !hasInboundImages(message)
85
95
  && !hasInboundFiles(message)
86
- && Boolean(cleanText(message.content));
96
+ && Boolean(controlTextOf(message));
87
97
  }
88
98
 
89
99
  function artifactFailureText(fileName, error, descriptor) {
@@ -138,6 +148,18 @@ export function createTextBridgeStatus() {
138
148
  };
139
149
  }
140
150
 
151
+ /**
152
+ * The text a channel's control commands and approval decisions are parsed from.
153
+ *
154
+ * A channel may decorate `content` for the model — the email channel prepends
155
+ * the mail headers so a subject line reaches the model — while `controlText`
156
+ * keeps the undecorated body. Without this a decorated message made `/help`
157
+ * and "批准" unrecognisable.
158
+ */
159
+ function controlTextOf(message) {
160
+ return cleanText(message?.controlText ?? message?.content);
161
+ }
162
+
141
163
  export class TextHarnessBridge {
142
164
  #descriptor;
143
165
  #bot;
@@ -161,6 +183,10 @@ export class TextHarnessBridge {
161
183
  #approvals;
162
184
  #batches = new BatchInputManager();
163
185
  #interactionCard;
186
+ // Delivery attempts per message id. A failed turn releases its `markSeen` so
187
+ // the next poll can retry, and this counter is what keeps that from becoming
188
+ // an infinite loop on a mail that always fails.
189
+ #deliveryAttempts = new Map();
164
190
 
165
191
  constructor({
166
192
  descriptor,
@@ -229,7 +255,11 @@ export class TextHarnessBridge {
229
255
  const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
230
256
  conversationType: kind,
231
257
  senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
232
- text: normalized.content,
258
+ // The same control text the command runner below parses. A channel may
259
+ // decorate `content` for the model (email prepends the mail headers), and
260
+ // a decorated body never looks like a command — so reading `content` here
261
+ // recognized no command at all and skipped the command-permission gate.
262
+ text: controlTextOf(normalized),
233
263
  hasImages,
234
264
  hasFiles,
235
265
  });
@@ -282,7 +312,7 @@ export class TextHarnessBridge {
282
312
 
283
313
  const key = `${normalized.kind}:${normalized.conversationId}`;
284
314
  const pending = this.#pendingInteractions.get(key);
285
- const text = cleanText(normalized.content);
315
+ const text = controlTextOf(normalized);
286
316
  const batchCommand = isBatchInputCommand(text);
287
317
  if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
288
318
  return this.#finishLocalMessage(
@@ -362,7 +392,12 @@ export class TextHarnessBridge {
362
392
  key,
363
393
  actor: senderId,
364
394
  messageId,
365
- text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
395
+ // Approval decisions are parsed from the control text, exactly like the
396
+ // commands above: a decorated `content` hid "批准" behind the mail headers,
397
+ // so a plain-body approval never claimed its pending request.
398
+ text: hasInboundImages(normalized) || hasInboundFiles(normalized)
399
+ ? ''
400
+ : controlTextOf(normalized),
366
401
  addressed: normalized.kind !== 'group' || normalized.addressed === true,
367
402
  hasPendingQuestion: Boolean(pending),
368
403
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -441,7 +476,7 @@ export class TextHarnessBridge {
441
476
  const failure = setLastMessageFailure(this.#status, error);
442
477
  this.#logger.error?.(
443
478
  `[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
444
- error,
479
+ messageFailureDiagnostic(error, failure),
445
480
  );
446
481
  }).finally(() => {
447
482
  this.#acceptedMessageIds.delete(messageId);
@@ -500,7 +535,7 @@ export class TextHarnessBridge {
500
535
  const target = message.replyTarget;
501
536
  try {
502
537
  const result = await runner(
503
- cleanText(message.content),
538
+ controlTextOf(message),
504
539
  this.#harness,
505
540
  this.#state,
506
541
  key,
@@ -550,7 +585,7 @@ export class TextHarnessBridge {
550
585
  const failure = setLastMessageFailure(this.#status, error);
551
586
  this.#logger.error?.(
552
587
  `[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
553
- error,
588
+ messageFailureDiagnostic(error, failure),
554
589
  );
555
590
  await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
556
591
  }
@@ -615,7 +650,7 @@ export class TextHarnessBridge {
615
650
  }
616
651
 
617
652
  const target = message.replyTarget;
618
- const text = cleanText(message.content);
653
+ const text = controlTextOf(message);
619
654
  const batchSubmission = message.batchSubmission;
620
655
  let stream = null;
621
656
  let semanticStream = false;
@@ -710,7 +745,7 @@ export class TextHarnessBridge {
710
745
  }
711
746
  }
712
747
  let content = hasImages || hasReply
713
- ? await promptContentForInboundMessage(message, { signal: this.#signal })
748
+ ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
714
749
  : undefined;
715
750
  const snapshot = this.#acceptedMessageIds.get(messageId);
716
751
  let contextEnhanced = false;
@@ -790,6 +825,7 @@ export class TextHarnessBridge {
790
825
  },
791
826
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
792
827
  files: message.files,
828
+ images: message.images,
793
829
  },
794
830
  });
795
831
  stopKeepalive();
@@ -877,6 +913,8 @@ export class TextHarnessBridge {
877
913
  clearLastMessageFailure(this.#status);
878
914
  }
879
915
  }
916
+ // The turn produced an answer, so its retry budget is spent and reset.
917
+ this.#deliveryAttempts.delete(messageId);
880
918
  return delivery.receipt;
881
919
  } catch (error) {
882
920
  stopKeepalive();
@@ -934,7 +972,7 @@ export class TextHarnessBridge {
934
972
  : messageFailureText(failure);
935
973
  this.#logger.error?.(
936
974
  `[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
937
- error,
975
+ messageFailureDiagnostic(error, failure),
938
976
  );
939
977
  if (await presentStreamFailure(failureText)) {
940
978
  return error.deliveryReceipt;
@@ -948,6 +986,10 @@ export class TextHarnessBridge {
948
986
  sendError,
949
987
  );
950
988
  }
989
+ // The turn produced no answer, so this id is "attempted", not "handled".
990
+ // Releasing the mark lets the next poll retry it; `alreadyRecorded` means
991
+ // an outer caller owns the mark, so it is left alone there.
992
+ if (!alreadyRecorded) await this.#releaseFailedDelivery(messageId);
951
993
  return error.deliveryReceipt;
952
994
  } finally {
953
995
  stopKeepalive();
@@ -958,6 +1000,43 @@ export class TextHarnessBridge {
958
1000
  }
959
1001
  }
960
1002
 
1003
+ /**
1004
+ * Release the `markSeen` of a turn that failed without delivering anything,
1005
+ * so the next poll can pick the message up again.
1006
+ *
1007
+ * Bounded: a message is retried at most `MAX_DELIVERY_ATTEMPTS` times, after
1008
+ * which the mark stays and the id becomes a tombstone. That keeps a mail that
1009
+ * always fails from being re-executed forever, while a transient Harness
1010
+ * outage no longer drops it permanently.
1011
+ *
1012
+ * Returns true when the message was released and will be retried.
1013
+ */
1014
+ async #releaseFailedDelivery(messageId) {
1015
+ const attempts = (this.#deliveryAttempts.get(messageId) ?? 0) + 1;
1016
+ if (attempts >= MAX_DELIVERY_ATTEMPTS) {
1017
+ // Give up: keep the mark so the id is never replayed, and stop tracking
1018
+ // it. The sender has already been told the turn failed.
1019
+ this.#deliveryAttempts.delete(messageId);
1020
+ this.#logger.warn?.(
1021
+ `[dsh-im:${this.#descriptor.key}] giving up on a message after `
1022
+ + `${attempts} failed delivery attempts; it will not be retried.`,
1023
+ );
1024
+ return false;
1025
+ }
1026
+ this.#deliveryAttempts.set(messageId, attempts);
1027
+ if (typeof this.#state?.unmarkSeen !== 'function') return false;
1028
+ try {
1029
+ await this.#state.unmarkSeen(messageId);
1030
+ return true;
1031
+ } catch (error) {
1032
+ this.#logger.warn?.(
1033
+ `[dsh-im:${this.#descriptor.key}] unable to release a failed message for retry:`,
1034
+ error,
1035
+ );
1036
+ return false;
1037
+ }
1038
+ }
1039
+
961
1040
  /**
962
1041
  * Advance the pending interaction with one answer.
963
1042
  *
@@ -1000,7 +1079,7 @@ export class TextHarnessBridge {
1000
1079
  }
1001
1080
 
1002
1081
  const target = message.replyTarget;
1003
- const text = cleanText(message.content);
1082
+ const text = controlTextOf(message);
1004
1083
  if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
1005
1084
  try {
1006
1085
  await this.#bot.sendText(target, t('请用文字回答当前问题。'));