@xmanrui/dsh-im 1.0.2 → 1.2.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 (52) hide show
  1. package/README.en.md +23 -3
  2. package/README.md +23 -3
  3. package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
  4. package/assets/logo_cn.png +0 -0
  5. package/lib/client.js +815 -560
  6. package/lib/index.js +163 -163
  7. package/package.json +1 -1
  8. package/plugin-src/client/agent-preset.js +15 -6
  9. package/plugin-src/client/channel-card-meta.js +48 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -19
  11. package/plugin-src/client/channels/dingtalk/styles.js +0 -6
  12. package/plugin-src/client/channels/feishu/index.js +41 -35
  13. package/plugin-src/client/channels/feishu/styles.js +0 -5
  14. package/plugin-src/client/channels/qq/index.js +24 -16
  15. package/plugin-src/client/channels/shared/token-channel.js +32 -24
  16. package/plugin-src/client/channels/wecom/index.js +24 -16
  17. package/plugin-src/client/channels/weixin/index.js +29 -23
  18. package/plugin-src/client/channels/weixin/styles.js +0 -5
  19. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  20. package/plugin-src/client/channels/whatsapp/index.js +152 -23
  21. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  22. package/plugin-src/client/i18n.js +20 -0
  23. package/plugin-src/client/styles.js +23 -8
  24. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  25. package/plugin-src/host/index.mjs +14 -1
  26. package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
  28. package/src/channels/discord/discord-api.mjs +134 -6
  29. package/src/channels/discord/discord-runtime.mjs +15 -4
  30. package/src/channels/feishu/bridge.mjs +223 -15
  31. package/src/channels/feishu/feishu-channel.mjs +227 -1
  32. package/src/channels/feishu/plugin-controller.mjs +1 -0
  33. package/src/channels/qq/qq-bridge.mjs +217 -10
  34. package/src/channels/shared/editable-message-stream.mjs +18 -1
  35. package/src/channels/shared/harness-client.mjs +99 -7
  36. package/src/channels/shared/semantic/artifact.mjs +748 -0
  37. package/src/channels/shared/semantic/delivery.mjs +153 -0
  38. package/src/channels/shared/text-harness-bridge.mjs +149 -3
  39. package/src/channels/shared/workspace-session.mjs +15 -1
  40. package/src/channels/slack/manifest.mjs +1 -0
  41. package/src/channels/slack/slack-api.mjs +167 -4
  42. package/src/channels/slack/slack-runtime.mjs +21 -5
  43. package/src/channels/telegram/telegram-api.mjs +111 -5
  44. package/src/channels/telegram/telegram-runtime.mjs +18 -4
  45. package/src/channels/wecom/wecom-bridge.mjs +260 -12
  46. package/src/channels/weixin/weixin-api.mjs +268 -2
  47. package/src/channels/weixin/weixin-bridge.mjs +134 -3
  48. package/src/channels/weixin/weixin-controller.mjs +5 -1
  49. package/src/channels/weixin/weixin-runtime.mjs +5 -1
  50. package/src/channels/whatsapp/config-store.mjs +43 -0
  51. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  52. package/src/channels/whatsapp/whatsapp-runtime.mjs +149 -5
@@ -120,6 +120,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
120
120
  let inFlight = null;
121
121
  let broken = false;
122
122
  let closed = false;
123
+ const providerMessageIds = [ts];
123
124
 
124
125
  const appendLatest = async (text) => {
125
126
  const next = splitMessageText(text, SLACK_MESSAGE_LIMIT)[0] ?? '';
@@ -150,6 +151,10 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
150
151
  };
151
152
 
