@soimy/dingtalk 3.6.2 → 3.6.4

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/dist/index.js CHANGED
@@ -1814,7 +1814,7 @@ async function updateCardVariables(outTrackId, params, token, config) {
1814
1814
  {
1815
1815
  outTrackId,
1816
1816
  cardData: { cardParamMap: stringMap },
1817
- cardUpdateOptions: { updateCardDataByKey: true, updatePrivateDataByKey: true }
1817
+ cardUpdateOptions: { updateCardDataByKey: true }
1818
1818
  },
1819
1819
  {
1820
1820
  headers: {
@@ -2614,6 +2614,19 @@ async function createAICard(config, conversationId, log, options = {}) {
2614
2614
  upsertPendingCard(aiCardInstance, options.storePath, log);
2615
2615
  }
2616
2616
  clearAICardDegrade(accountId, log);
2617
+ try {
2618
+ await putAICardStreamingField(aiCardInstance, template.contentKey, "", false, log, {
2619
+ suppressDegrade: true
2620
+ });
2621
+ aiCardInstance.state = AICardStatus.INPUTING;
2622
+ if (shouldPersistPending) {
2623
+ upsertPendingCard(aiCardInstance, options.storePath, log);
2624
+ }
2625
+ } catch (kickErr) {
2626
+ log?.debug?.(
2627
+ `[DingTalk][AICard] Non-critical: failed to kick card into streaming mode: ${kickErr.message}`
2628
+ );
2629
+ }
2617
2630
  return aiCardInstance;
2618
2631
  } catch (err) {
2619
2632
  log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
@@ -2712,13 +2725,13 @@ async function clearAICardStreamingContent(card, log) {
2712
2725
  log?.debug?.(`[DingTalk][AICard] Non-critical: failed to clear streaming content: ${message}`);
2713
2726
  }
2714
2727
  }
2715
- async function finalizeAICardStreamingLifecycleIfNeeded(card, content, log) {
2728
+ async function finalizeAICardStreamingLifecycleIfNeeded(card, log) {
2716
2729
  if (!card.streamLifecycleOpened) {
2717
2730
  return;
2718
2731
  }
2719
2732
  const template = DINGTALK_CARD_TEMPLATE;
2720
2733
  try {
2721
- await putAICardStreamingField(card, template.streamingKey, content, true, log, {
2734
+ await putAICardStreamingField(card, template.streamingKey, "", true, log, {
2722
2735
  suppressDegrade: true
2723
2736
  });
2724
2737
  } catch (err) {
@@ -2734,7 +2747,7 @@ async function commitAICardBlocks(card, options, log) {
2734
2747
  return;
2735
2748
  }
2736
2749
  await ensureFreshToken(card, log);
2737
- await finalizeAICardStreamingLifecycleIfNeeded(card, options.content, log);
2750
+ await finalizeAICardStreamingLifecycleIfNeeded(card, log);
2738
2751
  const template = DINGTALK_CARD_TEMPLATE;
2739
2752
  const updates = {
2740
2753
  [template.blockListKey]: options.blockListJson,
@@ -2797,7 +2810,7 @@ async function finalizeStoppedAICard(card, options, log) {
2797
2810
  await ensureFreshToken(card, log);
2798
2811
  const template = DINGTALK_CARD_TEMPLATE;
2799
2812
  const payload = buildStoppedCardFinalizePayload(options);
2800
- await finalizeAICardStreamingLifecycleIfNeeded(card, payload.content, log);
2813
+ await finalizeAICardStreamingLifecycleIfNeeded(card, log);
2801
2814
  try {
2802
2815
  await updateCardVariables(
2803
2816
  card.outTrackId || card.cardInstanceId,
@@ -4475,7 +4488,7 @@ function stripLeadingInvisibleChars(value) {
4475
4488
 
4476
4489
  // src/inbound-handler.ts
4477
4490
  import fs6 from "node:fs";
4478
- import * as path9 from "node:path";
4491
+ import * as path10 from "node:path";
4479
4492
  import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
4480
4493
  import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
4481
4494
 
@@ -6716,7 +6729,7 @@ function isMarkdownTableSeparator(line) {
6716
6729
  return false;
6717
6730
  }
6718
6731
  const cells = normalized.replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
6719
- return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
6732
+ return cells.length > 0 && cells.every((cell) => /^:?-{1,}:?$/.test(cell));
6720
6733
  }
6721
6734
  function isMarkdownTableRow(line) {
6722
6735
  const trimmed = line.trim();
@@ -6725,9 +6738,58 @@ function isMarkdownTableRow(line) {
6725
6738
  function parseMarkdownTableRow(line) {
6726
6739
  return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
6727
6740
  }
6728
- function renderMarkdownTable(lines) {
6729
- const rows = lines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
6730
- return rows.map((cells) => cells.join(" | ")).join(" \n");
6741
+ function parseSeparatorAlignment(cell) {
6742
+ const trimmed = cell.trim();
6743
+ const hasLeftColon = trimmed.startsWith(":");
6744
+ const hasRightColon = trimmed.endsWith(":");
6745
+ if (hasLeftColon && hasRightColon) {
6746
+ return "center";
6747
+ }
6748
+ if (hasRightColon) {
6749
+ return "right";
6750
+ }
6751
+ if (hasLeftColon) {
6752
+ return "left";
6753
+ }
6754
+ return "center";
6755
+ }
6756
+ function buildSeparatorRow(alignments) {
6757
+ const cells = alignments.map((align) => {
6758
+ if (align === "left") {
6759
+ return ":---";
6760
+ }
6761
+ if (align === "right") {
6762
+ return "---:";
6763
+ }
6764
+ return ":---:";
6765
+ });
6766
+ return "|" + cells.join("|") + "|";
6767
+ }
6768
+ function renderMarkdownTable(headerLine, separatorLine, dataLines) {
6769
+ const headerCells = parseMarkdownTableRow(headerLine);
6770
+ const separatorCells = parseMarkdownTableRow(separatorLine);
6771
+ const dataRows = dataLines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
6772
+ if (headerCells.length === 0) {
6773
+ return "";
6774
+ }
6775
+ const colCount = Math.max(
6776
+ headerCells.length,
6777
+ separatorCells.length,
6778
+ ...dataRows.map((cells) => cells.length)
6779
+ );
6780
+ const alignments = [];
6781
+ for (let i = 0; i < colCount; i++) {
6782
+ const sepCell = separatorCells[i] || "";
6783
+ alignments.push(parseSeparatorAlignment(sepCell));
6784
+ }
6785
+ const separator = buildSeparatorRow(alignments);
6786
+ const allRows = [headerCells, ...dataRows];
6787
+ const rendered = allRows.map((cells) => {
6788
+ const padded = cells.length < colCount ? [...cells, ...Array(colCount - cells.length).fill("")] : cells;
6789
+ return "|" + padded.join("|") + "|";
6790
+ });
6791
+ rendered.splice(1, 0, separator);
6792
+ return rendered.join("\n");
6731
6793
  }
6732
6794
  function convertMarkdownTablesToPlainText(text) {
6733
6795
  const lines = text.split("\n");
@@ -6743,13 +6805,20 @@ function convertMarkdownTablesToPlainText(text) {
6743
6805
  continue;
6744
6806
  }
6745
6807
  if (!inCodeFence && index + 1 < lines.length && isMarkdownTableRow(line) && isMarkdownTableSeparator(lines[index + 1] || "")) {
6746
- const tableLines = [line];
6808
+ const headerLine = line;
6809
+ const separatorLine = lines[index + 1] || "";
6810
+ const dataLines = [];
6747
6811
  index += 2;
6748
6812
  while (index < lines.length && isMarkdownTableRow(lines[index] || "")) {
6749
- tableLines.push(lines[index] || "");
6813
+ dataLines.push(lines[index] || "");
6750
6814
  index += 1;
6751
6815
  }
6752
- output.push(renderMarkdownTable(tableLines));
6816
+ const renderedTable = renderMarkdownTable(headerLine, separatorLine, dataLines);
6817
+ const lastOutput = output[output.length - 1];
6818
+ if (lastOutput !== void 0 && lastOutput.trim() !== "") {
6819
+ output.push("");
6820
+ }
6821
+ output.push(renderedTable);
6753
6822
  continue;
6754
6823
  }
6755
6824
  output.push(line);
@@ -7247,6 +7316,9 @@ function buildPersistedOutboundText(text, options) {
7247
7316
  }
7248
7317
  return text;
7249
7318
  }
7319
+ function shouldRouteSessionMediaViaProactive(mediaType) {
7320
+ return mediaType === "voice" || mediaType === "video" || mediaType === "file";
7321
+ }
7250
7322
  var DINGTALK_TEXT_CHUNK_LIMIT = 3800;
7251
7323
  var CARD_MEDIA_CONTROLLER_ATTACH_WAIT_MS = 150;
7252
7324
  var CARD_MEDIA_CONTROLLER_ATTACH_POLL_MS = 25;
@@ -7643,51 +7715,33 @@ async function sendBySession(config, sessionWebhook, text, options = {}) {
7643
7715
  const token = await getAccessToken(config, options.log);
7644
7716
  const log = options.log || getLogger();
7645
7717
  if (options.mediaPath && options.mediaType) {
7646
- const uploadResult = await uploadMedia2(config, options.mediaPath, options.mediaType, log, {
7647
- mediaLocalRoots: options.mediaLocalRoots
7648
- });
7649
- if (uploadResult) {
7650
- const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
7651
- let body;
7652
- if (options.mediaType === "image") {
7653
- body = { msgtype: "image", image: { media_id: mediaId } };
7654
- } else if (options.mediaType === "voice") {
7655
- const durationMs = uploadedDurationMs ?? await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
7656
- body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
7657
- log?.debug?.(
7658
- `[DingTalk] Sending session voice message mediaId=${mediaId} durationMs=${durationMs}`
7659
- );
7660
- } else if (options.mediaType === "video") {
7661
- body = { msgtype: "video", video: { media_id: mediaId } };
7662
- } else if (options.mediaType === "file") {
7663
- body = { msgtype: "file", file: { media_id: mediaId } };
7664
- }
7665
- if (body) {
7666
- const result = await http_client_default({
7667
- url: sessionWebhook,
7668
- method: "POST",
7669
- data: body,
7670
- headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
7671
- ...getProxyBypassOption(config)
7672
- });
7718
+ if (options.mediaType === "image") {
7719
+ const uploadResult = await uploadMedia2(config, options.mediaPath, options.mediaType, log, {
7720
+ mediaLocalRoots: options.mediaLocalRoots
7721
+ });
7722
+ if (uploadResult) {
7723
+ const imageMarkdown = `![${path7.basename(options.mediaPath)}](${uploadResult.mediaId})`;
7724
+ text = text ? `${text}
7725
+
7726
+ ${imageMarkdown}` : imageMarkdown;
7673
7727
  log?.debug?.(
7674
- `[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`
7728
+ `[DingTalk] Session webhook image will be delivered as markdown media reference mediaId=${uploadResult.mediaId}`
7675
7729
  );
7676
- ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
7677
- const delivery = extractOutboundDeliveryMetadata(result.data);
7678
- if (!delivery.messageId && !delivery.processQueryKey && !delivery.outTrackId) {
7679
- log?.warn?.(
7680
- `[DingTalk] Session webhook ${body.msgtype} response missing delivery metadata; ` + summarizeSessionWebhookResponse(result.data)
7681
- );
7682
- }
7683
- return result.data;
7730
+ } else {
7731
+ const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(\u5A92\u4F53\u53D1\u9001\u5931\u8D25)";
7732
+ text = `${text}
7733
+
7734
+ \u{1F4CE} \u5A92\u4F53\u53D1\u9001\u5931\u8D25\uFF0C\u515C\u5E95\u94FE\u63A5/\u8DEF\u5F84\uFF1A${mediaHint}`.trim();
7735
+ log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
7684
7736
  }
7685
7737
  } else {
7686
7738
  const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(\u5A92\u4F53\u53D1\u9001\u5931\u8D25)";
7687
7739
  text = `${text}
7688
7740
 
7689
- \u{1F4CE} \u5A92\u4F53\u53D1\u9001\u5931\u8D25\uFF0C\u515C\u5E95\u94FE\u63A5/\u8DEF\u5F84\uFF1A${mediaHint}`.trim();
7690
- log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
7741
+ \u{1F4CE} \u5F53\u524D\u4F1A\u8BDD\u65E0\u6CD5\u76F4\u63A5\u53D1\u9001 ${options.mediaType}\uFF0C\u515C\u5E95\u94FE\u63A5/\u8DEF\u5F84\uFF1A${mediaHint}`.trim();
7742
+ log?.warn?.(
7743
+ `[DingTalk] Session webhook does not support native ${options.mediaType} replies; falling back to text description`
7744
+ );
7691
7745
  }
7692
7746
  }
7693
7747
  const textWithUploadedLocalImages = await replaceMarkdownLocalImages({
@@ -7733,6 +7787,48 @@ async function sendMessage(config, conversationId, text, options = {}) {
7733
7787
  try {
7734
7788
  const messageType = config.messageType || "markdown";
7735
7789
  const log = options.log || getLogger();
7790
+ if (options.sessionWebhook && options.mediaPath && shouldRouteSessionMediaViaProactive(options.mediaType)) {
7791
+ log?.debug?.(
7792
+ `[DingTalk] Session webhook does not support ${options.mediaType} replies reliably; using proactive media API instead`
7793
+ );
7794
+ const proactiveMediaResult = await sendProactiveMedia(
7795
+ config,
7796
+ conversationId,
7797
+ options.mediaPath,
7798
+ options.mediaType,
7799
+ options
7800
+ );
7801
+ if (!proactiveMediaResult.ok) {
7802
+ log?.warn?.(
7803
+ `[DingTalk] Proactive ${options.mediaType} reply failed; falling back to session markdown: ` + (proactiveMediaResult.error || "unknown")
7804
+ );
7805
+ const data = await sendBySession(config, options.sessionWebhook, text, options);
7806
+ const delivery2 = extractOutboundDeliveryMetadata(data);
7807
+ const messageId2 = delivery2.messageId || delivery2.processQueryKey || delivery2.outTrackId;
7808
+ const persistedText = buildPersistedOutboundText(text, options);
7809
+ persistOutboundMessageContext({
7810
+ storePath: options.storePath,
7811
+ accountId: options.accountId,
7812
+ conversationId: options.conversationId || conversationId,
7813
+ text: persistedText,
7814
+ messageType: "outbound-media",
7815
+ quotedRef: options.quotedRef,
7816
+ log,
7817
+ ...DEFAULT_OUTBOUND_SENDER,
7818
+ chatType: inferConversationChatType(options.conversationId || conversationId),
7819
+ delivery: {
7820
+ ...delivery2,
7821
+ kind: "session"
7822
+ }
7823
+ });
7824
+ return { ok: true, data, messageId: messageId2 };
7825
+ }
7826
+ return {
7827
+ ok: true,
7828
+ data: proactiveMediaResult.data,
7829
+ messageId: proactiveMediaResult.messageId
7830
+ };
7831
+ }
7736
7832
  if (messageType === "card" && options.card && !options.forceMarkdown) {
7737
7833
  const card = options.card;
7738
7834
  if (isCardInTerminalState(card.state)) {
@@ -7754,26 +7850,6 @@ async function sendMessage(config, conversationId, text, options = {}) {
7754
7850
  };
7755
7851
  }
7756
7852
  }
7757
- if (options.sessionWebhook && options.mediaPath && options.mediaType === "voice") {
7758
- log?.debug?.(
7759
- "[DingTalk] Session webhook does not support voice replies reliably; using proactive media API for this voice response"
7760
- );
7761
- const proactiveVoiceResult = await sendProactiveMedia(
7762
- config,
7763
- conversationId,
7764
- options.mediaPath,
7765
- options.mediaType,
7766
- options
7767
- );
7768
- if (!proactiveVoiceResult.ok) {
7769
- return { ok: false, error: proactiveVoiceResult.error || "Voice reply send failed" };
7770
- }
7771
- return {
7772
- ok: true,
7773
- data: proactiveVoiceResult.data,
7774
- messageId: proactiveVoiceResult.messageId
7775
- };
7776
- }
7777
7853
  if (options.sessionWebhook) {
7778
7854
  const data = await sendBySession(config, options.sessionWebhook, text, options);
7779
7855
  const delivery2 = extractOutboundDeliveryMetadata(data);
@@ -9061,8 +9137,6 @@ function createCardDraftController(params) {
9061
9137
  if (stopped || failed) {
9062
9138
  return;
9063
9139
  }
9064
- await contentLoop.flush();
9065
- await contentLoop.waitForInFlight();
9066
9140
  if (hasStreamingContent) {
9067
9141
  await clearStreamingContentFromCard();
9068
9142
  }
@@ -9265,6 +9339,7 @@ function createCardDraftController(params) {
9265
9339
  discardCurrentAnswer();
9266
9340
  } else {
9267
9341
  sealCurrentAnswer();
9342
+ queueRender();
9268
9343
  }
9269
9344
  await beginBoundaryFlush();
9270
9345
  return;
@@ -9482,7 +9557,6 @@ function createCardReplyStrategy(ctx) {
9482
9557
  };
9483
9558
  const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
9484
9559
  const streamAnswerLive = mode === "answer" || mode === "all";
9485
- const renderAnswerBlocksLive = mode === "all";
9486
9560
  const streamThinkingLive = mode === "all";
9487
9561
  let lifecycleState = "open";
9488
9562
  const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
@@ -9510,6 +9584,7 @@ function createCardReplyStrategy(ctx) {
9510
9584
  let sawFinalDelivery = false;
9511
9585
  let latestReasoningSnapshot = "";
9512
9586
  let pendingNonImageMedia = [];
9587
+ const processedMediaUrls = /* @__PURE__ */ new Set();
9513
9588
  const getRenderedTimeline = (options = {}) => {
9514
9589
  const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : void 0);
9515
9590
  return controller.getRenderedContent({
@@ -9616,7 +9691,10 @@ function createCardReplyStrategy(ctx) {
9616
9691
  finalTextForFallback = normalized.answerText;
9617
9692
  return;
9618
9693
  }
9619
- await controller.updateAnswer(normalized.answerText);
9694
+ await controller.updateAnswer(normalized.answerText, {
9695
+ stream: streamAnswerLive,
9696
+ renderBlocks: !streamAnswerLive
9697
+ });
9620
9698
  }
9621
9699
  };
9622
9700
  const rewriteLocalMarkdownImagesToPlaceholders = (text) => {
@@ -9652,7 +9730,8 @@ function createCardReplyStrategy(ctx) {
9652
9730
  }
9653
9731
  await controller.updateAnswer(answerSnapshot, {
9654
9732
  stream: streamAnswerLive,
9655
- renderBlocks: renderAnswerBlocksLive
9733
+ // Active answer previews live in the content field; blockList is committed at boundaries/finalize.
9734
+ renderBlocks: false
9656
9735
  });
9657
9736
  };
9658
9737
  const applySplitTextToTimeline = async (text, options = {}) => {
@@ -9674,6 +9753,11 @@ function createCardReplyStrategy(ctx) {
9674
9753
  if (candidate.classification !== "local") {
9675
9754
  continue;
9676
9755
  }
9756
+ if (processedMediaUrls.has(candidate.url.trim())) {
9757
+ const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
9758
+ nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
9759
+ continue;
9760
+ }
9677
9761
  let prepared;
9678
9762
  try {
9679
9763
  prepared = await prepareMediaInput(candidate.url, log, config.mediaUrlAllowlist);
@@ -9686,6 +9770,7 @@ function createCardReplyStrategy(ctx) {
9686
9770
  if (!result?.mediaId) {
9687
9771
  continue;
9688
9772
  }
9773
+ processedMediaUrls.add(candidate.url.trim());
9689
9774
  const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
9690
9775
  const blockText = candidate.alt.trim() || placeholder.replace(/^见下图/, "").trim() || "\u56FE\u7247";
9691
9776
  successfulReroutes.push({
@@ -9712,6 +9797,11 @@ function createCardReplyStrategy(ctx) {
9712
9797
  // Card mode keeps runtime block streaming disabled, but still consumes
9713
9798
  // reasoning blocks through explicit callbacks and delivery metadata.
9714
9799
  disableBlockStreaming: ctx.disableBlockStreaming ?? true,
9800
+ // DingTalk card mode owns the visible reply surface. In group chats,
9801
+ // OpenClaw defaults source replies to message-tool-only; override that
9802
+ // so final replies are delivered into this card instead of spawning a
9803
+ // separate visible message/card via the message tool.
9804
+ sourceReplyDeliveryMode: "automatic",
9715
9805
  onAssistantMessageStart: async () => {
9716
9806
  if (isLifecycleSealed() || isStopRequested?.()) {
9717
9807
  return;
@@ -9780,6 +9870,10 @@ function createCardReplyStrategy(ctx) {
9780
9870
  );
9781
9871
  if (payload.mediaUrls.length > 0) {
9782
9872
  for (const url of payload.mediaUrls) {
9873
+ const normalizedUrl = url.trim();
9874
+ if (processedMediaUrls.has(normalizedUrl)) {
9875
+ continue;
9876
+ }
9783
9877
  try {
9784
9878
  const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
9785
9879
  const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
@@ -9794,6 +9888,7 @@ function createCardReplyStrategy(ctx) {
9794
9888
  const result = await uploadMedia2(config, prepared.path, "image", log);
9795
9889
  await prepared.cleanup?.();
9796
9890
  if (result?.mediaId) {
9891
+ processedMediaUrls.add(normalizedUrl);
9797
9892
  await controller.appendImageBlock(result.mediaId);
9798
9893
  }
9799
9894
  } catch (err) {
@@ -9852,6 +9947,10 @@ function createCardReplyStrategy(ctx) {
9852
9947
  }
9853
9948
  if (payload.mediaUrls.length > 0) {
9854
9949
  for (const url of payload.mediaUrls) {
9950
+ const normalizedUrl = url.trim();
9951
+ if (processedMediaUrls.has(normalizedUrl)) {
9952
+ continue;
9953
+ }
9855
9954
  try {
9856
9955
  const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
9857
9956
  const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
@@ -9864,6 +9963,7 @@ function createCardReplyStrategy(ctx) {
9864
9963
  const result = await uploadMedia2(config, prepared.path, "image", log);
9865
9964
  await prepared.cleanup?.();
9866
9965
  if (result?.mediaId) {
9966
+ processedMediaUrls.add(normalizedUrl);
9867
9967
  await controller.appendImageBlock(result.mediaId);
9868
9968
  }
9869
9969
  } catch (err) {
@@ -9951,6 +10051,7 @@ function createCardReplyStrategy(ctx) {
9951
10051
  }
9952
10052
  try {
9953
10053
  await flushPendingReasoning();
10054
+ await controller.clearStreamingContent?.();
9954
10055
  await controller.flush();
9955
10056
  await controller.waitForInFlight();
9956
10057
  const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : void 0);
@@ -10066,6 +10167,7 @@ function createCardReplyStrategy(ctx) {
10066
10167
  }
10067
10168
 
10068
10169
  // src/reply-strategy-markdown.ts
10170
+ import path8 from "node:path";
10069
10171
  var EMPTY_FINAL_FALLBACK_TEXT = "\u2705 Done";
10070
10172
  function renderQuotedSegment(text) {
10071
10173
  return text.split("\n").map((line) => line.length > 0 ? `> ${line}` : ">").join("\n");
@@ -10102,6 +10204,10 @@ function computeSharedPrefixTail(previous, next) {
10102
10204
  const suffix = current.slice(sharedPrefixLength);
10103
10205
  return suffix.trim() ? suffix : "";
10104
10206
  }
10207
+ function renderMarkdownImage(mediaPath) {
10208
+ const filename = path8.basename(mediaPath) || "image";
10209
+ return `![${filename}](${mediaPath})`;
10210
+ }
10105
10211
  function createMarkdownReplyStrategy(ctx) {
10106
10212
  let finalText;
10107
10213
  let activeAnswerText = "";
@@ -10125,7 +10231,7 @@ function createMarkdownReplyStrategy(ctx) {
10125
10231
  }
10126
10232
  sentVisibleContent = true;
10127
10233
  };
10128
- const emitAnswerSuffix = async (text) => {
10234
+ const prepareAnswerSuffix = (text) => {
10129
10235
  const current = typeof text === "string" ? text : "";
10130
10236
  if (current.length > 0) {
10131
10237
  activeAnswerText = current;
@@ -10133,37 +10239,118 @@ function createMarkdownReplyStrategy(ctx) {
10133
10239
  }
10134
10240
  const suffix = computeIncrementalSuffix(lastSentAnswerText, current);
10135
10241
  if (suffix) {
10136
- await sendMarkdownSegment(suffix);
10137
- lastSentAnswerText = current;
10138
- return;
10242
+ return {
10243
+ text: suffix,
10244
+ markSent: () => {
10245
+ lastSentAnswerText = current;
10246
+ }
10247
+ };
10139
10248
  }
10140
10249
  if (current.trim() && lastSentAnswerText && !current.startsWith(lastSentAnswerText)) {
10141
10250
  const suffix2 = computeSharedPrefixTail(lastSentAnswerText, current);
10142
10251
  ctx.log?.warn?.(
10143
10252
  `[DingTalk][Markdown] answer prefix drift detected; falling back to shared-prefix tail prevLen=${lastSentAnswerText.length} currentLen=${current.length}`
10144
10253
  );
10145
- lastSentAnswerText = "";
10146
10254
  if (suffix2) {
10147
- await sendMarkdownSegment(suffix2);
10148
- lastSentAnswerText = current;
10149
- return;
10255
+ return {
10256
+ text: suffix2,
10257
+ markSent: () => {
10258
+ lastSentAnswerText = current;
10259
+ }
10260
+ };
10261
+ }
10262
+ return {
10263
+ text: current,
10264
+ markSent: () => {
10265
+ lastSentAnswerText = current;
10266
+ }
10267
+ };
10268
+ }
10269
+ return null;
10270
+ };
10271
+ const emitAnswerSuffix = async (text) => {
10272
+ const suffix = prepareAnswerSuffix(text);
10273
+ if (suffix) {
10274
+ await sendMarkdownSegment(suffix.text);
10275
+ suffix.markSent();
10276
+ }
10277
+ };
10278
+ const prepareMarkdownImageAttachments = async (mediaUrls) => {
10279
+ const imageMarkdown = [];
10280
+ const passthroughMediaUrls = [];
10281
+ const cleanups = [];
10282
+ for (const rawMediaUrl of mediaUrls) {
10283
+ const preparedMedia = await prepareMediaInput(
10284
+ rawMediaUrl,
10285
+ ctx.log,
10286
+ ctx.config.mediaUrlAllowlist
10287
+ );
10288
+ const actualMediaPath = preparedMedia.cleanup ? preparedMedia.path : resolveRelativePath(preparedMedia.path);
10289
+ const mediaType = resolveOutboundMediaType({
10290
+ mediaPath: actualMediaPath,
10291
+ asVoice: false
10292
+ });
10293
+ if (mediaType === "image") {
10294
+ imageMarkdown.push(renderMarkdownImage(actualMediaPath));
10295
+ if (preparedMedia.cleanup) {
10296
+ cleanups.push(preparedMedia.cleanup);
10297
+ }
10298
+ } else {
10299
+ await preparedMedia.cleanup?.();
10300
+ passthroughMediaUrls.push(rawMediaUrl);
10150
10301
  }
10151
- await sendMarkdownSegment(current);
10152
- lastSentAnswerText = current;
10153
10302
  }
10303
+ return { imageMarkdown, passthroughMediaUrls, cleanups };
10154
10304
  };
10155
10305
  return {
10156
10306
  getReplyOptions() {
10157
10307
  return {
10158
- disableBlockStreaming: ctx.disableBlockStreaming === true
10308
+ disableBlockStreaming: ctx.disableBlockStreaming === true,
10309
+ // DingTalk markdown/sessionWebhook mode owns the visible reply surface.
10310
+ // Keep runtime final replies on this strategy even when group chats
10311
+ // default source replies to message-tool-only.
10312
+ sourceReplyDeliveryMode: "automatic"
10159
10313
  };
10160
10314
  },
10161
10315
  async deliver(payload) {
10316
+ let answerTextSentWithImages = false;
10317
+ let toolTextSentWithImages = false;
10162
10318
  if (payload.mediaUrls.length > 0) {
10163
- await ctx.deliverMedia(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
10164
- sentVisibleContent = true;
10319
+ const prepared = payload.audioAsVoice === true ? {
10320
+ imageMarkdown: [],
10321
+ passthroughMediaUrls: payload.mediaUrls,
10322
+ cleanups: []
10323
+ } : await prepareMarkdownImageAttachments(payload.mediaUrls);
10324
+ try {
10325
+ if (prepared.passthroughMediaUrls.length > 0) {
10326
+ await ctx.deliverMedia(prepared.passthroughMediaUrls, {
10327
+ audioAsVoice: payload.audioAsVoice
10328
+ });
10329
+ sentVisibleContent = true;
10330
+ }
10331
+ if (prepared.imageMarkdown.length > 0) {
10332
+ const answerSuffix = payload.kind === "block" || payload.kind === "final" ? prepareAnswerSuffix(payload.text) : typeof payload.text === "string" ? { text: renderQuotedSegment(payload.text), markSent: () => {
10333
+ } } : null;
10334
+ const markdownParts = [answerSuffix?.text || "", ...prepared.imageMarkdown].filter(
10335
+ (part) => part.trim().length > 0
10336
+ );
10337
+ if (markdownParts.length > 0) {
10338
+ await sendMarkdownSegment(markdownParts.join("\n\n"));
10339
+ answerSuffix?.markSent();
10340
+ }
10341
+ answerTextSentWithImages = payload.kind === "block" || payload.kind === "final";
10342
+ toolTextSentWithImages = payload.kind === "tool";
10343
+ }
10344
+ } finally {
10345
+ for (const cleanup of prepared.cleanups) {
10346
+ await cleanup();
10347
+ }
10348
+ }
10165
10349
  }
10166
10350
  if (payload.kind === "tool") {
10351
+ if (toolTextSentWithImages) {
10352
+ return;
10353
+ }
10167
10354
  const text = typeof payload.text === "string" ? payload.text : "";
10168
10355
  if (!text.trim()) {
10169
10356
  return;
@@ -10171,7 +10358,7 @@ function createMarkdownReplyStrategy(ctx) {
10171
10358
  await sendMarkdownSegment(renderQuotedSegment(text));
10172
10359
  return;
10173
10360
  }
10174
- if ((payload.kind === "block" || payload.kind === "final") && typeof payload.text === "string") {
10361
+ if ((payload.kind === "block" || payload.kind === "final") && typeof payload.text === "string" && !answerTextSentWithImages) {
10175
10362
  await emitAnswerSuffix(payload.text);
10176
10363
  }
10177
10364
  },
@@ -10386,46 +10573,61 @@ function buildAgentSessionKey(params) {
10386
10573
  identityLinks: cfg.session?.identityLinks
10387
10574
  }).toLowerCase();
10388
10575
  }
10389
- function sanitizeAgentName(name) {
10390
- return name.replace(/[[\]\r\n]/g, "").trim();
10391
- }
10392
- async function resolveSubAgentRoute(params) {
10393
- const { extractedContent, cfg, isGroup, dingtalkConfig, sessionWebhook, senderId, log } = params;
10576
+ function resolveMessageTarget(params) {
10577
+ const { extractedContent, cfg, isGroup } = params;
10394
10578
  const atMentions = extractedContent.atMentions || [];
10395
- const atUserDingtalkIds = isGroup ? extractedContent.atUserDingtalkIds : void 0;
10579
+ if (atMentions.length === 0 || !cfg.agents?.list || cfg.agents.list.length === 0) {
10580
+ return { kind: "default" };
10581
+ }
10396
10582
  const textForCommandCheck = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
10397
- const isLearnCommand = parseLearnCommand(textForCommandCheck).scope !== "unknown";
10398
- const textWithoutMentions = textForCommandCheck.replace(/^(?:@\S+\s+)*/u, "").trim();
10399
- const isSlashCommand = maybeResolveTextAlias(textWithoutMentions, cfg) !== null;
10400
- if (atMentions.length === 0 || !cfg.agents?.list || cfg.agents.list.length === 0 || isLearnCommand || isSlashCommand) {
10401
- return null;
10583
+ if (parseLearnCommand(textForCommandCheck).scope !== "unknown") {
10584
+ return { kind: "default" };
10402
10585
  }
10403
- const { matchedAgents, unmatchedNames, realUserCount, hasInvalidAgentNames } = resolveAtAgents(
10586
+ const atUserDingtalkIds = isGroup ? extractedContent.atUserDingtalkIds : void 0;
10587
+ const { matchedAgents, unmatchedNames, hasInvalidAgentNames } = resolveAtAgents(
10404
10588
  atMentions,
10405
10589
  cfg,
10406
10590
  atUserDingtalkIds
10407
10591
  );
10408
- log?.info?.(
10409
- `[DingTalk] Sub-agent resolve: matched=${matchedAgents.map((a) => a.agentId).join(",")} unmatched=${unmatchedNames.join(",")} realUsers=${realUserCount}`
10410
- );
10411
- if (hasInvalidAgentNames) {
10412
- const fallbackReason = `\u672A\u627E\u5230\u540D\u4E3A"${unmatchedNames.join("\u3001")}"\u7684\u52A9\u624B`;
10413
- try {
10414
- const sendOptions = isGroup ? { atUserId: senderId, log } : { log };
10415
- await sendBySession(dingtalkConfig, sessionWebhook, `\u26A0\uFE0F ${fallbackReason}`, {
10416
- ...sendOptions
10417
- });
10418
- } catch (err) {
10419
- log?.debug?.(`[DingTalk] Failed to send fallback notice: ${getErrorMessage(err)}`);
10592
+ const commandText = textForCommandCheck.replace(/^(?:@\S+\s+)*/u, "").trim();
10593
+ if (maybeResolveTextAlias(commandText, cfg) !== null) {
10594
+ const firstMatch = matchedAgents[0];
10595
+ if (firstMatch) {
10596
+ return { kind: "subagent-command", agent: firstMatch, commandText };
10420
10597
  }
10421
10598
  }
10422
- if (matchedAgents.length === 0) {
10423
- return null;
10599
+ if (matchedAgents.length === 0 && !hasInvalidAgentNames) {
10600
+ return { kind: "default" };
10601
+ }
10602
+ return { kind: "subagent-content", matchedAgents, unmatchedNames, hasInvalidAgentNames };
10603
+ }
10604
+ function sanitizeAgentName(name) {
10605
+ return name.replace(/[[\]\r\n]/g, "").trim();
10606
+ }
10607
+ async function sendUnmatchedAgentNotice(params) {
10608
+ const { unmatchedNames, isGroup, senderId, dingtalkConfig, sessionWebhook, log } = params;
10609
+ const fallbackReason = `\u672A\u627E\u5230\u540D\u4E3A"${unmatchedNames.join("\u3001")}"\u7684\u52A9\u624B`;
10610
+ try {
10611
+ const sendOptions = isGroup ? { atUserId: senderId, log } : { log };
10612
+ await sendBySession(dingtalkConfig, sessionWebhook, `\u26A0\uFE0F ${fallbackReason}`, sendOptions);
10613
+ } catch (err) {
10614
+ log?.debug?.(`[DingTalk] Failed to send fallback notice: ${getErrorMessage(err)}`);
10424
10615
  }
10425
- return { matchedAgents };
10426
10616
  }
10427
10617
  async function dispatchSubAgents(params) {
10428
- const { matchedAgents, cfg, accountId, data, dingtalkConfig, sessionWebhook, extractedContent, handleMessage, downloadMedia: download, log } = params;
10618
+ const {
10619
+ matchedAgents,
10620
+ commandText,
10621
+ cfg,
10622
+ accountId,
10623
+ data,
10624
+ dingtalkConfig,
10625
+ sessionWebhook,
10626
+ extractedContent,
10627
+ handleMessage,
10628
+ downloadMedia: download,
10629
+ log
10630
+ } = params;
10429
10631
  let preDownloadedMedia;
10430
10632
  const robotCode = resolveRobotCode(dingtalkConfig);
10431
10633
  if (robotCode) {
@@ -10460,18 +10662,17 @@ async function dispatchSubAgents(params) {
10460
10662
  dingtalkConfig,
10461
10663
  subAgentOptions: {
10462
10664
  agentId: agentMatch.agentId,
10463
- responsePrefix: `> \u{1F916} **${sanitizeAgentName(agentMatch.matchedName)}**:
10665
+ responsePrefix: commandText ? "" : `> \u{1F916} **${sanitizeAgentName(agentMatch.matchedName)}**:
10464
10666
 
10465
10667
  `,
10466
- matchedName: agentMatch.matchedName
10668
+ matchedName: agentMatch.matchedName,
10669
+ commandText
10467
10670
  },
10468
10671
  preDownloadedMedia
10469
10672
  });
10470
10673
  } catch (error) {
10471
10674
  const message = getErrorMessage(error);
10472
- log?.error?.(
10473
- `[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${message}`
10474
- );
10675
+ log?.error?.(`[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${message}`);
10475
10676
  if (error instanceof HostRoutingHelperUnavailableError && !helperMissingWarningSent) {
10476
10677
  helperMissingWarningSent = true;
10477
10678
  try {
@@ -10495,12 +10696,12 @@ async function dispatchSubAgents(params) {
10495
10696
 
10496
10697
  // src/targeting/group-members-store.ts
10497
10698
  import * as fs5 from "node:fs";
10498
- import * as path8 from "node:path";
10699
+ import * as path9 from "node:path";
10499
10700
  var GROUP_MEMBERS_NAMESPACE = "members.group-roster";
10500
10701
  function groupMembersFilePath(storePath, groupId) {
10501
- const dir = path8.join(path8.dirname(storePath), "dingtalk-members");
10702
+ const dir = path9.join(path9.dirname(storePath), "dingtalk-members");
10502
10703
  const safeId = groupId.replace(/\+/g, "-").replace(/\//g, "_");
10503
- return path8.join(dir, `${safeId}.json`);
10704
+ return path9.join(dir, `${safeId}.json`);
10504
10705
  }
10505
10706
  function readLegacyRoster(storePath, groupId) {
10506
10707
  const filePath = groupMembersFilePath(storePath, groupId);
@@ -11191,7 +11392,7 @@ async function handleDingTalkMessage(params) {
11191
11392
  return;
11192
11393
  }
11193
11394
  const rawInboundText = extractedContent.text.trim();
11194
- if (subAgentOptions) {
11395
+ if (subAgentOptions && !subAgentOptions.commandText) {
11195
11396
  const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
11196
11397
  const contextHint = `[\u4F60\u88AB @ \u4E3A"${subAgentOptions.matchedName}"]
11197
11398
 
@@ -11369,36 +11570,62 @@ async function handleDingTalkMessage(params) {
11369
11570
  peerIdOverride,
11370
11571
  config: dingtalkConfig
11371
11572
  });
11372
- const route = subAgentOptions ? {
11373
- agentId: subAgentOptions.agentId,
11374
- sessionKey: buildAgentSessionKey({
11375
- rt,
11376
- cfg,
11377
- accountId,
11573
+ const messageTarget = subAgentOptions ? null : resolveMessageTarget({ extractedContent, cfg, isGroup });
11574
+ let route;
11575
+ if (subAgentOptions) {
11576
+ route = {
11378
11577
  agentId: subAgentOptions.agentId,
11379
- peerKind: sessionPeer.kind,
11380
- peerId: sessionPeer.peerId
11381
- }),
11382
- mainSessionKey: ""
11383
- } : rt.channel.routing.resolveAgentRoute({
11384
- cfg,
11385
- channel: "dingtalk",
11386
- accountId,
11387
- peer: { kind: sessionPeer.kind, id: sessionPeer.peerId }
11388
- });
11389
- if (!subAgentOptions) {
11390
- const subAgentRoute = await resolveSubAgentRoute({
11391
- extractedContent,
11578
+ sessionKey: buildAgentSessionKey({
11579
+ rt,
11580
+ cfg,
11581
+ accountId,
11582
+ agentId: subAgentOptions.agentId,
11583
+ peerKind: sessionPeer.kind,
11584
+ peerId: sessionPeer.peerId
11585
+ }),
11586
+ mainSessionKey: ""
11587
+ };
11588
+ } else {
11589
+ route = rt.channel.routing.resolveAgentRoute({
11392
11590
  cfg,
11393
- isGroup,
11394
- dingtalkConfig,
11395
- sessionWebhook,
11396
- senderId,
11397
- log
11591
+ channel: "dingtalk",
11592
+ accountId,
11593
+ peer: { kind: sessionPeer.kind, id: sessionPeer.peerId }
11398
11594
  });
11399
- if (subAgentRoute) {
11595
+ }
11596
+ if (messageTarget && messageTarget.kind !== "default") {
11597
+ if (messageTarget.kind === "subagent-command") {
11598
+ await dispatchSubAgents({
11599
+ matchedAgents: [messageTarget.agent],
11600
+ commandText: messageTarget.commandText,
11601
+ cfg,
11602
+ accountId,
11603
+ data,
11604
+ dingtalkConfig,
11605
+ sessionWebhook,
11606
+ extractedContent,
11607
+ handleMessage: handleDingTalkMessage,
11608
+ downloadMedia,
11609
+ log
11610
+ });
11611
+ return;
11612
+ }
11613
+ if (messageTarget.hasInvalidAgentNames) {
11614
+ await sendUnmatchedAgentNotice({
11615
+ unmatchedNames: messageTarget.unmatchedNames,
11616
+ isGroup,
11617
+ senderId,
11618
+ dingtalkConfig,
11619
+ sessionWebhook,
11620
+ log
11621
+ });
11622
+ }
11623
+ if (messageTarget.matchedAgents.length > 0) {
11624
+ log?.info?.(
11625
+ `[DingTalk] Sub-agent resolve: matched=${messageTarget.matchedAgents.map((a) => a.agentId).join(",")} unmatched=${messageTarget.unmatchedNames.join(",")}`
11626
+ );
11400
11627
  await dispatchSubAgents({
11401
- ...subAgentRoute,
11628
+ matchedAgents: messageTarget.matchedAgents,
11402
11629
  cfg,
11403
11630
  accountId,
11404
11631
  data,
@@ -11422,7 +11649,7 @@ async function handleDingTalkMessage(params) {
11422
11649
  dingtalkConfig,
11423
11650
  senderId,
11424
11651
  isDirect,
11425
- extractedText: extractedContent.text,
11652
+ extractedText: subAgentOptions?.commandText ?? extractedContent.text,
11426
11653
  messageType: extractedContent.messageType,
11427
11654
  data: {
11428
11655
  conversationId: data.conversationId,
@@ -11581,7 +11808,7 @@ async function handleDingTalkMessage(params) {
11581
11808
  if (!hasPathLikeShape) {
11582
11809
  return void 0;
11583
11810
  }
11584
- return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path9.extname(trimmed).toLowerCase()) ? trimmed : void 0;
11811
+ return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path10.extname(trimmed).toLowerCase()) ? trimmed : void 0;
11585
11812
  }, extractSharedAudioAsVoice = function(payload, inlineReplyPayload) {
11586
11813
  const richPayload = payload;
11587
11814
  const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
@@ -12045,6 +12272,7 @@ ${extracted.text}`;
12045
12272
  const inboundText = attachmentExtractedText ? `${inboundBody.trimEnd()}
12046
12273
 
12047
12274
  ${attachmentExtractedText}` : inboundBody;
12275
+ const commandBody = subAgentOptions?.commandText ?? inboundText;
12048
12276
  const learningEnabled = isLearningEnabled(dingtalkConfig);
12049
12277
  const learningContextBlock = buildLearningContextBlock({
12050
12278
  enabled: learningEnabled,
@@ -12086,7 +12314,7 @@ ${attachmentExtractedText}` : inboundBody;
12086
12314
  const ctx = rt.channel.reply.finalizeInboundContext({
12087
12315
  Body: body,
12088
12316
  RawBody: inboundText,
12089
- CommandBody: inboundText,
12317
+ CommandBody: commandBody,
12090
12318
  QuotedRef: quotedRef,
12091
12319
  QuotedRefJson: quotedRef ? JSON.stringify(quotedRef) : void 0,
12092
12320
  ReplyToId: quotedRuntimeContext?.replyToId,
@@ -13025,14 +13253,14 @@ var RegistrationError = class extends Error {
13025
13253
  function asString(value) {
13026
13254
  return typeof value === "string" ? value : "";
13027
13255
  }
13028
- async function apiPost(path10, payload) {
13029
- const url = `${REGISTRATION_BASE_URL}${path10}`;
13256
+ async function apiPost(path11, payload) {
13257
+ const url = `${REGISTRATION_BASE_URL}${path11}`;
13030
13258
  const resp = await http_client_default.post(url, payload, { timeout: 15e3 });
13031
13259
  const data = resp.data;
13032
13260
  const errcode = data.errcode;
13033
13261
  if (errcode !== void 0 && errcode !== 0) {
13034
13262
  const errmsg = asString(data.errmsg) || "unknown error";
13035
- throw new RegistrationError(`API error [${path10}]: ${errmsg} (errcode=${typeof errcode === "number" ? errcode : asString(errcode)})`);
13263
+ throw new RegistrationError(`API error [${path11}]: ${errmsg} (errcode=${typeof errcode === "number" ? errcode : asString(errcode)})`);
13036
13264
  }
13037
13265
  return data;
13038
13266
  }