@xmanrui/dsh-im 1.1.0 → 1.3.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 (40) hide show
  1. package/README.en.md +5 -3
  2. package/README.md +5 -3
  3. package/lib/client.js +212 -0
  4. package/lib/index.js +166 -163
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  7. package/plugin-src/client/channels/whatsapp/index.js +125 -0
  8. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  9. package/plugin-src/client/i18n.js +18 -0
  10. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  11. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  12. package/plugin-src/host/channels/qq/production.mjs +3 -1
  13. package/plugin-src/host/channels/shared/production.mjs +3 -1
  14. package/plugin-src/host/channels/slack/production.mjs +3 -1
  15. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  16. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  17. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  18. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  19. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  20. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  22. package/src/channels/discord/discord-runtime.mjs +23 -0
  23. package/src/channels/feishu/bridge.mjs +18 -10
  24. package/src/channels/feishu/message-utils.mjs +47 -0
  25. package/src/channels/qq/qq-bridge.mjs +80 -28
  26. package/src/channels/shared/file-download.mjs +64 -0
  27. package/src/channels/shared/harness-client.mjs +45 -0
  28. package/src/channels/shared/inbound-file.mjs +206 -0
  29. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  30. package/src/channels/slack/slack-api.mjs +27 -4
  31. package/src/channels/slack/slack-runtime.mjs +55 -5
  32. package/src/channels/telegram/telegram-api.mjs +21 -6
  33. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  34. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  35. package/src/channels/weixin/weixin-api.mjs +45 -0
  36. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  37. package/src/channels/whatsapp/config-store.mjs +43 -0
  38. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  39. package/src/channels/whatsapp/whatsapp-runtime.mjs +83 -2
  40. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
@@ -26,6 +26,11 @@ import {
26
26
  imagePromptUserMessage,
27
27
  promptContentForMessage,
28
28
  } from '../shared/image-prompt.mjs';
