@xmanrui/dsh-im 1.2.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 (30) hide show
  1. package/lib/index.js +166 -163
  2. package/package.json +1 -1
  3. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  4. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  5. package/plugin-src/host/channels/qq/production.mjs +3 -1
  6. package/plugin-src/host/channels/shared/production.mjs +3 -1
  7. package/plugin-src/host/channels/slack/production.mjs +3 -1
  8. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  9. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  10. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  11. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  12. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  13. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  14. package/src/channels/discord/discord-runtime.mjs +23 -0
  15. package/src/channels/feishu/bridge.mjs +18 -10
  16. package/src/channels/feishu/message-utils.mjs +47 -0
  17. package/src/channels/qq/qq-bridge.mjs +80 -28
  18. package/src/channels/shared/file-download.mjs +64 -0
  19. package/src/channels/shared/harness-client.mjs +45 -0
  20. package/src/channels/shared/inbound-file.mjs +206 -0
  21. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  22. package/src/channels/slack/slack-api.mjs +27 -4
  23. package/src/channels/slack/slack-runtime.mjs +55 -5
  24. package/src/channels/telegram/telegram-api.mjs +21 -6
  25. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  26. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  27. package/src/channels/weixin/weixin-api.mjs +45 -0
  28. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  29. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
  30. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -86,9 +86,10 @@ export async function createProductionController(ctx, config = {}, internals = {
86
86
  return state;
87
87
  };
88
88
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
89
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
89
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
90
90
  controlExecutor: internals.controlExecutor,
91
91
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
92
+ fileIngressExecutor: internals.fileIngressExecutor,
92
93
  });
