@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.
- package/lib/index.js +166 -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/qq-bridge.mjs +80 -28
- package/src/channels/shared/file-download.mjs +64 -0
- package/src/channels/shared/harness-client.mjs +45 -0
- 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
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
imagePromptUserMessage,
|
|
13
13
|
promptContentForMessage,
|
|
14
14
|
} from '../shared/image-prompt.mjs';
|
|
15
|
+
import {
|
|
16
|
+
hasInboundFiles,
|
|
17
|
+
inboundFileUserMessage,
|
|
18
|
+
} from '../shared/inbound-file.mjs';
|
|
15
19
|
import {
|
|
16
20
|
harnessAnswerForQuestion,
|
|
17
21
|
harnessQuestionText,
|
|
@@ -91,7 +95,7 @@ const REPAIR_URL_HOSTS = new Set([
|
|
|
91
95
|
const HELP_TEXT = [
|
|
92
96
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
|
93
97
|
'',
|
|
94
|
-
'
|
|
98
|
+
'直接发送文字、图片或文件即可继续当前会话。',
|
|
95
99
|
'/new 开启一个全新会话',
|
|
96
100
|
'/compact 压缩当前会话的较早上下文',
|
|
97
101
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -431,7 +435,7 @@ export class FeishuHarnessBridge {
|
|
|
431
435
|
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
|
432
436
|
const commandMessage = extractInboundMessage(event, this.#client);
|
|
433
437
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
|
434
|
-
const commandRunner = isControlCommand(commandText)
|
|
438
|
+
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
|
435
439
|
? runControlCommand
|
|
436
440
|
: (isModelCommand(commandText)
|
|
437
441
|
? runModelCommand
|
|
@@ -610,7 +614,8 @@ export class FeishuHarnessBridge {
|
|
|
610
614
|
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
|
611
615
|
await this.#send(
|
|
612
616
|
event.message.chat_id,
|
|
613
|
-
|
|
617
|
+
inboundFileUserMessage(error)
|
|
618
|
+
?? imagePromptUserMessage(error)
|
|
614
619
|
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
|
|
615
620
|
).catch(() => undefined);
|
|
616
621
|
}
|
|
@@ -641,6 +646,7 @@ export class FeishuHarnessBridge {
|
|
|
641
646
|
{
|
|
642
647
|
signal: this.#signal,
|
|
643
648
|
hasImages: hasInboundImages(message),
|
|
649
|
+
hasFiles: hasInboundFiles(message),
|
|
644
650
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
645
651
|
|| this.#approvals.hasPending(key),
|
|
646
652
|
control: { owner: this, key },
|
|
@@ -671,9 +677,10 @@ export class FeishuHarnessBridge {
|
|
|
671
677
|
const message = extractInboundMessage(event, this.#client);
|
|
672
678
|
const text = message.content;
|
|
673
679
|
const hasImages = hasInboundImages(message);
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
680
|
+
const hasFiles = hasInboundFiles(message);
|
|
681
|
+
const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
|
|
682
|
+
if (!text && !hasImages && !hasFiles) {
|
|
683
|
+
await this.#send(event.message.chat_id, '目前支持文字、图片和文件消息。');
|
|
677
684
|
return;
|
|
678
685
|
}
|
|
679
686
|
|
|
@@ -1603,7 +1610,7 @@ export class FeishuHarnessBridge {
|
|
|
1603
1610
|
}
|
|
1604
1611
|
}
|
|
1605
1612
|
|
|
1606
|
-
#interactionAskOptions(event, key) {
|
|
1613
|
+
#interactionAskOptions(event, key, files) {
|
|
1607
1614
|
return {
|
|
1608
1615
|
timeoutMs: this.#replyTimeoutMs,
|
|
1609
1616
|
signal: this.#signal,
|
|
@@ -1615,6 +1622,7 @@ export class FeishuHarnessBridge {
|
|
|
1615
1622
|
requiresMention: event.message.chat_type !== 'p2p',
|
|
1616
1623
|
}),
|
|
1617
1624
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
1625
|
+
files,
|
|
1618
1626
|
};
|
|
1619
1627
|
}
|
|
1620
1628
|
|
|
@@ -1709,7 +1717,7 @@ export class FeishuHarnessBridge {
|
|
|
1709
1717
|
content,
|
|
1710
1718
|
createOptions: { signal: this.#signal },
|
|
1711
1719
|
existsOptions: { signal: this.#signal },
|
|
1712
|
-
askOptions: this.#interactionAskOptions(event, key),
|
|
1720
|
+
askOptions: this.#interactionAskOptions(event, key, message.files),
|
|
1713
1721
|
});
|
|
1714
1722
|
let textReceipt;
|
|
1715
1723
|
let textSendError = null;
|
|
@@ -1749,7 +1757,7 @@ export class FeishuHarnessBridge {
|
|
|
1749
1757
|
markdown: async (controller) => {
|
|
1750
1758
|
promptStarted = true;
|
|
1751
1759
|
const askOptions = {
|
|
1752
|
-
...this.#interactionAskOptions(event, key),
|
|
1760
|
+
...this.#interactionAskOptions(event, key, message.files),
|
|
1753
1761
|
onUpdate: async (update) => {
|
|
1754
1762
|
await controller.setContent(this.#progressText(update));
|
|
1755
1763
|
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
|
@@ -1821,7 +1829,7 @@ export class FeishuHarnessBridge {
|
|
|
1821
1829
|
content,
|
|
1822
1830
|
createOptions: { signal: this.#signal },
|
|
1823
1831
|
existsOptions: { signal: this.#signal },
|
|
1824
|
-
askOptions: this.#interactionAskOptions(event, key),
|
|
1832
|
+
askOptions: this.#interactionAskOptions(event, key, message.files),
|
|
1825
1833
|
});
|
|
1826
1834
|
let textReceipt;
|
|
1827
1835
|
let textSendError = null;
|
|
@@ -125,6 +125,31 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
async function readStream(stream, { signal }) {
|
|
129
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
130
|
+
throw new Error('Feishu file download returned no readable stream');
|
|
131
|
+
}
|
|
132
|
+
signal?.throwIfAborted();
|
|
133
|
+
const abort = () => stream.destroy?.(
|
|
134
|
+
signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
|
|
135
|
+
);
|
|
136
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
137
|
+
const chunks = [];
|
|
138
|
+
let size = 0;
|
|
139
|
+
try {
|
|
140
|
+
for await (const chunk of stream) {
|
|
141
|
+
signal?.throwIfAborted();
|
|
142
|
+
const data = Buffer.from(chunk);
|
|
143
|
+
size += data.length;
|
|
144
|
+
chunks.push(data);
|
|
145
|
+
}
|
|
146
|
+
signal?.throwIfAborted();
|
|
147
|
+
return Buffer.concat(chunks, size);
|
|
148
|
+
} finally {
|
|
149
|
+
signal?.removeEventListener('abort', abort);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
128
153
|
function providerCode(value) {
|
|
129
154
|
if (!value || typeof value !== 'object') return null;
|
|
130
155
|
const code = value.code ?? value.error?.code;
|
|
@@ -232,6 +257,26 @@ function feishuImageSource(event, client, key) {
|
|
|
232
257
|
};
|
|
233
258
|
}
|
|
234
259
|
|
|
260
|
+
function feishuFileSource(event, client, file) {
|
|
261
|
+
const key = nonEmptyString(file?.file_key);
|
|
262
|
+
if (!key) return null;
|
|
263
|
+
return {
|
|
264
|
+
name: nonEmptyString(file?.file_name) ?? 'file',
|
|
265
|
+
async load({ signal } = {}) {
|
|
266
|
+
signal?.throwIfAborted();
|
|
267
|
+
const resource = await client?.im?.v1?.messageResource?.get?.({
|
|
268
|
+
path: {
|
|
269
|
+
message_id: event.message.message_id,
|
|
270
|
+
file_key: key,
|
|
271
|
+
},
|
|
272
|
+
params: { type: 'file' },
|
|
273
|
+
});
|
|
274
|
+
signal?.throwIfAborted();
|
|
275
|
+
return readStream(resource?.getReadableStream?.(), { signal });
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
235
280
|
export function extractInboundMessage(event, client) {
|
|
236
281
|
const messageType = event?.message?.message_type;
|
|
237
282
|
const parsed = parsedMessageContent(event);
|
|
@@ -240,9 +285,11 @@ export function extractInboundMessage(event, client) {
|
|
|
240
285
|
? nonEmptyString(parsed?.image_key)
|
|
241
286
|
: null;
|
|
242
287
|
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
|
|
288
|
+
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
|
|
243
289
|
return {
|
|
244
290
|
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
|
|
245
291
|
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
|
292
|
+
files: file ? [file] : [],
|
|
246
293
|
};
|
|
247
294
|
}
|
|
248
295
|
|
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
118
|
-
if (!url) throw new Error('QQ
|
|
119
|
-
return
|
|
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 =
|
|
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
|
-
|
|
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);
|