152
153
  return {
154
+ messageId: ts,
155
+ get providerMessageIds() {
156
+ return [...providerMessageIds];
157
+ },
153
158
  update(text) {
154
159
  if (closed || broken || typeof text !== 'string' || !text.trim() || isToolProgress(text)) return;
155
160
  pending = text;
@@ -173,12 +178,13 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
173
178
  await api.updateMessage({ channelId: target.channelId, ts, text: first, signal });
174
179
  }
175
180
  for (const chunk of chunks.slice(1)) {
176
- await api.postMessage({
181
+ const result = await api.postMessage({
177
182
  channelId: target.channelId,
178
183
  threadTs: target.threadTs,
179
184
  text: chunk,
180
185
  signal,
181
186
  });
187
+ if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
182
188
  }
183
189
  },
184
190
  cancel() {
@@ -191,7 +197,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
191
197
  };
192
198
  }
193
199
 
194
- class SlackBotClient {
200
+ export class SlackBotClient {
195
201
  #api;
196
202
  #signal;
197
203
  #logger;
@@ -204,16 +210,17 @@ class SlackBotClient {
204
210
 
205
211
  async sendText(target, text) {
206
212
  const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
207
- let result = null;
213
+ const providerMessageIds = [];
208
214
  for (const chunk of chunks) {
209
- result = await this.#api.postMessage({
215
+ const result = await this.#api.postMessage({
210
216
  channelId: target.channelId,
211
217
  threadTs: target.threadTs,
212
218
  text: chunk,
213
219
  signal: this.#signal,
214
220
  });
221
+ if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
215
222
  }
216
- return result;
223
+ return { providerMessageIds };
217
224
  }
218
225
 
219
226
  openStream(target) {
@@ -224,6 +231,15 @@ class SlackBotClient {
224
231
  logger: this.#logger,
225
232
  });
226
233
  }
234
+
235
+ sendFile(target, file) {
236
+ return this.#api.uploadFile({
237
+ channelId: target.channelId,
238
+ threadTs: target.threadTs,
239
+ file,
240
+ signal: this.#signal,
241
+ });
242
+ }
227
243
  }
228
244
 
