@xmanrui/dsh-im 1.2.0 → 1.4.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 (33) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/lib/index.js +176 -163
  4. package/package.json +1 -1
  5. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  6. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  7. package/plugin-src/host/channels/qq/production.mjs +3 -1
  8. package/plugin-src/host/channels/shared/production.mjs +3 -1
  9. package/plugin-src/host/channels/slack/production.mjs +3 -1
  10. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  11. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  12. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  13. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  14. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  15. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  16. package/src/channels/discord/discord-runtime.mjs +23 -0
  17. package/src/channels/feishu/bridge.mjs +18 -10
  18. package/src/channels/feishu/message-utils.mjs +47 -0
  19. package/src/channels/qq/markdown-reply.mjs +176 -0
  20. package/src/channels/qq/qq-bridge.mjs +124 -55
  21. package/src/channels/shared/file-download.mjs +64 -0
  22. package/src/channels/shared/harness-client.mjs +111 -13
  23. package/src/channels/shared/inbound-file.mjs +206 -0
  24. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  25. package/src/channels/slack/slack-api.mjs +27 -4
  26. package/src/channels/slack/slack-runtime.mjs +55 -5
  27. package/src/channels/telegram/telegram-api.mjs +21 -6
  28. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  29. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  30. package/src/channels/weixin/weixin-api.mjs +45 -0
  31. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  32. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
  33. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
@@ -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
@@ -251,6 +255,21 @@ function assistantMessageText(event) {
251
255
  .trim();
252
256
  }
253
257
 
