@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.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/index.js +176 -163
- package/package.json +1 -1
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
- package/plugin-src/host/channels/feishu/production.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +3 -1
- package/plugin-src/host/channels/shared/production.mjs +3 -1
- package/plugin-src/host/channels/slack/production.mjs +3 -1
- package/plugin-src/host/channels/wecom/production.mjs +3 -1
- package/plugin-src/host/channels/weixin/production.mjs +3 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
- package/plugin-src/host/harness-session-coordinator.mjs +32 -5
- package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
- package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
- package/src/channels/discord/discord-runtime.mjs +23 -0
- package/src/channels/feishu/bridge.mjs +18 -10
- package/src/channels/feishu/message-utils.mjs +47 -0
- package/src/channels/qq/markdown-reply.mjs +176 -0
- package/src/channels/qq/qq-bridge.mjs +124 -55
- package/src/channels/shared/file-download.mjs +64 -0
- package/src/channels/shared/harness-client.mjs +111 -13
- package/src/channels/shared/inbound-file.mjs +206 -0
- package/src/channels/shared/text-harness-bridge.mjs +31 -11
- package/src/channels/slack/slack-api.mjs +27 -4
- package/src/channels/slack/slack-runtime.mjs +55 -5
- package/src/channels/telegram/telegram-api.mjs +21 -6
- package/src/channels/telegram/telegram-runtime.mjs +24 -3
- package/src/channels/wecom/wecom-bridge.mjs +73 -10
- package/src/channels/weixin/weixin-api.mjs +45 -0
- package/src/channels/weixin/weixin-bridge.mjs +52 -18
- package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
- 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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
+
pushUpdate({ type: 'text', text });
|
|
403
436
|
}
|
|
404
437
|
continue;
|
|
405
438
|
}
|
|
406
439
|
|
|
407
440
|
if (event.type === 'tool/call') {
|
|
408
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1154
|
-
if (
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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), {
|