@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
|
@@ -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,
|
|
@@ -37,6 +42,7 @@ import {
|
|
|
37
42
|
mergeDeliveryReceipts,
|
|
38
43
|
providerMessageIdsFor,
|
|
39
44
|
} from '../shared/semantic/delivery.mjs';
|
|
45
|
+
import { sendMarkdownReply } from './markdown-reply.mjs';
|
|
40
46
|
|
|
41
47
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
42
48
|
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
@@ -55,7 +61,7 @@ const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
|
|
|
55
61
|
const HELP_TEXT = [
|
|
56
62
|
'QQ 机器人已连接 DeepSeek Harness。',
|
|
57
63
|
'',
|
|
58
|
-
'
|
|
64
|
+
'直接发送文字、图片或文件即可继续当前会话。',
|
|
59
65
|
'/new 开启一个全新会话',
|
|
60
66
|
'/compact 压缩当前会话的较早上下文',
|
|
61
67
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -100,32 +106,63 @@ function hasQqImageAttachments(message) {
|
|
|
100
106
|
&& message.attachments.some(isQqImageAttachment);
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
function hasQqFileAttachments(message) {
|
|
110
|
+
return Array.isArray(message?.attachments)
|
|
111
|
+
&& message.attachments.some((attachment) => !isQqImageAttachment(attachment));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
|
|
115
|
+
const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
|
|
116
|
+
const response = await fetchImpl(new URL(normalizedUrl), {
|
|
117
|
+
method: 'GET',
|
|
118
|
+
signal,
|
|
119
|
+
redirect: 'follow',
|
|
120
|
+
});
|
|
121
|
+
if (!response?.ok) {
|
|
122
|
+
await response?.body?.cancel?.().catch?.(() => undefined);
|
|
123
|
+
throw new Error(`QQ file download failed with HTTP ${response?.status ?? 'unknown'}`);
|
|
124
|
+
}
|
|
125
|
+
return Buffer.from(await response.arrayBuffer());
|
|
126
|
+
}
|
|
127
|
+
|
|
103
128
|
/** Convert QQ's attachment metadata into lazily downloaded image references. */
|
|
104
129
|
export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
|
|
105
130
|
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
106
131
|
const images = [];
|
|
132
|
+
const files = [];
|
|
107
133
|
for (const attachment of message?.attachments ?? []) {
|
|
108
|
-
if (!isQqImageAttachment(attachment)) continue;
|
|
109
134
|
const url = nonEmptyString(attachment?.url);
|
|
110
135
|
const name = nonEmptyString(attachment?.filename) ?? undefined;
|
|
111
136
|
const mediaType = attachmentMediaType(attachment);
|
|
112
137
|
const declaredSize = Number(attachment?.size);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
138
|
+
if (isQqImageAttachment(attachment)) {
|
|
139
|
+
images.push({
|
|
140
|
+
...(name ? { name } : {}),
|
|
141
|
+
...(mediaType?.startsWith('image/') ? { mediaType } : {}),
|
|
142
|
+
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
|
143
|
+
load: ({ signal, maxBytes }) => {
|
|
144
|
+
if (!url) throw new Error('QQ image attachment has no download URL');
|
|
145
|
+
return fetchImageBuffer(url, {
|
|
146
|
+
fetchImpl,
|
|
147
|
+
signal,
|
|
148
|
+
maxBytes,
|
|
149
|
+
allowedHosts: QQ_IMAGE_HOSTS,
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
files.push({
|
|
156
|
+
name: name ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
|
|
157
|
+
...(mediaType?.includes('/') ? { mediaType } : {}),
|
|
116
158
|
...(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
|
-
});
|
|
159
|
+
load: ({ signal } = {}) => {
|
|
160
|
+
if (!url) throw new Error('QQ file attachment has no download URL');
|
|
161
|
+
return fetchQqFileBuffer(url, { fetchImpl, signal });
|
|
125
162
|
},
|
|
126
163
|
});
|
|
127
164
|
}
|
|
128
|
-
return { content: safeText(message), images };
|
|
165
|
+
return { content: safeText(message), images, files };
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
function nonEmptyString(value) {
|
|
@@ -213,6 +250,7 @@ function canClaimInteractionReply(message, pending) {
|
|
|
213
250
|
&& nonEmptyString(message?.senderId) === pending.actor
|
|
214
251
|
&& (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
|
|
215
252
|
&& !hasQqImageAttachments(message)
|
|
253
|
+
&& !hasQqFileAttachments(message)
|
|
216
254
|
&& nonEmptyString(safeText(message));
|
|
217
255
|
}
|
|
218
256
|
|
|
@@ -303,7 +341,7 @@ export class QqHarnessBridge {
|
|
|
303
341
|
}
|
|
304
342
|
const pending = this.#pendingInteractions.get(key);
|
|
305
343
|
const commandText = safeText(message);
|
|
306
|
-
const commandRunner = isControlCommand(commandText)
|
|
344
|
+
const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
|
|
307
345
|
? runControlCommand
|
|
308
346
|
: (isModelCommand(commandText)
|
|
309
347
|
? runModelCommand
|
|
@@ -336,7 +374,7 @@ export class QqHarnessBridge {
|
|
|
336
374
|
key,
|
|
337
375
|
actor: sender,
|
|
338
376
|
messageId,
|
|
339
|
-
text: hasQqImageAttachments(message) ? '' : safeText(message),
|
|
377
|
+
text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
|
|
340
378
|
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
|
|
341
379
|
hasPendingQuestion: Boolean(pending),
|
|
342
380
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -391,10 +429,19 @@ export class QqHarnessBridge {
|
|
|
391
429
|
releaseMessageId = true,
|
|
392
430
|
alreadyRecorded = false,
|
|
393
431
|
} = {}) {
|
|
432
|
+
const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
|
|
433
|
+
const addressed = message.kind !== 'group'
|
|
434
|
+
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
435
|
+
const preparedMessage = allowed && addressed
|
|
436
|
+
? prefetchInboundFiles(
|
|
437
|
+
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
|
|
438
|
+
{ signal: this.#signal },
|
|
439
|
+
)
|
|
440
|
+
: undefined;
|
|
394
441
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
395
442
|
const current = previous
|
|
396
443
|
.catch(() => undefined)
|
|
397
|
-
.then(() => this.#process(message, key, { alreadyRecorded }))
|
|
444
|
+
.then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
|
|
398
445
|
.finally(() => {
|
|
399
446
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
400
447
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
@@ -423,6 +470,7 @@ export class QqHarnessBridge {
|
|
|
423
470
|
const result = await runner(text, this.#harness, this.#state, key, {
|
|
424
471
|
signal: this.#signal,
|
|
425
472
|
hasImages: hasQqImageAttachments(message),
|
|
473
|
+
hasFiles: hasQqFileAttachments(message),
|
|
426
474
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
427
475
|
|| this.#approvals.hasPending(key),
|
|
428
476
|
control: { owner: this, key },
|
|
@@ -520,7 +568,7 @@ export class QqHarnessBridge {
|
|
|
520
568
|
};
|
|
521
569
|
}
|
|
522
570
|
|
|
523
|
-
async #process(message, key, { alreadyRecorded = false } = {}) {
|
|
571
|
+
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
|
|
524
572
|
if (this.#signal?.aborted) return;
|
|
525
573
|
const messageId = nonEmptyString(message?.messageId);
|
|
526
574
|
const sender = nonEmptyString(message?.senderId);
|
|
@@ -539,35 +587,37 @@ export class QqHarnessBridge {
|
|
|
539
587
|
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
|
540
588
|
|
|
541
589
|
const target = message.replyTarget;
|
|
542
|
-
const promptMessage =
|
|
590
|
+
const promptMessage = preparedMessage
|
|
591
|
+
?? qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
|
|
543
592
|
const text = promptMessage.content;
|
|
544
593
|
const hasImages = hasInboundImages(promptMessage);
|
|
594
|
+
const hasFiles = hasInboundFiles(promptMessage);
|
|
545
595
|
let stream = null;
|
|
546
596
|
try {
|
|
547
|
-
if (!text && !hasImages) {
|
|
548
|
-
await this.#bot.sendText(target, '
|
|
597
|
+
if (!text && !hasImages && !hasFiles) {
|
|
598
|
+
await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
|
|
549
599
|
await this.#state.markSeen(messageId);
|
|
550
600
|
return;
|
|
551
601
|
}
|
|
552
602
|
const command = text.toLowerCase();
|
|
553
|
-
if (!hasImages && command === '/help') {
|
|
603
|
+
if (!hasImages && !hasFiles && command === '/help') {
|
|
554
604
|
await this.#bot.sendText(target, HELP_TEXT);
|
|
555
605
|
await this.#state.markSeen(messageId);
|
|
556
606
|
return;
|
|
557
607
|
}
|
|
558
|
-
if (!hasImages && command === '/status') {
|
|
608
|
+
if (!hasImages && !hasFiles && command === '/status') {
|
|
559
609
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
560
610
|
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
|
561
611
|
await this.#state.markSeen(messageId);
|
|
562
612
|
return;
|
|
563
613
|
}
|
|
564
|
-
if (!hasImages && command === '/new') {
|
|
614
|
+
if (!hasImages && !hasFiles && command === '/new') {
|
|
565
615
|
await this.#state.clearSession(key);
|
|
566
616
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
|
567
617
|
await this.#state.markSeen(messageId);
|
|
568
618
|
return;
|
|
569
619
|
}
|
|
570
|
-
const workspaceCommand = hasImages
|
|
620
|
+
const workspaceCommand = hasImages || hasFiles
|
|
571
621
|
? null
|
|
572
622
|
: await runWorkspaceCommand(text, this.#harness, key);
|
|
573
623
|
if (workspaceCommand) {
|
|
@@ -577,7 +627,7 @@ export class QqHarnessBridge {
|
|
|
577
627
|
await this.#state.markSeen(messageId);
|
|
578
628
|
return;
|
|
579
629
|
}
|
|
580
|
-
const compactCommand = hasImages
|
|
630
|
+
const compactCommand = hasImages || hasFiles
|
|
581
631
|
? null
|
|
582
632
|
: await runCompactCommand(
|
|
583
633
|
text,
|
|
@@ -595,14 +645,17 @@ export class QqHarnessBridge {
|
|
|
595
645
|
const content = hasImages
|
|
596
646
|
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
|
597
647
|
: undefined;
|
|
598
|
-
|
|
648
|
+
// QQ C2C keeps one stream bubble. Progress is collected but never submitted:
|
|
649
|
+
// some clients reject replacing an already visible stream frame, which would
|
|
650
|
+
// otherwise leave a stale progress bubble plus a separate fallback answer.
|
|
599
651
|
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
|
|
600
652
|
try {
|
|
601
653
|
stream = this.#bot.openStream({ target });
|
|
602
654
|
} catch (error) {
|
|
603
|
-
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using
|
|
655
|
+
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using markdown fallback:', error);
|
|
604
656
|
}
|
|
605
657
|
}
|
|
658
|
+
const toolErrors = [];
|
|
606
659
|
let answer;
|
|
607
660
|
let artifacts = [];
|
|
608
661
|
try {
|
|
@@ -617,14 +670,15 @@ export class QqHarnessBridge {
|
|
|
617
670
|
timeoutMs: this.#replyTimeoutMs,
|
|
618
671
|
signal: this.#signal,
|
|
619
672
|
control: { owner: this, key },
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
?
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
673
|
+
progressMode: 'all',
|
|
674
|
+
onUpdate: (update) => {
|
|
675
|
+
if (update.error) {
|
|
676
|
+
const label = nonEmptyString(update.toolName)
|
|
677
|
+
? `Tool call ${update.toolName}` : 'Tool call';
|
|
678
|
+
const text = `${label}\nError: ${update.error}`;
|
|
679
|
+
toolErrors.push(text);
|
|
680
|
+
}
|
|
681
|
+
},
|
|
628
682
|
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
629
683
|
key,
|
|
630
684
|
actor: sender,
|
|
@@ -632,6 +686,7 @@ export class QqHarnessBridge {
|
|
|
632
686
|
requiresMention: message.kind === 'group',
|
|
633
687
|
}),
|
|
634
688
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
689
|
+
files: promptMessage.files,
|
|
635
690
|
},
|
|
636
691
|
}));
|
|
637
692
|
} finally {
|
|
@@ -641,31 +696,41 @@ export class QqHarnessBridge {
|
|
|
641
696
|
]);
|
|
642
697
|
}
|
|
643
698
|
this.#signal?.throwIfAborted();
|
|
644
|
-
const
|
|
699
|
+
const answerText = answerTextForDelivery(answer, artifacts);
|
|
700
|
+
const displayAnswer = toolErrors.length > 0
|
|
701
|
+
? `${answerText}\n\n---\n\n${toolErrors.join('\n\n')}`
|
|
702
|
+
: answerText;
|
|
645
703
|
let textReceipt = null;
|
|
646
704
|
let textSendError = null;
|
|
647
705
|
try {
|
|
706
|
+
let streamFinished = false;
|
|
648
707
|
if (stream) {
|
|
649
708
|
try {
|
|
650
709
|
await stream.update(displayAnswer);
|
|
651
|
-
await stream.complete();
|
|
652
710
|
streamFinished = true;
|
|
653
711
|
textReceipt = createDeliveryReceipt({
|
|
654
712
|
deliveryId: messageId,
|
|
655
713
|
presentation: 'qq-text',
|
|
656
714
|
providerMessageIds: providerMessageIdsFor(stream),
|
|
657
715
|
});
|
|
716
|
+
try {
|
|
717
|
+
await stream.complete();
|
|
718
|
+
} catch (error) {
|
|
719
|
+
this.#logger.warn?.('[dsh-im:qq] QQ stream completion failed after visible final content:', error);
|
|
720
|
+
}
|
|
658
721
|
} catch (error) {
|
|
659
722
|
stream.cancel?.();
|
|
660
|
-
this.#logger.warn?.('[dsh-im:qq] QQ stream
|
|
723
|
+
this.#logger.warn?.('[dsh-im:qq] QQ stream update failed; using markdown fallback:', error);
|
|
661
724
|
}
|
|
662
725
|
}
|
|
663
726
|
if (!streamFinished) {
|
|
664
|
-
const
|
|
727
|
+
const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, {
|
|
728
|
+
logger: this.#logger,
|
|
729
|
+
});
|
|
665
730
|
textReceipt = createDeliveryReceipt({
|
|
666
731
|
deliveryId: messageId,
|
|
667
732
|
presentation: 'qq-text',
|
|
668
|
-
providerMessageIds: providerMessageIdsFor(
|
|
733
|
+
providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)),
|
|
669
734
|
});
|
|
670
735
|
}
|
|
671
736
|
} catch (error) {
|
|
@@ -686,29 +751,33 @@ export class QqHarnessBridge {
|
|
|
686
751
|
return delivery.receipt;
|
|
687
752
|
} catch (error) {
|
|
688
753
|
if (error?.code === 'turn-stopped') {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
|
|
699
|
-
}
|
|
754
|
+
try {
|
|
755
|
+
stream?.cancel?.();
|
|
756
|
+
} catch (streamError) {
|
|
757
|
+
this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
await this.#bot.sendText(target, '已停止。');
|
|
761
|
+
} catch (sendError) {
|
|
762
|
+
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
|
|
700
763
|
}
|
|
701
764
|
await this.#state.markSeen(messageId);
|
|
702
765
|
return;
|
|
703
766
|
}
|
|
704
|
-
|
|
767
|
+
try {
|
|
768
|
+
stream?.cancel?.();
|
|
769
|
+
} catch (streamError) {
|
|
770
|
+
this.#logger.warn?.('[dsh-im:qq] unable to cancel a failed QQ stream:', streamError);
|
|
771
|
+
}
|
|
705
772
|
if (this.#signal?.aborted) return;
|
|
706
773
|
this.#status.lastError = error?.message ?? String(error);
|
|
707
774
|
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
|
708
775
|
try {
|
|
709
776
|
await this.#bot.sendText(
|
|
710
777
|
target,
|
|
711
|
-
|
|
778
|
+
inboundFileUserMessage(error)
|
|
779
|
+
?? imagePromptUserMessage(error)
|
|
780
|
+
?? '消息处理失败,请稍后重试。',
|
|
712
781
|
);
|
|
713
782
|
await this.#state.markSeen(messageId);
|
|
714
783
|
} catch (sendError) {
|
|
@@ -734,7 +803,7 @@ export class QqHarnessBridge {
|
|
|
734
803
|
|
|
735
804
|
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
|
736
805
|
const text = nonEmptyString(safeText(message));
|
|
737
|
-
if (!text || hasQqImageAttachments(message)) {
|
|
806
|
+
if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
|
|
738
807
|
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
|
|
739
808
|
return;
|
|
740
809
|
}
|
|
@@ -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
|
+
}
|