258
+ function nonEmptyText(value) {
259
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
260
+ }
261
+
262
+ /** Flatten a tool/result error payload into a displayable one-line reason. */
263
+ function toolResultErrorText(error) {
264
+ if (!error || typeof error !== 'object') return null;
265
+ const message = nonEmptyText(error.message);
266
+ if (message) return message;
267
+ const name = nonEmptyText(error.name);
268
+ const code = nonEmptyText(error.code);
269
+ if (name || code) return [name ?? 'Error', code].filter(Boolean).join(': ');
270
+ return null;
271
+ }
272
+
254
273
  function consumeInteractionOwnership(ownership, entries) {
255
274
  const ordered = [...entries]
256
275
  .map((entry) => entry?.event ?? entry)
@@ -321,6 +340,8 @@ export class HarnessReplyTracker {
321
340
  #latestText = '';
322
341
  #finished = false;
323
342
  #reason = null;
343
+ #toolNames = new Map();
344
+ #lastToolName = null;
324
345
 
325
346
  constructor({ promptRpcId, afterSeq = -1 }) {
326
347
  this.#promptRpcId = promptRpcId;
@@ -347,8 +368,20 @@ export class HarnessReplyTracker {
347
368
  return this.#targetTurn;
348
369
  }
349
370
 
350
- consume(entries) {
351
- let update = null;
371
+ consumeAll(entries) {
372
+ const updates = [];
373
+ // 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
374
+ // 让消费方能按顺序看到每个工具调用与结果。
375
+ const pushUpdate = (update) => {
376
+ if (update.type === 'text' && updates.length > 0) {
377
+ const last = updates[updates.length - 1];
378
+ if (last.type === 'text') {
379
+ updates[updates.length - 1] = update;
380
+ return;
381
+ }
382
+ }
383
+ updates.push(update);
384
+ };
352
385
  const ordered = [...entries]
353
386
  .map((entry) => entry?.event ?? entry)
354
387
  .filter(Boolean)
@@ -390,7 +423,7 @@ export class HarnessReplyTracker {
390
423
  .trim();
391
424
  if (text && text !== this.#latestText) {
392
425
  this.#latestText = text;
393
- update = { type: 'text', text };
426
+ pushUpdate({ type: 'text', text });
394
427
  }
395
428
  continue;
396
429
  }
@@ -399,18 +432,38 @@ export class HarnessReplyTracker {
399
432
  const text = assistantMessageText(event);
400
433
  if (text && text !== this.#latestText) {
401
434
  this.#latestText = text;
402
- update = { type: 'text', text };
435
+ pushUpdate({ type: 'text', text });
403
436
  }
404
437
  continue;
405
438
  }
406
439
 
407
440
  if (event.type === 'tool/call') {
408
- update = { type: 'tool', name: event.data?.name ?? '工具' };
441
+ const name = nonEmptyText(event.data?.name) ?? '工具';
442
+ const callId = nonEmptyText(event.data?.callId)
443
+ ?? nonEmptyText(event.data?.subCallId);
444
+ if (callId) this.#toolNames.set(callId, name);
445
+ this.#lastToolName = name;
446
+ pushUpdate({ type: 'tool', name, ...(callId ? { callId } : {}) });
409
447
  } else if (event.type === 'tool/result') {
410
- update = { type: 'status', text: '正在整理结果…' };
448
+ const callId = nonEmptyText(event.data?.message?.source?.callId)
449
+ ?? nonEmptyText(event.data?.callId)
450
+ ?? nonEmptyText(event.data?.subCallId);
451
+ const toolName = (callId ? this.#toolNames.get(callId) : null)
452
+ ?? this.#lastToolName;
453
+ const error = toolResultErrorText(event.data?.error);
454
+ pushUpdate({
455
+ type: 'status',
456
+ text: '正在整理结果…',
457
+ ...(toolName ? { toolName } : {}),
458
+ ...(error ? { error } : {}),
459
+ });
411
460
  }
412
461
  }
413
- return update;
462
+ return updates;
463
+ }
464
+
465
+ consume(entries) {
466
+ return this.consumeAll(entries).at(-1) ?? null;
414
467
  }
415
468
  }
416
469
 
@@ -466,6 +519,7 @@ export class HarnessClient {
466
519
  #commandExecutor;
467
520
  #controlExecutor;
468
521
  #sessionMaintenanceExecutor;
522
+ #fileIngressExecutor;
469
523
  #managedProcess = null;
470
524
  #interactionRegistry;
471
525
  #interactionOwnerships;
@@ -486,6 +540,7 @@ export class HarnessClient {
486
540
  commandExecutor,
487
541
  controlExecutor,
488
542
  sessionMaintenanceExecutor,
543
+ fileIngressExecutor,
489
544
  }) {
490
545
  if (typeof createWebSocket !== 'function') {
491
546
  throw new TypeError('createWebSocket must be a function');
@@ -509,6 +564,9 @@ export class HarnessClient {
509
564
  && typeof sessionMaintenanceExecutor !== 'function') {
510
565
  throw new TypeError('sessionMaintenanceExecutor must be a function');
511
566
  }
567
+ if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
568
+ throw new TypeError('fileIngressExecutor must be a function');
569
+ }
512
570
  this.#baseUrl = new URL(baseUrl);
513
571
  this.#workspace = workspace;
514
572
  // Keep an omitted preset absent so session.create resolves the Host's current default.
@@ -523,6 +581,7 @@ export class HarnessClient {
523
581
  this.#commandExecutor = commandExecutor;
524
582
  this.#controlExecutor = controlExecutor;
525
583
  this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
584
+ this.#fileIngressExecutor = fileIngressExecutor;
526
585
  this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
527
586
  this.#interactionOwnerships = this.#interactionRegistry.ownerships;
528
587
  this.#interactionClaims = this.#interactionRegistry.claims;
@@ -1026,6 +1085,7 @@ export class HarnessClient {
1026
1085
  const timeoutMs = options.timeoutMs ?? 600_000;
1027
1086
  const signal = options.signal;
1028
1087
  const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
1088
+ const progressMode = options.progressMode === 'all' ? 'all' : 'latest';
1029
1089
  const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
1030
1090
  const onInteraction = typeof options.onInteraction === 'function'
1031
1091
  ? options.onInteraction
@@ -1034,6 +1094,7 @@ export class HarnessClient {
1034
1094
  ? options.onInteractionResolved
1035
1095
  : undefined;
1036
1096
  const control = normalizeControl(options.control);
1097
+ const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
1037
1098
  await this.ensureRunning({ signal });
1038
1099
  const before = await this.rpc(
1039
1100
  'session.history',
@@ -1075,6 +1136,9 @@ export class HarnessClient {
1075
1136
  let interactionTask = null;
1076
1137
  let artifactsDelivered = false;
1077
1138
  let deliveredArtifactCount = 0;
1139
+ let stagedInboundFiles = null;
1140
+ let promptAccepted = false;
1141
+ let turnFinished = false;
1078
1142
 
1079
1143
  const deliverArtifacts = async () => {
1080
1144
  if (!onArtifact || artifactsDelivered || tracker.turn === null) {
@@ -1103,6 +1167,30 @@ export class HarnessClient {
1103
1167
  const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
1104
1168
 
1105
1169
  try {
1170
+ if (inboundFiles.length > 0) {
1171
+ if (!this.#fileIngressExecutor) {
1172
+ throw new InboundFileError(
1173
+ 'inbound-file-ingress-unavailable',
1174
+ 'Harness file ingress is unavailable in this Host process.',
1175
+ );
1176
+ }
1177
+ const sessionList = await this.rpc(
1178
+ 'session.list',
1179
+ {},
1180
+ 30_000,
1181
+ { signal },
1182
+ );
1183
+ const sessionWorkspace = sessionList?.items?.find(
1184
+ (item) => item?.sessionId === sessionId,
1185
+ )?.cwd;
1186
+ stagedInboundFiles = await this.#fileIngressExecutor({
1187
+ sessionId,
1188
+ workspace: sessionWorkspace,
1189
+ files: inboundFiles,
1190
+ signal,
1191
+ });
1192
+ prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
1193
+ }
1106
1194
  if (interactionSignal) {
1107
1195
  let markOpen;
1108
1196
  const opened = new Promise((resolve) => { markOpen = resolve; });
@@ -1134,6 +1222,7 @@ export class HarnessClient {
1134
1222
  content,
1135
1223
  clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1136
1224
  }, 30_000, { rpcId: promptRpcId, signal });
1225
+ promptAccepted = true;
1137
1226
 
1138
1227
  try {
1139
1228
  const deadline = Date.now() + timeoutMs;
@@ -1150,15 +1239,19 @@ export class HarnessClient {
1150
1239
  this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
1151
1240
  if (!wasActive && ownership.active) ownership.reconnect?.();
1152
1241
  }
1153
- const update = tracker.consume(history.events ?? []);
1154
- if (update && onUpdate) {
1155
- try {
1156
- await onUpdate(update);
1157
- } catch (error) {
1158
- console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
1242
+ const updates = tracker.consumeAll(history.events ?? []);
1243
+ if (onUpdate) {
1244
+ const visibleUpdates = progressMode === 'all' ? updates : updates.slice(-1);
1245
+ for (const update of visibleUpdates) {
1246
+ try {
1247
+ await onUpdate(update);
1248
+ } catch (error) {
1249
+ console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
1250
+ }
1159
1251
  }
1160
1252
  }
1161
1253
  if (!tracker.finished) continue;
1254
+ turnFinished = true;
1162
1255
  // An accepted /stop revokes attachment delivery even when Harness
1163
1256
  // preserved a useful partial text answer for the existing UX.
1164
1257
  const artifactCount = ownership?.stopRequested
@@ -1185,6 +1278,11 @@ export class HarnessClient {
1185
1278
  throw turnStoppedError();
1186
1279
  }
1187
1280
  } finally {
1281
+ if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
1282
+ await stagedInboundFiles.cleanup().catch((error) => {
1283
+ console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
1284
+ });
1285
+ }
1188
1286
  closeArtifactConsumer();
1189
1287
  if (ownership) {
1190
1288
  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
+ }
@@ -23,6 +23,10 @@ import {
23
23
  imagePromptUserMessage,
24
24
  promptContentForMessage,
25
25
  } from './image-prompt.mjs';
26
+ import {
27
+ hasInboundFiles,
28
+ inboundFileUserMessage,
29
+ } from './inbound-file.mjs';
26
30
  import {
27
31
  harnessAnswerForQuestion,
28
32
  harnessQuestionText,
@@ -50,6 +54,7 @@ function canClaimInteractionReply(message, pending, senderId) {
50
54
  return pending.actor === senderId
51
55
  && (message.kind !== 'group' || message.addressed === true)
52
56
  && !hasInboundImages(message)
57
+ && !hasInboundFiles(message)
53
58
  && Boolean(cleanText(message.content));
54
59
  }
55
60
 
@@ -171,7 +176,7 @@ export class TextHarnessBridge {
171
176
  const key = `${kind}:${conversationId}`;
172
177
  const pending = this.#pendingInteractions.get(key);
173
178
  const text = cleanText(normalized.content);
174
- const commandRunner = isControlCommand(text)
179
+ const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
175
180
  ? runControlCommand
176
181
  : (isModelCommand(text)
177
182
  ? runModelCommand
@@ -194,7 +199,7 @@ export class TextHarnessBridge {
194
199
  key,
195
200
  actor: senderId,
196
201
  messageId,
197
- text: hasInboundImages(normalized) ? '' : normalized.content,
202
+ text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
198
203
  addressed: normalized.kind !== 'group' || normalized.addressed === true,
199
204
  hasPendingQuestion: Boolean(pending),
200
205
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -300,6 +305,7 @@ export class TextHarnessBridge {
300
305
  {
301
306
  signal: this.#signal,
302
307
  hasImages: hasInboundImages(message),
308
+ hasFiles: hasInboundFiles(message),
303
309
  pendingInteraction: this.#pendingInteractions.has(key)
304
310
  || this.#approvals.hasPending(key),
305
311
  control: { owner: this, key },
@@ -423,16 +429,17 @@ export class TextHarnessBridge {
423
429
  return;
424
430
  }
425
431
  const hasImages = hasInboundImages(message);
426
- if (!text && !hasImages) {
427
- await this.#bot.sendText(target, '目前支持文字和图片消息。');
432
+ const hasFiles = hasInboundFiles(message);
433
+ if (!text && !hasImages && !hasFiles) {
434
+ await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
428
435
  return;
429
436
  }
430
437
  const command = text.toLowerCase();
431
- if (!hasImages && command === '/help') {
438
+ if (!hasImages && !hasFiles && command === '/help') {
432
439
  await this.#bot.sendText(target, [
433
440
  `${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
434
441
  '',
435
- '直接发送文字或图片即可继续当前会话。',
442
+ '直接发送文字、图片或文件即可继续当前会话。',
436
443
  '/new 开启一个全新会话',
437
444
  '/compact 压缩当前会话的较早上下文',
438
445
  '/workspace 工作区绝对路径 切换工作区',
@@ -453,12 +460,12 @@ export class TextHarnessBridge {
453
460
  ].join('\n'));
454
461
  return;
455
462
  }
456
- if (!hasImages && command === '/status') {
463
+ if (!hasImages && !hasFiles && command === '/status') {
457
464
  await this.#harness.ensureRunning({ signal: this.#signal });
458
465
  await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
459
466
  return;
460
467
  }
461
- const workspaceCommand = !hasImages
468
+ const workspaceCommand = !hasImages && !hasFiles
462
469
  ? await runWorkspaceCommand(text, this.#harness, conversationKey)
463
470
  : null;
464
471
  if (workspaceCommand) {
@@ -467,12 +474,12 @@ export class TextHarnessBridge {
467
474
  }
468
475
  return;
469
476
  }
470
- if (!hasImages && command === '/new') {
477
+ if (!hasImages && !hasFiles && command === '/new') {
471
478
  await this.#state.clearSession(conversationKey);
472
479
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
473
480
  return;
474
481
  }
475
- const compactCommand = !hasImages
482
+ const compactCommand = !hasImages && !hasFiles
476
483
  ? await runCompactCommand(
477
484
  text,
478
485
  this.#harness,
@@ -527,6 +534,7 @@ export class TextHarnessBridge {
527
534
  requiresMention: message.kind === 'group',
528
535
  }),
529
536
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
537
+ files: message.files,
530
538
  },
531
539
  });
532
540
  const visibleAnswer = !cleanText(answer) && artifacts.length > 0
@@ -600,6 +608,18 @@ export class TextHarnessBridge {
600
608
  }
601
609
  return;
602
610
  }
611
+ const fileErrorMessage = inboundFileUserMessage(error);
612
+ if (fileErrorMessage) {
613
+ try {
614
+ await this.#bot.sendText(target, fileErrorMessage);
615
+ } catch (sendError) {
616
+ this.#logger.error?.(
617
+ `[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
618
+ sendError,
619
+ );
620
+ }
621
+ return;
622
+ }
603
623
  this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
604
624
  try {
605
625
  await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
@@ -642,7 +662,7 @@ export class TextHarnessBridge {
642
662
 
643
663
  const target = message.replyTarget;
644
664
  const text = cleanText(message.content);
645
- if (!text || hasInboundImages(message)) {
665
+ if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
646
666
  try {
647
667
  await this.#bot.sendText(target, '请用文字回答当前问题。');
648
668
  } catch (error) {
@@ -1,3 +1,4 @@
1
+ import { fetchFileStream } from '../shared/file-download.mjs';
1
2
  import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
2
3
 
3
4
  const DEFAULT_BASE_URL = 'https://slack.com/api/';
@@ -232,6 +233,18 @@ export class SlackApi {
232
233
  });
233
234
  }
234
235
 
236
+ async fileInfo({ fileId, signal } = {}) {
237
+ const value = await this.#request('files.info', {
238
+ tokenKind: 'bot',
239
+ signal,
240
+ body: { file: slackId(fileId, 'file id') },
241
+ });
242
+ if (!value?.file || typeof value.file !== 'object' || Array.isArray(value.file)) {
243
+ throw new Error('Slack files.info returned no file object');
244
+ }
245
+ return value.file;
246
+ }
247
+
235
248
  postMessage({ channelId, text, threadTs, signal }) {
236
249
  return this.#request('chat.postMessage', {
237
250
  tokenKind: 'bot',
@@ -396,6 +409,14 @@ export class SlackApi {
396
409
  }
397
410
 
398
411
  async downloadFile({ url, signal, maxBytes }) {
412
+ return this.#downloadFile({ url, signal, maxBytes, stream: false });
413
+ }
414
+
415
+ async downloadFileStream({ url, signal }) {
416
+ return this.#downloadFile({ url, signal, stream: true });
417
+ }
418
+
419
+ async #downloadFile({ url, signal, maxBytes, stream }) {
399
420
  if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
400
421
  const target = secureSlackFileUrl(url);
401
422
  const fetchSlackFile = async (requestUrl, options) => {
@@ -414,13 +435,15 @@ export class SlackApi {
414
435
  }
415
436
  return response;
416
437
  };
417
- return fetchImageBuffer(target, {
438
+ const options = {
418
439
  fetchImpl: fetchSlackFile,
419
440
  headers: { authorization: `Bearer ${this.#botToken}` },
420
441
  signal,
421
- maxBytes,
422
442
  allowedHosts: SLACK_FILE_HOSTS,
423
- });
443
+ };
444
+ return stream
445
+ ? fetchFileStream(target, options)
446
+ : fetchImageBuffer(target, { ...options, maxBytes });
424
447
  }
425
448
 
426
449
  async #request(method, {
@@ -432,7 +455,7 @@ export class SlackApi {
432
455
  }) {
433
456
  const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
434
457
  if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
435
- const formEncoded = method === 'files.getUploadURLExternal';
458
+ const formEncoded = method === 'files.getUploadURLExternal' || method === 'files.info';
436
459
  let response;
437
460
  try {
438
461
  response = await this.#fetch(new URL(method, this.#baseUrl), {