229
245
  export function createSlackRuntimeStatus() {
@@ -2,6 +2,7 @@ import { fetchImageBuffer } from '../shared/image-prompt.mjs';
2
2
 
3
3
  const DEFAULT_BASE_URL = 'https://api.telegram.org/';
4
4
  const TELEGRAM_FILE_HOSTS = Object.freeze(['api.telegram.org']);
5
+ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
5
6
 
6
7
  function cleanString(value) {
7
8
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -12,6 +13,58 @@ function requestSignal(signal, timeoutMs) {
12
13
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
13
14
  }
14
15
 
16
+ function abortReason(signal) {
17
+ return signal?.reason instanceof Error
18
+ ? signal.reason
19
+ : new DOMException('The operation was aborted', 'AbortError');
20
+ }
21
+
22
+ function positiveTimeout(value, name) {
23
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
24
+ return value;
25
+ }
26
+
27
+ function preserveProviderMetadata(target, source) {
28
+ if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
29
+ if (source?.retry_after !== undefined) {
30
+ target.retry_after = source.retry_after;
31
+ target.retryAfter = source.retry_after;
32
+ }
33
+ if (Number.isInteger(source?.status)) target.status = source.status;
34
+ return target;
35
+ }
36
+
37
+ function telegramArtifactProviderError(cause) {
38
+ const providerCode = Number(cause?.providerCode);
39
+ const status = Number(cause?.status);
40
+ const message = cleanString(cause?.message) ?? '';
41
+ let code = 'artifact-provider-rejected';
42
+ let summary = 'Telegram rejected the document.';
43
+ if (providerCode === 401 || providerCode === 403 || status === 401 || status === 403) {
44
+ code = 'artifact-permission-required';
45
+ summary = 'Telegram denied permission to send the document.';
46
+ } else if (providerCode === 413 || status === 413
47
+ || /(?:file|request|entity).{0,20}(?:too (?:big|large)|size limit)|too (?:big|large)/i.test(message)) {
48
+ code = 'artifact-too-large';
49
+ summary = 'The document exceeds Telegram\'s size limit.';
50
+ } else if (providerCode === 429 || status === 429) {
51
+ code = 'artifact-rate-limited';
52
+ summary = 'Telegram rate-limited document delivery.';
53
+ } else if (providerCode >= 500 || status >= 500) {
54
+ code = 'artifact-delivery-uncertain';
55
+ summary = 'Telegram document delivery result is uncertain.';
56
+ }
57
+ const error = new Error(summary, { cause });
58
+ error.code = code;
59
+ return preserveProviderMetadata(error, cause);
60
+ }
61
+
62
+ function uncertainTelegramDelivery(cause) {
63
+ const error = new Error('Telegram document delivery result is uncertain', { cause });
64
+ error.code = 'artifact-delivery-uncertain';
65
+ return preserveProviderMetadata(error, cause);
66
+ }
67
+
15
68
  export function validTelegramToken(value) {
16
69
  return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim());
17
70
  }
@@ -32,13 +85,20 @@ export class TelegramApi {
32
85
  #token;
33
86
  #fetch;
34
87
  #baseUrl;
88
+ #fileUploadTimeoutMs;
35
89
 
36
- constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
90
+ constructor({
91
+ token,
92
+ fetchImpl = fetch,
93
+ baseUrl = DEFAULT_BASE_URL,
94
+ fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
95
+ }) {
37
96
  if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
38
97
  if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
39
98
  this.#token = token.trim();
40
99
  this.#fetch = fetchImpl;
41
100
  this.#baseUrl = new URL(baseUrl);
101
+ this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
42
102
  }
43
103
 
44
104
  async getMe(options = {}) {
@@ -108,6 +168,43 @@ export class TelegramApi {
108
168
  }, { signal });
109
169
  }
110
170
 
171
+ async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
172
+ if (!file || typeof file !== 'object'
173
+ || typeof file.fileName !== 'string' || !file.fileName
174
+ || !Buffer.isBuffer(file.bytes)) {
175
+ throw new TypeError('A Telegram document is required');
176
+ }
177
+ const payload = new FormData();
178
+ payload.append('chat_id', String(chatId));
179
+ payload.append(
180
+ 'document',
181
+ new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
182
+ file.fileName,
183
+ );
184
+ if (replyToMessageId) {
185
+ payload.append('reply_parameters', JSON.stringify({
186
+ message_id: replyToMessageId,
187
+ allow_sending_without_reply: true,
188
+ }));
189
+ }
190
+ if (messageThreadId) payload.append('message_thread_id', String(messageThreadId));
191
+ if (signal?.aborted) throw abortReason(signal);
192
+ const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
193
+ try {
194
+ return await this.#call('sendDocument', payload, {
195
+ signal: uploadSignal,
196
+ timeoutMs: this.#fileUploadTimeoutMs,
197
+ multipart: true,
198
+ });
199
+ } catch (error) {
200
+ if (signal?.aborted) throw abortReason(signal);
201
+ if (error?.code?.startsWith?.('telegram-')) {
202
+ throw telegramArtifactProviderError(error);
203
+ }
204
+ throw uncertainTelegramDelivery(error);
205
+ }
206
+ }
207
+
111
208
  async editMessageText({ chatId, messageId, text, signal }) {
112
209
  return this.#call('editMessageText', {
113
210
  chat_id: chatId,
@@ -144,15 +241,15 @@ export class TelegramApi {
144
241
  return this.#call('setChatMenuButton', { menu_button: menuButton }, { signal });
145
242
  }
146
243
 
147
- async #call(method, payload, { signal, timeoutMs = 15_000 } = {}) {
244
+ async #call(method, payload, { signal, timeoutMs = 15_000, multipart = false } = {}) {
148
245
  const url = new URL(this.#baseUrl);
149
246
  url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`;
150
247
  let response;
151
248
  try {
152
249
  response = await this.#fetch(url, {
153
250
  method: 'POST',
154
- headers: { 'content-type': 'application/json' },
155
- body: JSON.stringify(payload),
251
+ ...(multipart ? {} : { headers: { 'content-type': 'application/json' } }),
252
+ body: multipart ? payload : JSON.stringify(payload),
156
253
  signal: requestSignal(signal, timeoutMs),
157
254
  redirect: 'error',
158
255
  });
@@ -164,12 +261,21 @@ export class TelegramApi {
164
261
  try {
165
262
  body = await response.json();
166
263
  } catch {
167
- throw new Error(`Telegram ${method} returned invalid JSON`);
264
+ const error = new Error(`Telegram ${method} returned invalid JSON`);
265
+ error.status = response?.status;
266
+ throw error;
168
267
  }
169
268
  if (!response.ok || body?.ok !== true) {
170
269
  const description = cleanString(body?.description);
171
270
  const error = new Error(description ?? `Telegram ${method} failed`);
172
271
  error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : 'telegram-api-error';
272
+ error.status = response.status;
273
+ if (Number.isInteger(body?.error_code)) error.providerCode = body.error_code;
274
+ const retryAfter = Number(body?.parameters?.retry_after);
275
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) {
276
+ error.retry_after = retryAfter;
277
+ error.retryAfter = retryAfter;
278
+ }
173
279
  throw error;
174
280
  }
175
281
  return body.result;
@@ -146,7 +146,7 @@ export function telegramInboundAllowed(message, {
146
146
  && allowedPrivateUserIds.has(String(message.senderId));
147
147
  }
148
148
 
149
- class TelegramBotClient {
149
+ export class TelegramBotClient {
150
150
  #api;
151
151
  #signal;
152
152
 
@@ -157,17 +157,20 @@ class TelegramBotClient {
157
157
 
158
158
  async sendText(target, text) {
159
159
  const chunks = splitMessageText(text, 4_000);
160
- let result = null;
160
+ const providerMessageIds = [];
161
161
  for (const [index, chunk] of chunks.entries()) {
162
- result = await this.#api.sendMessage({
162
+ const result = await this.#api.sendMessage({
163
163
  chatId: target.chatId,
164
164
  text: chunk,
165
165
  replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
166
166
  messageThreadId: target.messageThreadId,
167
167
  signal: this.#signal,
168
168
  });
169
+ if (Number.isSafeInteger(result?.message_id)) {
170
+ providerMessageIds.push(String(result.message_id));
171
+ }
169
172
  }
170
- return result;
173
+ return { providerMessageIds };
171
174
  }
172
175
 
173
176
  sendTyping(target) {
@@ -178,6 +181,16 @@ class TelegramBotClient {
178
181
  });
179
182
  }
180
183
 
184
+ sendFile(target, file) {
185
+ return this.#api.sendDocument({
186
+ chatId: target.chatId,
187
+ file,
188
+ replyToMessageId: target.replyToMessageId,
189
+ messageThreadId: target.messageThreadId,
190
+ signal: this.#signal,
191
+ });
192
+ }
193
+
181
194
  async openStream(target) {
182
195
  const stream = createEditableMessageStream({
183
196
  limit: 4_000,
@@ -203,6 +216,7 @@ class TelegramBotClient {
203
216
  messageThreadId: target.messageThreadId,
204
217
  signal: this.#signal,
205
218
  }),
219
+ messageIdForResult: (message) => message?.message_id,
206
220
  });
207
221
  return stream.start();
208
222
  }
@@ -27,6 +27,18 @@ import {
27
27
  promptContentForMessage,
28
28
  } from '../shared/image-prompt.mjs';
29
29
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
30
+ import {
31
+ materializeOutboundArtifact,
32
+ releaseOutboundArtifact,
33
+ trackOutboundArtifactProviderPromise,
34
+ } from '../shared/semantic/artifact.mjs';
35
+ import {
36
+ createArtifactFailureReceipt,
37
+ createDeliveryReceipt,
38
+ mergeDeliveryReceipts,
39
+ } from '../shared/semantic/delivery.mjs';
40
+
41
+ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
30
42
 
31
43
  const HELP_TEXT = [
32
44
  '企业微信机器人已连接 DeepSeek Harness。',
@@ -212,6 +224,88 @@ function progressText(update) {
212
224
  return update?.text;
213
225
  }
214
226
 
227
+ function artifactFailureText(fileName, error) {
228
+ const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
229
+ switch (error?.code) {
230
+ case 'artifact-delivery-uncertain':
231
+ return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
232
+ case 'artifact-permission-required':
233
+ return `结果文件「${name}」已生成,但企业微信智能机器人缺少素材上传或文件消息能力,请检查机器人权限。`;
234
+ case 'artifact-too-large':
235
+ return `结果文件「${name}」超过当前企业微信机器人可发送的文件大小,未发送。`;
236
+ case 'artifact-empty':
237
+ return `结果文件「${name}」为空,企业微信不允许发送空文件。`;
238
+ case 'artifact-changed':
239
+ case 'artifact-invalid':
240
+ case 'artifact-unavailable':
241
+ return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
242
+ case 'artifact-rate-limited':
243
+ return `结果文件「${name}」暂时被企业微信限流,未能发送,请稍后重试。`;
244
+ case 'artifact-provider-rejected':
245
+ return `结果文件「${name}」已生成,但企业微信拒绝了该文件或文件消息。`;
246
+ default:
247
+ return `结果文件「${name}」已生成,但暂时未能通过企业微信发送,请稍后重试。`;
248
+ }
249
+ }
250
+
251
+ function abortReason(signal) {
252
+ return signal?.reason instanceof Error
253
+ ? signal.reason
254
+ : new DOMException('The operation was aborted', 'AbortError');
255
+ }
256
+
257
+ function waitWithSignal(promise, signal) {
258
+ if (!signal) return promise;
259
+ signal.throwIfAborted();
260
+ return new Promise((resolve, reject) => {
261
+ let settled = false;
262
+ const finish = (callback, value) => {
263
+ if (settled) return;
264
+ settled = true;
265
+ signal.removeEventListener('abort', onAbort);
266
+ callback(value);
267
+ };
268
+ const onAbort = () => finish(reject, abortReason(signal));
269
+ signal.addEventListener('abort', onAbort, { once: true });
270
+ Promise.resolve(promise).then(
271
+ (value) => finish(resolve, value),
272
+ (error) => finish(reject, error),
273
+ );
274
+ if (signal.aborted) onAbort();
275
+ });
276
+ }
277
+
278
+ function wecomArtifactError(error, { dispatched = false } = {}) {
279
+ if (error?.code?.startsWith?.('artifact-')) return error;
280
+ const status = Number(error?.httpStatus ?? error?.status ?? error?.response?.status);
281
+ const providerCode = Number(error?.providerCode ?? error?.errcode ?? error?.body?.errcode);
282
+ const wrapped = new Error('Enterprise WeChat file delivery failed', { cause: error });
283
+ if (status === 401 || status === 403 || providerCode === 48002) {
284
+ wrapped.code = 'artifact-permission-required';
285
+ } else if (status === 413) {
286
+ wrapped.code = 'artifact-too-large';
287
+ } else if (status === 429 || providerCode === 45009) {
288
+ wrapped.code = 'artifact-rate-limited';
289
+ } else if (Number.isFinite(providerCode) && providerCode !== 0) {
290
+ wrapped.code = 'artifact-provider-rejected';
291
+ } else {
292
+ wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
293
+ }
294
+ if (Number.isFinite(status)) wrapped.status = status;
295
+ if (Number.isFinite(providerCode)) wrapped.providerCode = providerCode;
296
+ return wrapped;
297
+ }
298
+
299
+ function answerTextForDelivery(answer, artifacts) {
300
+ if (typeof answer === 'string' && answer.trim()) return answer;
301
+ return artifacts.length > 0 ? '结果文件已生成。' : '任务已完成,但没有生成可显示的文本。';
302
+ }
303
+
304
+ function providerMessageId(result) {
305
+ return nonEmptyString(result?.body?.msgid)
306
+ ?? nonEmptyString(result?.body?.message_id);
307
+ }
308
+
215
309
  function canClaimInteractionReply(frame, pending) {
216
310
  return pending.questions[pending.index]
217
311
  && nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
@@ -223,6 +317,8 @@ export function createWecomBridgeStatus() {
223
317
  messagesReceived: 0,
224
318
  messagesReplied: 0,
225
319
  messagesRejected: 0,
320
+ artifactsSent: 0,
321
+ artifactSendErrors: 0,
226
322
  lastMessageAt: null,
227
323
  lastReplyAt: null,
228
324
  lastRejectedAt: null,
@@ -239,6 +335,7 @@ export class WecomHarnessBridge {
239
335
  #replyTimeoutMs;
240
336
  #generateReqId;
241
337
  #signal;
338
+ #fileUploadTimeoutMs;
242
339
  #queues = new Map();
243
340
  #pendingInteractions = new Map();
244
341
  #interactionKeys = new Map();
@@ -256,12 +353,16 @@ export class WecomHarnessBridge {
256
353
  logger = console,
257
354
  replyTimeoutMs = 600_000,
258
355
  generateStreamId = generateReqId,
356
+ fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
259
357
  signal,
260
358
  }) {
261
359
  if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
262
360
  throw new TypeError('Enterprise WeChat client is required');
263
361
  }
264
362
  if (!harness || !state) throw new TypeError('Harness client and state store are required');
363
+ if (!Number.isInteger(fileUploadTimeoutMs) || fileUploadTimeoutMs < 1) {
364
+ throw new TypeError('fileUploadTimeoutMs must be a positive integer');
365
+ }
265
366
  this.#client = client;
266
367
  this.#harness = harness;
267
368
  this.#state = state;
@@ -269,6 +370,7 @@ export class WecomHarnessBridge {
269
370
  this.#logger = logger;
270
371
  this.#replyTimeoutMs = replyTimeoutMs;
271
372
  this.#generateReqId = generateStreamId;
373
+ this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
272
374
  this.#signal = signal;
273
375
  this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
274
376
  }
@@ -458,10 +560,17 @@ export class WecomHarnessBridge {
458
560
  }
459
561
 
460
562
  async #sendActive(chatId, text) {
563
+ const providerMessageIds = [];
461
564
  for (const chunk of splitUtf8(text)) {
462
565
  this.#signal?.throwIfAborted();
463
- await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
566
+ const result = await this.#client.sendMessage(
567
+ chatId,
568
+ { msgtype: 'markdown', markdown: { content: chunk } },
569
+ );
570
+ const messageId = providerMessageId(result);
571
+ if (messageId) providerMessageIds.push(messageId);
464
572
  }
573
+ return providerMessageIds;
465
574
  }
466
575
 
467
576
  async #sendImmediate(frame, chatId, text) {
@@ -478,6 +587,105 @@ export class WecomHarnessBridge {
478
587
  }
479
588
  }
480
589
 
590
+ async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt = null) {
591
+ if (artifacts.length === 0) {
592
+ return { receipt: baseReceipt, failureNoticeVisible: false };
593
+ }
594
+ const receipts = baseReceipt ? [baseReceipt] : [];
595
+ let failureNoticeVisible = false;
596
+ for (const artifact of artifacts) {
597
+ this.#signal?.throwIfAborted();
598
+ try {
599
+ if (typeof this.#client.uploadMedia !== 'function'
600
+ || typeof this.#client.sendMediaMessage !== 'function') {
601
+ const unavailable = new Error('Enterprise WeChat file delivery is unavailable');
602
+ unavailable.code = 'artifact-provider-unavailable';
603
+ throw unavailable;
604
+ }
605
+ const file = await materializeOutboundArtifact(artifact, {
606
+ signal: this.#signal,
607
+ });
608
+ this.#signal?.throwIfAborted();
609
+ const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
610
+ const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
611
+ let uploaded;
612
+ try {
613
+ const pending = this.#client.uploadMedia(file.bytes, {
614
+ type: 'file',
615
+ filename: file.fileName,
616
+ });
617
+ trackOutboundArtifactProviderPromise(file, pending);
618
+ uploaded = await waitWithSignal(pending, waitSignal);
619
+ } catch (error) {
620
+ if (this.#signal?.aborted) throw abortReason(this.#signal);
621
+ throw wecomArtifactError(error);
622
+ }
623
+ this.#signal?.throwIfAborted();
624
+ const mediaId = nonEmptyString(uploaded?.media_id);
625
+ if (!mediaId) {
626
+ const rejected = new Error('Enterprise WeChat upload returned no media id');
627
+ rejected.code = 'artifact-provider-rejected';
628
+ throw rejected;
629
+ }
630
+ let sent;
631
+ try {
632
+ const pending = this.#client.sendMediaMessage(chatId, 'file', mediaId);
633
+ trackOutboundArtifactProviderPromise(file, pending);
634
+ sent = await waitWithSignal(pending, waitSignal);
635
+ } catch (error) {
636
+ if (this.#signal?.aborted) throw abortReason(this.#signal);
637
+ throw wecomArtifactError(error, { dispatched: true });
638
+ }
639
+ this.#signal?.throwIfAborted();
640
+ const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
641
+ if (Number.isFinite(providerCode) && providerCode !== 0) {
642
+ throw wecomArtifactError({ providerCode });
643
+ }
644
+ const messageId = providerMessageId(sent);
645
+ receipts.push(createDeliveryReceipt({
646
+ deliveryId: file.deliveryKey,
647
+ presentation: 'wecom-file',
648
+ providerMessageIds: messageId ? [messageId] : [],
649
+ artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
650
+ }));
651
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
652
+ } catch (error) {
653
+ if (this.#signal?.aborted) throw error;
654
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
655
+ this.#logger.warn?.(
656
+ `[dsh-im:wecom] result file delivery failed (${error?.code ?? 'unknown'})`,
657
+ );
658
+ let providerMessageIds = [];
659
+ try {
660
+ providerMessageIds = await this.#sendActive(
661
+ chatId,
662
+ artifactFailureText(artifact?.fileName, error),
663
+ );
664
+ failureNoticeVisible = true;
665
+ } catch (noticeError) {
666
+ if (this.#signal?.aborted) throw noticeError;
667
+ this.#logger.warn?.('[dsh-im:wecom] unable to send the safe result-file failure notice');
668
+ }
669
+ receipts.push(createArtifactFailureReceipt({
670
+ artifactId: artifact?.artifactId ?? 'unknown',
671
+ deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
672
+ error,
673
+ providerMessageIds,
674
+ }));
675
+ } finally {
676
+ releaseOutboundArtifact(artifact);
677
+ }
678
+ }
679
+ return {
680
+ receipt: mergeDeliveryReceipts({
681
+ deliveryId: replyTo,
682
+ presentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
683
+ receipts,
684
+ }),
685
+ failureNoticeVisible,
686
+ };
687
+ }
688
+
481
689
  async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
482
690
  if (this.#signal?.aborted) return;
483
691
  const body = bodyOf(frame);
@@ -556,7 +764,7 @@ export class WecomHarnessBridge {
556
764
  const content = hasImages
557
765
  ? await promptContentForMessage(message, { signal: this.#signal })
558
766
  : undefined;
559
- const { answer } = await askInWorkspaceSession({
767
+ const { answer, artifacts = [] } = await askInWorkspaceSession({
560
768
  harness: this.#harness,
561
769
  state: this.#state,
562
770
  key,
@@ -584,24 +792,64 @@ export class WecomHarnessBridge {
584
792
  },
585
793
  });
586
794
 
587
- const chunks = splitUtf8(answer || '任务已完成,但没有生成可显示的文本。');
795
+ this.#signal?.throwIfAborted();
796
+ const displayAnswer = answerTextForDelivery(answer, artifacts);
797
+ const chunks = splitUtf8(displayAnswer);
588
798
  let finalSent = false;
589
- if (streamStarted && chunks.length > 0) {
590
- try {
591
- await this.#client.replyStream(frame, streamId, chunks[0], true);
592
- for (const chunk of chunks.slice(1)) {
593
- await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
799
+ let textReceipt = null;
800
+ let textSendError = null;
801
+ try {
802
+ if (streamStarted && chunks.length > 0) {
803
+ try {
804
+ const providerMessageIds = [];
805
+ const streamed = await this.#client.replyStream(frame, streamId, chunks[0], true);
806
+ const streamedMessageId = providerMessageId(streamed);
807
+ if (streamedMessageId) providerMessageIds.push(streamedMessageId);
808
+ for (const chunk of chunks.slice(1)) {
809
+ const sent = await this.#client.sendMessage(
810
+ chatId,
811
+ { msgtype: 'markdown', markdown: { content: chunk } },
812
+ );
813
+ const messageId = providerMessageId(sent);
814
+ if (messageId) providerMessageIds.push(messageId);
815
+ }
816
+ finalSent = true;
817
+ textReceipt = createDeliveryReceipt({
818
+ deliveryId: messageId,
819
+ presentation: 'wecom-text',
820
+ providerMessageIds,
821
+ });
822
+ } catch (error) {
823
+ this.#logger.warn?.('[dsh-im:wecom] stream finalization failed; using an active reply:', error);
594
824
  }
595
- finalSent = true;
596
- } catch (error) {
597
- this.#logger.warn?.('[dsh-im:wecom] stream finalization failed; using an active reply:', error);
598
825
  }
826
+ if (!finalSent) {
827
+ const providerMessageIds = await this.#sendActive(chatId, displayAnswer);
828
+ textReceipt = createDeliveryReceipt({
829
+ deliveryId: messageId,
830
+ presentation: 'wecom-text',
831
+ providerMessageIds,
832
+ });
833
+ }
834
+ } catch (error) {
835
+ textSendError = error;
836
+ this.#logger.warn?.(
837
+ '[dsh-im:wecom] final text delivery failed; continuing with result files:',
838
+ error,
839
+ );
840
+ }
841
+ const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
842
+ const artifactDispatched = delivery.receipt?.artifacts?.some(
843
+ ({ outcome }) => outcome === 'sent' || outcome === 'unknown',
844
+ );
845
+ if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
846
+ throw textSendError;
599
847
  }
600
- if (!finalSent) await this.#sendActive(chatId, answer);
601
848
  await this.#state.markSeen(messageId);
602
849
  this.#status.messagesReplied += 1;
603
850
  this.#status.lastReplyAt = new Date().toISOString();
604
851
  this.#status.lastError = null;
852
+ return delivery.receipt;
605
853
  } catch (error) {
606
854
  if (error?.code === 'turn-stopped') {
607
855
  if (streamStarted && streamId) {