29
+ import {
30
+ hasInboundFiles,
31
+ inboundFileUserMessage,
32
+ prefetchInboundFiles,
33
+ } from '../shared/inbound-file.mjs';
29
34
  import {
30
35
  materializeOutboundArtifact,
31
36
  releaseOutboundArtifact,
@@ -55,7 +60,7 @@ const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
55
60
  const HELP_TEXT = [
56
61
  'QQ 机器人已连接 DeepSeek Harness。',
57
62
  '',
58
- '直接发送文字或图片即可继续当前会话。',
63
+ '直接发送文字、图片或文件即可继续当前会话。',
59
64
  '/new 开启一个全新会话',
60
65
  '/compact 压缩当前会话的较早上下文',
61
66
  '/workspace 工作区绝对路径 切换工作区',
@@ -100,32 +105,63 @@ function hasQqImageAttachments(message) {
100
105
  && message.attachments.some(isQqImageAttachment);
101
106
  }
102
107
 
108
+ function hasQqFileAttachments(message) {
109
+ return Array.isArray(message?.attachments)
110
+ && message.attachments.some((attachment) => !isQqImageAttachment(attachment));
111
+ }
112
+
113
+ async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
114
+ const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
115
+ const response = await fetchImpl(new URL(normalizedUrl), {
116
+ method: 'GET',
117
+ signal,
118
+ redirect: 'follow',
119
+ });
120
+ if (!response?.ok) {
121
+ await response?.body?.cancel?.().catch?.(() => undefined);
122
+ throw new Error(`QQ file download failed with HTTP ${response?.status ?? 'unknown'}`);
123
+ }
124
+ return Buffer.from(await response.arrayBuffer());
125
+ }
126
+
103
127
  /** Convert QQ's attachment metadata into lazily downloaded image references. */
104
128
  export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
105
129
  if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
106
130
  const images = [];
131
+ const files = [];
107
132
  for (const attachment of message?.attachments ?? []) {
108
- if (!isQqImageAttachment(attachment)) continue;
109
133
  const url = nonEmptyString(attachment?.url);
110
134
  const name = nonEmptyString(attachment?.filename) ?? undefined;
111
135
  const mediaType = attachmentMediaType(attachment);
112
136
  const declaredSize = Number(attachment?.size);
113
- images.push({
114
- ...(name ? { name } : {}),
115
- ...(mediaType?.startsWith('image/') ? { mediaType } : {}),
137
+ if (isQqImageAttachment(attachment)) {
138
+ images.push({
139
+ ...(name ? { name } : {}),
140
+ ...(mediaType?.startsWith('image/') ? { mediaType } : {}),
141
+ ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
142
+ load: ({ signal, maxBytes }) => {
143
+ if (!url) throw new Error('QQ image attachment has no download URL');
144
+ return fetchImageBuffer(url, {
145
+ fetchImpl,
146
+ signal,
147
+ maxBytes,
148
+ allowedHosts: QQ_IMAGE_HOSTS,
149
+ });
150
+ },
151
+ });
152
+ continue;
153
+ }
154
+ files.push({
155
+ name: name ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
156
+ ...(mediaType?.includes('/') ? { mediaType } : {}),
116
157
  ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
117
- load: ({ signal, maxBytes }) => {
118
- if (!url) throw new Error('QQ image attachment has no download URL');
119
- return fetchImageBuffer(url, {
120
- fetchImpl,
121
- signal,
122
- maxBytes,
123
- allowedHosts: QQ_IMAGE_HOSTS,
124
- });
158
+ load: ({ signal } = {}) => {
159
+ if (!url) throw new Error('QQ file attachment has no download URL');
160
+ return fetchQqFileBuffer(url, { fetchImpl, signal });
125
161
  },
126
162
  });
127
163
  }
128
- return { content: safeText(message), images };
164
+ return { content: safeText(message), images, files };
129
165
  }
130
166
 
131
167
  function nonEmptyString(value) {
@@ -213,6 +249,7 @@ function canClaimInteractionReply(message, pending) {
213
249
  && nonEmptyString(message?.senderId) === pending.actor
214
250
  && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
215
251
  && !hasQqImageAttachments(message)
252
+ && !hasQqFileAttachments(message)
216
253
  && nonEmptyString(safeText(message));
217
254
  }
218
255
 
@@ -303,7 +340,7 @@ export class QqHarnessBridge {
303
340
  }
304
341
  const pending = this.#pendingInteractions.get(key);
305
342
  const commandText = safeText(message);
306
- const commandRunner = isControlCommand(commandText)
343
+ const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
307
344
  ? runControlCommand
308
345
  : (isModelCommand(commandText)
309
346
  ? runModelCommand
@@ -336,7 +373,7 @@ export class QqHarnessBridge {
336
373
  key,
337
374
  actor: sender,
338
375
  messageId,
339
- text: hasQqImageAttachments(message) ? '' : safeText(message),
376
+ text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
340
377
  addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
341
378
  hasPendingQuestion: Boolean(pending),
342
379
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -391,10 +428,19 @@ export class QqHarnessBridge {
391
428
  releaseMessageId = true,
392
429
  alreadyRecorded = false,
393
430
  } = {}) {
431
+ const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
432
+ const addressed = message.kind !== 'group'
433
+ || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
434
+ const preparedMessage = allowed && addressed
435
+ ? prefetchInboundFiles(
436
+ qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
437
+ { signal: this.#signal },
438
+ )
439
+ : undefined;
394
440
  const previous = this.#queues.get(key) ?? Promise.resolve();
395
441
  const current = previous
396
442
  .catch(() => undefined)
397
- .then(() => this.#process(message, key, { alreadyRecorded }))
443
+ .then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
398
444
  .finally(() => {
399
445
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
400
446
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -423,6 +469,7 @@ export class QqHarnessBridge {
423
469
  const result = await runner(text, this.#harness, this.#state, key, {
424
470
  signal: this.#signal,
425
471
  hasImages: hasQqImageAttachments(message),
472
+ hasFiles: hasQqFileAttachments(message),
426
473
  pendingInteraction: this.#pendingInteractions.has(key)
427
474
  || this.#approvals.hasPending(key),
428
475
  control: { owner: this, key },
@@ -520,7 +567,7 @@ export class QqHarnessBridge {
520
567
  };
521
568
  }
522
569
 
523
- async #process(message, key, { alreadyRecorded = false } = {}) {
570
+ async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
524
571
  if (this.#signal?.aborted) return;
525
572
  const messageId = nonEmptyString(message?.messageId);
526
573
  const sender = nonEmptyString(message?.senderId);
@@ -539,35 +586,37 @@ export class QqHarnessBridge {
539
586
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
540
587
 
541
588
  const target = message.replyTarget;
542
- const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
589
+ const promptMessage = preparedMessage
590
+ ?? qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
543
591
  const text = promptMessage.content;
544
592
  const hasImages = hasInboundImages(promptMessage);
593
+ const hasFiles = hasInboundFiles(promptMessage);
545
594
  let stream = null;
546
595
  try {
547
- if (!text && !hasImages) {
548
- await this.#bot.sendText(target, '目前支持文字和图片消息。');
596
+ if (!text && !hasImages && !hasFiles) {
597
+ await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
549
598
  await this.#state.markSeen(messageId);
550
599
  return;
551
600
  }
552
601
  const command = text.toLowerCase();
553
- if (!hasImages && command === '/help') {
602
+ if (!hasImages && !hasFiles && command === '/help') {
554
603
  await this.#bot.sendText(target, HELP_TEXT);
555
604
  await this.#state.markSeen(messageId);
556
605
  return;
557
606
  }
558
- if (!hasImages && command === '/status') {
607
+ if (!hasImages && !hasFiles && command === '/status') {
559
608
  await this.#harness.ensureRunning({ signal: this.#signal });
560
609
  await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
561
610
  await this.#state.markSeen(messageId);
562
611
  return;
563
612
  }
564
- if (!hasImages && command === '/new') {
613
+ if (!hasImages && !hasFiles && command === '/new') {
565
614
  await this.#state.clearSession(key);
566
615
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
567
616
  await this.#state.markSeen(messageId);
568
617
  return;
569
618
  }
570
- const workspaceCommand = hasImages
619
+ const workspaceCommand = hasImages || hasFiles
571
620
  ? null
572
621
  : await runWorkspaceCommand(text, this.#harness, key);
573
622
  if (workspaceCommand) {
@@ -577,7 +626,7 @@ export class QqHarnessBridge {
577
626
  await this.#state.markSeen(messageId);
578
627
  return;
579
628
  }
580
- const compactCommand = hasImages
629
+ const compactCommand = hasImages || hasFiles
581
630
  ? null
582
631
  : await runCompactCommand(
583
632
  text,
@@ -632,6 +681,7 @@ export class QqHarnessBridge {
632
681
  requiresMention: message.kind === 'group',
633
682
  }),
634
683
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
684
+ files: promptMessage.files,
635
685
  },
636
686
  }));
637
687
  } finally {
@@ -708,7 +758,9 @@ export class QqHarnessBridge {
708
758
  try {
709
759
  await this.#bot.sendText(
710
760
  target,
711
- imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
761
+ inboundFileUserMessage(error)
762
+ ?? imagePromptUserMessage(error)
763
+ ?? '消息处理失败,请稍后重试。',
712
764
  );
713
765
  await this.#state.markSeen(messageId);
714
766
  } catch (sendError) {
@@ -734,7 +786,7 @@ export class QqHarnessBridge {
734
786
 
735
787
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
736
788
  const text = nonEmptyString(safeText(message));
737
- if (!text || hasQqImageAttachments(message)) {
789
+ if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
738
790
  await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
739
791
  return;
740
792
  }
@@ -0,0 +1,64 @@
1
+ async function cancelResponseBody(response) {
2
+ try {
3
+ await response?.body?.cancel?.();
4
+ } catch {
5
+ // Preserve the original download failure.
6
+ }
7
+ }
8
+
9
+ function hostedByMessagingPlatform(target, allowedHosts) {
10
+ return !Array.isArray(allowedHosts) || allowedHosts.some((rule) => (
11
+ typeof rule === 'string'
12
+ && (target.hostname === rule
13
+ || (rule.startsWith('.')
14
+ && (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
15
+ ));
16
+ }
17
+
18
+ /**
19
+ * Open a channel-hosted ordinary file as a stream.
20
+ *
21
+ * This deliberately has no plugin-defined size, type, count, or download-time
22
+ * limit. The caller owns cancellation through its AbortSignal and the channel
23
+ * remains the authority for its own file limits.
24
+ */
25
+ export async function fetchFileStream(url, {
26
+ fetchImpl = fetch,
27
+ headers,
28
+ signal,
29
+ allowedHosts,
30
+ } = {}) {
31
+ const target = new URL(url);
32
+ if (target.protocol !== 'https:') throw new Error('File download URL must use HTTPS');
33
+ if (!hostedByMessagingPlatform(target, allowedHosts)) {
34
+ throw new Error('File download URL is not hosted by the messaging platform');
35
+ }
36
+
37
+ const response = await fetchImpl(target, {
38
+ method: 'GET',
39
+ headers,
40
+ signal,
41
+ redirect: 'manual',
42
+ });
43
+ if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
44
+ await cancelResponseBody(response);
45
+ const error = new Error(`File download redirect was blocked (HTTP ${response.status})`);
46
+ error.code = 'file-redirect-blocked';
47
+ throw error;
48
+ }
49
+ if (!response?.ok) {
50
+ await cancelResponseBody(response);
51
+ const error = new Error(`File download failed with HTTP ${response?.status ?? 'unknown'}`);
52
+ error.code = 'file-http-error';
53
+ error.status = response?.status;
54
+ throw error;
55
+ }
56
+ if (response.body?.[Symbol.asyncIterator]) return { stream: response.body };
57
+ if (typeof response.arrayBuffer === 'function') {
58
+ const data = Buffer.from(await response.arrayBuffer());
59
+ return {
60
+ stream: (async function* fileBody() { yield data; }()),
61
+ };
62
+ }
63
+ throw new Error('File download returned no readable body');
64
+ }
@@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
3
3
  import { isAbsolute } from 'node:path';
4
4
 
5
5
  import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
6
+ import {
7
+ appendInboundFilesToPrompt,
8
+ InboundFileError,
9
+ } from './inbound-file.mjs';
6
10
  import { outboundArtifactRegistry } from './semantic/artifact.mjs';
7
11
 
8
12
  // Every channel plugin runs in the same Host process. Sharing ownership by
@@ -466,6 +470,7 @@ export class HarnessClient {
466
470
  #commandExecutor;
467
471
  #controlExecutor;
468
472
  #sessionMaintenanceExecutor;
473
+ #fileIngressExecutor;
469
474
  #managedProcess = null;
470
475
  #interactionRegistry;
471
476
  #interactionOwnerships;
@@ -486,6 +491,7 @@ export class HarnessClient {
486
491
  commandExecutor,
487
492
  controlExecutor,
488
493
  sessionMaintenanceExecutor,
494
+ fileIngressExecutor,
489
495
  }) {
490
496
  if (typeof createWebSocket !== 'function') {
491
497
  throw new TypeError('createWebSocket must be a function');
@@ -509,6 +515,9 @@ export class HarnessClient {
509
515
  && typeof sessionMaintenanceExecutor !== 'function') {
510
516
  throw new TypeError('sessionMaintenanceExecutor must be a function');
511
517
  }
518
+ if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
519
+ throw new TypeError('fileIngressExecutor must be a function');
520
+ }
512
521
  this.#baseUrl = new URL(baseUrl);
513
522
  this.#workspace = workspace;
514
523
  // Keep an omitted preset absent so session.create resolves the Host's current default.
@@ -523,6 +532,7 @@ export class HarnessClient {
523
532
  this.#commandExecutor = commandExecutor;
524
533
  this.#controlExecutor = controlExecutor;
525
534
  this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
535
+ this.#fileIngressExecutor = fileIngressExecutor;
526
536
  this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
527
537
  this.#interactionOwnerships = this.#interactionRegistry.ownerships;
528
538
  this.#interactionClaims = this.#interactionRegistry.claims;
@@ -1034,6 +1044,7 @@ export class HarnessClient {
1034
1044
  ? options.onInteractionResolved
1035
1045
  : undefined;
1036
1046
  const control = normalizeControl(options.control);
1047
+ const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
1037
1048
  await this.ensureRunning({ signal });
1038
1049
  const before = await this.rpc(
1039
1050
  'session.history',
@@ -1075,6 +1086,9 @@ export class HarnessClient {
1075
1086
  let interactionTask = null;
1076
1087
  let artifactsDelivered = false;
1077
1088
  let deliveredArtifactCount = 0;
1089
+ let stagedInboundFiles = null;
1090
+ let promptAccepted = false;
1091
+ let turnFinished = false;
1078
1092
 
1079
1093
  const deliverArtifacts = async () => {
1080
1094
  if (!onArtifact || artifactsDelivered || tracker.turn === null) {
@@ -1103,6 +1117,30 @@ export class HarnessClient {
1103
1117
  const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
1104
1118
 
1105
1119
  try {
1120
+ if (inboundFiles.length > 0) {
1121
+ if (!this.#fileIngressExecutor) {
1122
+ throw new InboundFileError(
1123
+ 'inbound-file-ingress-unavailable',
1124
+ 'Harness file ingress is unavailable in this Host process.',
1125
+ );
1126
+ }
1127
+ const sessionList = await this.rpc(
1128
+ 'session.list',
1129
+ {},
1130
+ 30_000,
1131
+ { signal },
1132
+ );
1133
+ const sessionWorkspace = sessionList?.items?.find(
1134
+ (item) => item?.sessionId === sessionId,
1135
+ )?.cwd;
1136
+ stagedInboundFiles = await this.#fileIngressExecutor({
1137
+ sessionId,
1138
+ workspace: sessionWorkspace,
1139
+ files: inboundFiles,
1140
+ signal,
1141
+ });
1142
+ prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
1143
+ }
1106
1144
  if (interactionSignal) {
1107
1145
  let markOpen;
1108
1146
  const opened = new Promise((resolve) => { markOpen = resolve; });
@@ -1134,6 +1172,7 @@ export class HarnessClient {
1134
1172
  content,
1135
1173
  clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1136
1174
  }, 30_000, { rpcId: promptRpcId, signal });
1175
+ promptAccepted = true;
1137
1176
 
1138
1177
  try {
1139
1178
  const deadline = Date.now() + timeoutMs;
@@ -1159,6 +1198,7 @@ export class HarnessClient {
1159
1198
  }
1160
1199
  }
1161
1200
  if (!tracker.finished) continue;
1201
+ turnFinished = true;
1162
1202
  // An accepted /stop revokes attachment delivery even when Harness
1163
1203
  // preserved a useful partial text answer for the existing UX.
1164
1204
  const artifactCount = ownership?.stopRequested
@@ -1185,6 +1225,11 @@ export class HarnessClient {
1185
1225
  throw turnStoppedError();
1186
1226
  }
1187
1227
  } finally {
1228
+ if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
1229
+ await stagedInboundFiles.cleanup().catch((error) => {
1230
+ console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
1231
+ });
1232
+ }
1188
1233
  closeArtifactConsumer();
1189
1234
  if (ownership) {
1190
1235
  this.#unregisterControlOwnership(ownership);
@@ -0,0 +1,206 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { pipeline } from 'node:stream/promises';
5
+
6
+ const FILES_DIRECTORY = join('.dsh-im', 'inbound');
7
+
8
+ export class InboundFileError extends Error {
9
+ constructor(code, message, userMessage = '文件接收失败,请重新发送后再试。', options = {}) {
10
+ super(message, options);
11
+ this.name = 'InboundFileError';
12
+ this.code = code;
13
+ this.userMessage = userMessage;
14
+ }
15
+ }
16
+
17
+ function fileSources(message) {
18
+ return Array.isArray(message?.files) ? message.files.filter(Boolean) : [];
19
+ }
20
+
21
+ function displayName(value, fallback) {
22
+ if (typeof value !== 'string') return fallback;
23
+ const cleaned = value
24
+ .replaceAll('\\', '/')
25
+ .split('/')
26
+ .at(-1)
27
+ ?.replace(/[\u0000-\u001f\u007f]/g, '')
28
+ .trim();
29
+ return cleaned || fallback;
30
+ }
31
+
32
+ function storageName(value, index) {
33
+ const cleaned = displayName(value, 'file')
34
+ .replace(/[^\p{L}\p{N}._ -]/gu, '_')
35
+ .replace(/^\.+/, '')
36
+ .slice(0, 160) || 'file';
37
+ return `${String(index + 1).padStart(2, '0')}-${cleaned}`;
38
+ }
39
+
40
+ function loadedFile(value) {
41
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
42
+ return { data: Buffer.from(value) };
43
+ }
44
+ const raw = value?.data ?? value?.buffer;
45
+ if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
46
+ return {
47
+ data: Buffer.from(raw),
48
+ name: value?.name ?? value?.filename,
49
+ mediaType: value?.mediaType ?? value?.mimetype,
50
+ };
51
+ }
52
+ const stream = value?.stream ?? value;
53
+ if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
54
+ return {
55
+ stream,
56
+ name: value?.name ?? value?.filename,
57
+ mediaType: value?.mediaType ?? value?.mimetype,
58
+ };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ export function hasInboundFiles(message) {
64
+ return fileSources(message).length > 0;
65
+ }
66
+
67
+ /** Start provider downloads immediately while preserving the lazy file-source contract. */
68
+ export function prefetchInboundFiles(message, { signal } = {}) {
69
+ const sources = fileSources(message);
70
+ if (sources.length === 0) return message;
71
+ return {
72
+ ...message,
73
+ files: sources.map((source) => {
74
+ if (source?.data !== undefined || typeof source?.load !== 'function') return source;
75
+ let download;
76
+ try {
77
+ download = Promise.resolve(source.load({ signal }));
78
+ } catch (error) {
79
+ download = Promise.reject(error);
80
+ }
81
+ download.catch(() => undefined);
82
+ return {
83
+ ...source,
84
+ async load({ signal: loadSignal } = {}) {
85
+ loadSignal?.throwIfAborted();
86
+ const result = await download;
87
+ loadSignal?.throwIfAborted();
88
+ return result;
89
+ },
90
+ };
91
+ }),
92
+ };
93
+ }
94
+
95
+ export async function stageInboundFiles(message, {
96
+ workspace,
97
+ signal,
98
+ } = {}) {
99
+ const sources = fileSources(message);
100
+ if (sources.length === 0) return null;
101
+ if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
102
+ throw new InboundFileError(
103
+ 'inbound-file-workspace-unavailable',
104
+ 'The Harness Session workspace is unavailable for inbound files.',
105
+ );
106
+ }
107
+
108
+ signal?.throwIfAborted();
109
+ const root = resolve(workspace, FILES_DIRECTORY);
110
+ await mkdir(root, { recursive: true, mode: 0o700 });
111
+ const directory = await mkdtemp(join(root, 'turn-'));
112
+ const files = [];
113
+
114
+ try {
115
+ for (const [index, source] of sources.entries()) {
116
+ signal?.throwIfAborted();
117
+ let value;
118
+ try {
119
+ value = source?.data === undefined
120
+ ? await source?.load?.({ signal })
121
+ : source.data;
122
+ } catch (error) {
123
+ if (signal?.aborted) throw error;
124
+ throw new InboundFileError(
125
+ 'inbound-file-download-failed',
126
+ `Unable to download inbound file ${index + 1}: ${error?.message ?? String(error)}`,
127
+ '文件下载失败,请重新发送后再试。',
128
+ { cause: error },
129
+ );
130
+ }
131
+
132
+ const loaded = loadedFile(value);
133
+ if (!loaded) {
134
+ throw new InboundFileError(
135
+ 'inbound-file-data-invalid',
136
+ `Inbound file ${index + 1} returned no readable data.`,
137
+ );
138
+ }
139
+ const name = displayName(loaded.name ?? source?.name, `file-${index + 1}`);
140
+ const path = join(directory, storageName(name, index));
141
+ if (loaded.data) {
142
+ await writeFile(path, loaded.data, { mode: 0o600, signal });
143
+ } else {
144
+ try {
145
+ await pipeline(
146
+ loaded.stream,
147
+ createWriteStream(path, { flags: 'wx', mode: 0o600 }),
148
+ { signal },
149
+ );
150
+ } catch (error) {
151
+ if (signal?.aborted) throw error;
152
+ throw new InboundFileError(
153
+ 'inbound-file-download-failed',
154
+ `Unable to stream inbound file ${index + 1}: ${error?.message ?? String(error)}`,
155
+ '文件下载失败,请重新发送后再试。',
156
+ { cause: error },
157
+ );
158
+ }
159
+ }
160
+ const relativePath = relative(resolve(workspace), path);
161
+ if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
162
+ throw new InboundFileError(
163
+ 'inbound-file-path-invalid',
164
+ 'The staged inbound file escaped the Harness Session workspace.',
165
+ );
166
+ }
167
+ files.push(Object.freeze({
168
+ name,
169
+ path: relativePath,
170
+ ...(typeof (loaded.mediaType ?? source?.mediaType) === 'string'
171
+ && (loaded.mediaType ?? source.mediaType).trim()
172
+ ? { mediaType: (loaded.mediaType ?? source.mediaType).trim() }
173
+ : {}),
174
+ }));
175
+ }
176
+ return Object.freeze({
177
+ files: Object.freeze(files),
178
+ async cleanup() {
179
+ await rm(directory, { recursive: true, force: true });
180
+ },
181
+ });
182
+ } catch (error) {
183
+ await rm(directory, { recursive: true, force: true }).catch(() => undefined);
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ export function appendInboundFilesToPrompt(prompt, staged) {
189
+ if (!staged?.files?.length) return prompt;
190
+ const manifest = [
191
+ '<dsh_im_files>',
192
+ JSON.stringify({
193
+ description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
194
+ files: staged.files,
195
+ }),
196
+ '</dsh_im_files>',
197
+ ].join('\n');
198
+
199
+ if (Array.isArray(prompt)) return [...prompt, { type: 'text', text: manifest }];
200
+ const text = typeof prompt === 'string' ? prompt.trim() : '';
201
+ return text ? `${text}\n\n${manifest}` : manifest;
202
+ }
203
+
204
+ export function inboundFileUserMessage(error) {
205
+ return error instanceof InboundFileError ? error.userMessage : null;
206
+ }