93
94
  const harness = new Harness({
94
95
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -98,6 +99,7 @@ export async function createProductionController(ctx, config = {}, internals = {
98
99
  ...(commandExecutor ? { commandExecutor } : {}),
99
100
  ...(controlExecutor ? { controlExecutor } : {}),
100
101
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
102
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
101
103
  });
102
104
  const coreController = new Controller({
103
105
  deviceAuth,
@@ -133,9 +133,10 @@ export async function createProductionController(ctx, config = {}, internals = {
133
133
  return stateFor(botConfig);
134
134
  };
135
135
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
136
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
136
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
137
137
  controlExecutor: internals.controlExecutor,
138
138
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
139
+ fileIngressExecutor: internals.fileIngressExecutor,
139
140
  });
140
141
  const harness = new Harness({
141
142
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -147,6 +148,7 @@ export async function createProductionController(ctx, config = {}, internals = {
147
148
  ...(commandExecutor ? { commandExecutor } : {}),
148
149
  ...(controlExecutor ? { controlExecutor } : {}),
149
150
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
151
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
150
152
  });
151
153
  const proxyEnv = internals.proxyEnv ?? process.env;
152
154
  const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);
@@ -77,9 +77,10 @@ export async function createProductionController(ctx, config = {}, internals = {
77
77
  return state;
78
78
  };
79
79
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
80
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
80
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
81
81
  controlExecutor: internals.controlExecutor,
82
82
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
83
+ fileIngressExecutor: internals.fileIngressExecutor,
83
84
  });
84
85
  const harness = new Harness({
85
86
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -89,6 +90,7 @@ export async function createProductionController(ctx, config = {}, internals = {
89
90
  ...(commandExecutor ? { commandExecutor } : {}),
90
91
  ...(controlExecutor ? { controlExecutor } : {}),
91
92
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
93
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
92
94
  });
93
95
  const coreController = new Controller({
94
96
  qrAuth,
@@ -78,9 +78,10 @@ export async function createTokenProductionController(ctx, config, internals, de
78
78
  return state;
79
79
  };
80
80
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
81
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
81
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
82
82
  controlExecutor: internals.controlExecutor,
83
83
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
84
+ fileIngressExecutor: internals.fileIngressExecutor,
84
85
  });
85
86
  const harness = new ResolvedHarness({
86
87
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -90,6 +91,7 @@ export async function createTokenProductionController(ctx, config, internals, de
90
91
  ...(commandExecutor ? { commandExecutor } : {}),
91
92
  ...(controlExecutor ? { controlExecutor } : {}),
92
93
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
94
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
93
95
  });
94
96
  const coreController = new ResolvedController({
95
97
  credentials: ctx.credentials,
@@ -56,9 +56,10 @@ export async function createProductionController(ctx, config = {}, internals = {
56
56
  return state;
57
57
  };
58
58
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
59
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
59
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
60
60
  controlExecutor: internals.controlExecutor,
61
61
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
62
+ fileIngressExecutor: internals.fileIngressExecutor,
62
63
  });
63
64
  const harness = new ResolvedHarness({
64
65
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -68,6 +69,7 @@ export async function createProductionController(ctx, config = {}, internals = {
68
69
  ...(commandExecutor ? { commandExecutor } : {}),
69
70
  ...(controlExecutor ? { controlExecutor } : {}),
70
71
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
72
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
71
73
  });
72
74
  const coreController = new ResolvedController({
73
75
  credentials: ctx.credentials,
@@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
80
80
  return state;
81
81
  };
82
82
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
83
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
83
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
84
84
  controlExecutor: internals.controlExecutor,
85
85
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
86
+ fileIngressExecutor: internals.fileIngressExecutor,
86
87
  });
87
88
  const harness = new Harness({
88
89
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
92
93
  ...(commandExecutor ? { commandExecutor } : {}),
93
94
  ...(controlExecutor ? { controlExecutor } : {}),
94
95
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
96
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
95
97
  });
96
98
  const coreController = new Controller({
97
99
  qrAuth,
@@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
80
80
  return state;
81
81
  };
82
82
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
83
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
83
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
84
84
  controlExecutor: internals.controlExecutor,
85
85
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
86
+ fileIngressExecutor: internals.fileIngressExecutor,
86
87
  });
87
88
  const harness = new Harness({
88
89
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
92
93
  ...(commandExecutor ? { commandExecutor } : {}),
93
94
  ...(controlExecutor ? { controlExecutor } : {}),
94
95
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
96
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
95
97
  });
96
98
  const coreController = new Controller({
97
99
  api,
@@ -83,9 +83,10 @@ export async function createProductionController(ctx, config = {}, internals = {
83
83
  return state;
84
84
  };
85
85
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
86
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
86
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
87
87
  controlExecutor: internals.controlExecutor,
88
88
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
89
+ fileIngressExecutor: internals.fileIngressExecutor,
89
90
  });
90
91
  const harness = new Harness({
91
92
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -95,6 +96,7 @@ export async function createProductionController(ctx, config = {}, internals = {
95
96
  ...(commandExecutor ? { commandExecutor } : {}),
96
97
  ...(controlExecutor ? { controlExecutor } : {}),
97
98
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
99
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
98
100
  });
99
101
  const coreController = new Controller({
100
102
  configStore: observedConfigStore,
@@ -1,5 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import {
4
+ InboundFileError,
5
+ stageInboundFiles,
6
+ } from '../../src/channels/shared/inbound-file.mjs';
7
+
3
8
  function agentsFromContext(ctx) {
4
9
  if (typeof ctx?.get !== 'function') return undefined;
5
10
  let agents;
@@ -108,13 +113,30 @@ function createSessionMaintenanceExecutor(agents) {
108
113
  };
109
114
  }
110
115
 
116
+ function createFileIngressExecutor(agents) {
117
+ return ({ sessionId, workspace, files, signal }) => {
118
+ const agent = agents.get(sessionId);
119
+ const attachedWorkspace = agent?.session?.header?.cwd;
120
+ const exactWorkspace = typeof attachedWorkspace === 'string' && attachedWorkspace
121
+ ? attachedWorkspace
122
+ : workspace;
123
+ if (typeof exactWorkspace !== 'string' || !exactWorkspace) {
124
+ throw new InboundFileError(
125
+ 'inbound-file-workspace-unavailable',
126
+ 'The Harness Session workspace is unavailable for inbound files.',
127
+ );
128
+ }
129
+ return stageInboundFiles({ files }, { workspace: exactWorkspace, signal });
130
+ };
131
+ }
132
+
111
133
  /**
112
- * Build optional same-process Session executors without adding a hard Cordis
113
- * service injection. Fixtures and deployments without AgentRegistry preserve
114
- * the existing HTTP behavior.
134
+ * Build optional same-process Session executors. File ingress also works for
135
+ * cold Sessions by using the authoritative cwd returned by session.list;
136
+ * when an Agent is attached, its live Session header remains authoritative.
115
137
  */
116
138
  export function createHarnessSessionExecutors(ctx, provided = {}) {
117
- const { controlExecutor, sessionMaintenanceExecutor } = provided;
139
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = provided;
118
140
  if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
119
141
  throw new TypeError('controlExecutor must be a function');
120
142
  }
@@ -122,13 +144,18 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
122
144
  && typeof sessionMaintenanceExecutor !== 'function') {
123
145
  throw new TypeError('sessionMaintenanceExecutor must be a function');
124
146
  }
147
+ if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
148
+ throw new TypeError('fileIngressExecutor must be a function');
149
+ }
125
150
 
126
- const agents = controlExecutor && sessionMaintenanceExecutor
151
+ const agents = controlExecutor && sessionMaintenanceExecutor && fileIngressExecutor
127
152
  ? undefined
128
153
  : agentsFromContext(ctx);
129
154
  return {
130
155
  controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined),
131
156
  sessionMaintenanceExecutor: sessionMaintenanceExecutor
132
157
  ?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
158
+ fileIngressExecutor: fileIngressExecutor
159
+ ?? createFileIngressExecutor(agents ?? { get: () => undefined }),
133
160
  };
134
161
  }
@@ -435,6 +435,48 @@ export function createDingtalkApi({
435
435
  return request;
436
436
  }
437
437
 
438
+ async function messageFileDownloadUrl({
439
+ clientId,
440
+ clientSecret,
441
+ robotCode,
442
+ downloadCode,
443
+ signal,
444
+ kind,
445
+ }) {
446
+ const botCode = nonEmptyString(robotCode);
447
+ const fileCode = nonEmptyString(downloadCode);
448
+ if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
449
+ const token = await accessToken({ clientId, clientSecret, signal });
450
+ let response;
451
+ try {
452
+ response = await requestJson(
453
+ fetchImpl,
454
+ endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
455
+ {
456
+ body: { downloadCode: fileCode, robotCode: botCode },
457
+ headers: { 'x-acs-dingtalk-access-token': token },
458
+ signal,
459
+ action: `${kind === 'image' ? '图片' : '文件'}下载地址`,
460
+ },
461
+ );
462
+ } catch (error) {
463
+ if (signal?.aborted) throw error;
464
+ throw new DingtalkApiError(
465
+ `${kind}-download-address-failed`,
466
+ `钉钉${kind === 'image' ? '图片' : '文件'}下载地址获取失败。`,
467
+ { cause: error, status: error?.status, providerCode: error?.providerCode },
468
+ );
469
+ }
470
+ const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
471
+ if (!downloadUrl) {
472
+ throw new DingtalkApiError(
473
+ `invalid-${kind}-download`,
474
+ `钉钉服务没有返回${kind === 'image' ? '图片' : '文件'}下载地址。`,
475
+ );
476
+ }
477
+ return secureDingtalkDownloadUrl(downloadUrl);
478
+ }
479
+
438
480
  function acquireCardRequestSlot(signal) {
439
481
  const acquire = async () => {
440
482
  const waitMs = Math.max(0, nextCardRequestAt - now());
@@ -568,45 +610,69 @@ export function createDingtalkApi({
568
610
  signal,
569
611
  maxBytes,
570
612
  }) {
571
- const botCode = nonEmptyString(robotCode);
572
- const fileCode = nonEmptyString(downloadCode);
573
- if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
574
- const token = await accessToken({ clientId, clientSecret, signal });
575
- let response;
613
+ const downloadUrl = await messageFileDownloadUrl({
614
+ clientId,
615
+ clientSecret,
616
+ robotCode,
617
+ downloadCode,
618
+ signal,
619
+ kind: 'image',
620
+ });
576
621
  try {
577
- response = await requestJson(
622
+ return await fetchImageBuffer(downloadUrl, {
578
623
  fetchImpl,
579
- endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
580
- {
581
- body: { downloadCode: fileCode, robotCode: botCode },
582
- headers: { 'x-acs-dingtalk-access-token': token },
583
- signal,
584
- action: '图片下载地址',
585
- },
586
- );
624
+ signal,
625
+ maxBytes,
626
+ });
587
627
  } catch (error) {
588
- if (signal?.aborted) throw error;
628
+ if (signal?.aborted || error instanceof ImagePromptError) throw error;
589
629
  throw new DingtalkApiError(
590
- 'image-download-address-failed',
591
- '钉钉图片下载地址获取失败。',
592
- { cause: error, status: error?.status, providerCode: error?.providerCode },
630
+ 'image-content-download-failed',
631
+ '钉钉图片内容下载失败。',
632
+ { cause: error },
593
633
  );
594
634
  }
595
- const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
596
- if (!downloadUrl) {
597
- throw new DingtalkApiError('invalid-image-download', '钉钉服务没有返回图片下载地址。');
635
+ },
636
+
637
+ async downloadFile({
638
+ clientId,
639
+ clientSecret,
640
+ robotCode,
641
+ downloadCode,
642
+ signal,
643
+ }) {
644
+ const downloadUrl = await messageFileDownloadUrl({
645
+ clientId,
646
+ clientSecret,
647
+ robotCode,
648
+ downloadCode,
649
+ signal,
650
+ kind: 'file',
651
+ });
652
+ if (downloadUrl.protocol !== 'https:') {
653
+ throw new DingtalkApiError('invalid-file-download', '钉钉服务返回了无效的文件下载地址。');
598
654
  }
599
655
  try {
600
- return await fetchImageBuffer(secureDingtalkDownloadUrl(downloadUrl), {
601
- fetchImpl,
656
+ const response = await fetchImpl(downloadUrl, {
657
+ method: 'GET',
658
+ redirect: 'follow',
602
659
  signal,
603
- maxBytes,
604
660
  });
661
+ if (!response?.ok) {
662
+ await response?.body?.cancel?.().catch?.(() => undefined);
663
+ throw new DingtalkApiError(
664
+ 'file-content-download-failed',
665
+ `钉钉文件内容下载失败(HTTP ${response?.status ?? 'unknown'})。`,
666
+ { status: response?.status },
667
+ );
668
+ }
669
+ return Buffer.from(await response.arrayBuffer());
605
670
  } catch (error) {
606
- if (signal?.aborted || error instanceof ImagePromptError) throw error;
671
+ if (signal?.aborted) throw abortError(signal);
672
+ if (error instanceof DingtalkApiError) throw error;
607
673
  throw new DingtalkApiError(
608
- 'image-content-download-failed',
609
- '钉钉图片内容下载失败。',
674
+ 'file-content-download-failed',
675
+ '钉钉文件内容下载失败。',
610
676
  { cause: error },
611
677
  );
612
678
  }
@@ -29,6 +29,11 @@ import {
29
29
  imagePromptUserMessage,
30
30
  promptContentForMessage,
31
31
  } from '../shared/image-prompt.mjs';
32
+ import {
33
+ hasInboundFiles,
34
+ inboundFileUserMessage,
35
+ prefetchInboundFiles,
36
+ } from '../shared/inbound-file.mjs';
32
37
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
33
38
  import {
34
39
  materializeOutboundArtifact,
@@ -48,7 +53,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
48
53
  const HELP_TEXT = [
49
54
  '钉钉机器人已连接 DeepSeek Harness。',
50
55
  '',
51
- '直接发送文字或图片即可继续当前会话。',
56
+ '直接发送文字、图片或文件即可继续当前会话。',
52
57
  '/new 开启一个全新会话',
53
58
  '/compact 压缩当前会话的较早上下文',
54
59
  '/workspace 工作区绝对路径 切换工作区',
@@ -175,6 +180,7 @@ export function dingtalkInboundMessage(message, {
175
180
  if (code) imageCodes.push(code);
176
181
  }
177
182
  }
183
+ const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
178
184
  return {
179
185
  content: text,
180
186
  images: imageCodes.map((downloadCode, index) => ({
@@ -193,6 +199,21 @@ export function dingtalkInboundMessage(message, {
193
199
  });
194
200
  },
195
201
  })),
202
+ files: fileCode ? [{
203
+ name: nonEmptyString(content?.fileName ?? content?.file_name) ?? 'file',
204
+ load: ({ signal } = {}) => {
205
+ if (typeof api?.downloadFile !== 'function') {
206
+ throw new Error('DingTalk API does not support file downloads');
207
+ }
208
+ return api.downloadFile({
209
+ clientId,
210
+ clientSecret,
211
+ robotCode: message?.robotCode,
212
+ downloadCode: fileCode,
213
+ signal,
214
+ });
215
+ },
216
+ }] : [],
196
217
  };
197
218
  }
198
219
 
@@ -393,7 +414,7 @@ export class DingtalkHarnessBridge {
393
414
  clientSecret: this.#clientSecret,
394
415
  });
395
416
  const commandText = nonEmptyString(promptMessage.content) ?? '';
396
- const commandRunner = isControlCommand(commandText)
417
+ const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
397
418
  ? runControlCommand
398
419
  : (isModelCommand(commandText)
399
420
  ? runModelCommand
@@ -499,10 +520,28 @@ export class DingtalkHarnessBridge {
499
520
  releaseMessageId = true,
500
521
  alreadyRecorded = false,
501
522
  } = {}) {
523
+ let hasSafeReplyRoute = false;
524
+ try {
525
+ normalizeDingtalkSessionWebhook(message.sessionWebhook);
526
+ hasSafeReplyRoute = true;
527
+ } catch {
528
+ // Keep the existing rejection path without downloading an unusable file.
529
+ }
530
+ const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
531
+ const preparedMessage = hasSafeReplyRoute && addressed
532
+ ? prefetchInboundFiles(dingtalkInboundMessage(message, {
533
+ api: this.#api,
534
+ clientId: this.#clientId,
535
+ clientSecret: this.#clientSecret,
536
+ }), { signal: this.#signal })
537
+ : undefined;
502
538
  const previous = this.#queues.get(key) ?? Promise.resolve();
503
539
  const current = previous
504
540
  .catch(() => undefined)
505
- .then(() => this.#process(message, messageId, sender, key, { alreadyRecorded }))
541
+ .then(() => this.#process(message, messageId, sender, key, {
542
+ alreadyRecorded,
543
+ preparedMessage,
544
+ }))
506
545
  .finally(() => {
507
546
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
508
547
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -536,6 +575,7 @@ export class DingtalkHarnessBridge {
536
575
  {
537
576
  signal: this.#signal,
538
577
  hasImages: hasInboundImages(prompt),
578
+ hasFiles: hasInboundFiles(prompt),
539
579
  pendingInteraction: this.#pendingInteractions.has(key)
540
580
  || this.#approvals.hasPending(key),
541
581
  control: { owner: this, key },
@@ -553,7 +593,10 @@ export class DingtalkHarnessBridge {
553
593
  this.#status.lastError = null;
554
594
  }
555
595
 
556
- async #process(message, messageId, sender, key, { alreadyRecorded = false } = {}) {
596
+ async #process(message, messageId, sender, key, {
597
+ alreadyRecorded = false,
598
+ preparedMessage,
599
+ } = {}) {
557
600
  this.#signal?.throwIfAborted();
558
601
  if (!alreadyRecorded) {
559
602
  if (this.#state.hasSeen(messageId)) return;
@@ -577,38 +620,39 @@ export class DingtalkHarnessBridge {
577
620
  return;
578
621
  }
579
622
 
580
- const promptMessage = dingtalkInboundMessage(message, {
623
+ const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
581
624
  api: this.#api,
582
625
  clientId: this.#clientId,
583
626
  clientSecret: this.#clientSecret,
584
627
  });
585
628
  const text = promptMessage.content;
586
629
  const hasImages = hasInboundImages(promptMessage);
630
+ const hasFiles = hasInboundFiles(promptMessage);
587
631
  const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
588
632
  let cardStream = null;
589
633
  let cardStarted = false;
590
634
  try {
591
- if (!text && !hasImages) {
592
- await this.#send(sessionWebhook, '目前支持文字和图片消息。');
635
+ if (!text && !hasImages && !hasFiles) {
636
+ await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
593
637
  return;
594
638
  }
595
639
 
596
640
  const command = text.toLowerCase();
597
- if (isPlainText && !hasImages && command === '/help') {
641
+ if (isPlainText && !hasImages && !hasFiles && command === '/help') {
598
642
  await this.#send(sessionWebhook, HELP_TEXT);
599
643
  return;
600
644
  }
601
- if (isPlainText && !hasImages && command === '/status') {
645
+ if (isPlainText && !hasImages && !hasFiles && command === '/status') {
602
646
  await this.#harness.ensureRunning({ signal: this.#signal });
603
647
  await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
604
648
  return;
605
649
  }
606
- if (isPlainText && !hasImages && command === '/new') {
650
+ if (isPlainText && !hasImages && !hasFiles && command === '/new') {
607
651
  await this.#state.clearSession(key);
608
652
  await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
609
653
  return;
610
654
  }
611
- const workspaceCommand = isPlainText && !hasImages
655
+ const workspaceCommand = isPlainText && !hasImages && !hasFiles
612
656
  ? await runWorkspaceCommand(text, this.#harness, key)
613
657
  : null;
614
658
  if (workspaceCommand) {
@@ -617,7 +661,7 @@ export class DingtalkHarnessBridge {
617
661
  }
618
662
  return;
619
663
  }
620
- const compactCommand = isPlainText && !hasImages
664
+ const compactCommand = isPlainText && !hasImages && !hasFiles
621
665
  ? await runCompactCommand(
622
666
  text,
623
667
  this.#harness,
@@ -668,6 +712,7 @@ export class DingtalkHarnessBridge {
668
712
  requiresMention: String(message.conversationType) === '2',
669
713
  }),
670
714
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
715
+ files: promptMessage.files,
671
716
  },
672
717
  });
673
718
  const answerText = typeof answer === 'string' && answer.trim()
@@ -717,7 +762,9 @@ export class DingtalkHarnessBridge {
717
762
  safeErrorDiagnostic(error),
718
763
  );
719
764
  try {
720
- const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
765
+ const errorText = inboundFileUserMessage(error)
766
+ ?? dingtalkImageErrorUserMessage(error)
767
+ ?? CARD_ERROR_TEXT;
721
768
  const streamed = cardStarted && await cardStream.finish(errorText);
722
769
  if (!streamed) await this.#send(sessionWebhook, errorText);
723
770
  } catch {
@@ -1,4 +1,5 @@
1
1
  import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
+ import { fetchFileStream } from '../shared/file-download.mjs';
2
3
  import { fetchImageBuffer } from '../shared/image-prompt.mjs';
3
4
  import { DiscordApi } from './discord-api.mjs';
4
5
  import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
@@ -88,6 +89,25 @@ function discordImageSource(attachment, fetchImpl) {
88
89
  };
89
90
  }
90
91
 
92
+ function discordFileSource(attachment, fetchImpl) {
93
+ if (attachmentMediaType(attachment) || typeof attachment?.url !== 'string' || !attachment.url) {
94
+ return null;
95
+ }
96
+ const mediaType = typeof attachment.content_type === 'string' && attachment.content_type
97
+ ? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : undefined;
98
+ return {
99
+ name: typeof attachment.filename === 'string' && attachment.filename
100
+ ? attachment.filename : String(attachment.id ?? 'discord-file'),
101
+ ...(mediaType ? { mediaType } : {}),
102
+ size: attachmentSize(attachment.size),
103
+ load: ({ signal } = {}) => fetchFileStream(attachment.url, {
104
+ fetchImpl,
105
+ signal,
106
+ allowedHosts: DISCORD_IMAGE_HOSTS,
107
+ }),
108
+ };
109
+ }
110
+
91
111
  export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
92
112
  if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
93
113
  const direct = !message.guild_id;
@@ -103,6 +123,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
103
123
  images: Array.isArray(message.attachments)
104
124
  ? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
105
125
  : [],
126
+ files: Array.isArray(message.attachments)
127
+ ? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
128
+ : [],
106
129
  addressed,
107
130
  replyTarget: {
108
131
  channelId: String(message.channel_id),