@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
@@ -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
- images.push({
114
- ...(name ? { name } : {}),
115
- ...(mediaType?.startsWith('image/') ? { mediaType } : {}),
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, maxBytes }) => {
118
- if (!url) throw new Error('QQ image attachment has no download URL');
119
- return fetchImageBuffer(url, {
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 = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
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
- let streamFinished = false;
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 a text reply:', error);
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
- onUpdate: stream ? async (update) => {
621
- const progress = update.type === 'text'
622
- ? update.text
623
- : update.type === 'tool'
624
- ? `正在使用${update.name}…`
625
- : update.text;
626
- if (progress) await stream.update(progress);
627
- } : undefined,
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 displayAnswer = answerTextForDelivery(answer, artifacts);
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 finalization failed; using a text reply:', error);
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 sent = await this.#bot.sendText(target, displayAnswer);
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(sent),
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
- if (stream) {
690
- try {
691
- await stream.cancel?.();
692
- } catch (streamError) {
693
- this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
694
- }
695
- try {
696
- await this.#bot.sendText(target, '已停止。');
697
- } catch (sendError) {
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
- stream?.cancel?.();
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
- imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
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
+ }