@soimy/dingtalk 3.5.3 → 3.6.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.
@@ -1,14 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
3
4
  import axios from "./http-client";
4
5
  import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
5
6
  import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
7
+ import { getAgentDisplayName } from "./targeting/agent-name-matcher";
6
8
  import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
7
9
  import { attachNativeAckReaction } from "./ack-reaction-service";
8
10
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
9
11
  import { extractAttachmentText } from "./messaging/attachment-text-extractor";
10
12
  import { getAccessToken } from "./auth";
11
- import { createAICard, finishAICard, isCardInTerminalState } from "./card-service";
13
+ import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
14
+ import { renderStatusLine } from "./card/statusline-renderer";
12
15
  import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
13
16
  import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
14
17
  import { AICardStatus } from "./types";
@@ -30,6 +33,7 @@ import {
30
33
  upsertInboundMessageContext,
31
34
  } from "./message-context-store";
32
35
  import { extractMessageContent } from "./message-utils";
36
+ import { deliverBtwReply, stripLeadingMentions } from "./messaging/btw-deliver";
33
37
  import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
34
38
  import {
35
39
  buildInboundQuotedRef,
@@ -43,7 +47,7 @@ import {
43
47
  } from "./proactive-risk-registry";
44
48
  import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
45
49
  import { createReplyStrategy } from "./reply-strategy";
46
- import type { DeliverPayload } from "./reply-strategy";
50
+ import type { DeliverPayload } from "./reply-strategy-types";
47
51
  import { getDingTalkRuntime } from "./runtime";
48
52
  import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
49
53
  import { acquireSessionLock } from "./session-lock";
@@ -53,13 +57,13 @@ import {
53
57
  setSessionPeerOverride,
54
58
  } from "./session-peer-store";
55
59
  import { resolveDingTalkSessionPeer } from "./session-routing";
60
+ import { getSessionState, initSessionState } from "./session-state";
56
61
  import {
57
62
  upsertObservedGroupTarget,
58
63
  upsertObservedUserTarget,
59
64
  } from "./targeting/target-directory-store";
60
65
  import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
61
66
  import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData, parseBooleanLike } from "./utils";
62
- import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
63
67
  import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
64
68
 
65
69
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
@@ -387,6 +391,8 @@ function buildGroupTurnContextPrompt(params: {
387
391
  type ReplyStreamPayload = {
388
392
  text?: string;
389
393
  isReasoning?: boolean;
394
+ mediaUrl?: string;
395
+ mediaUrls?: string[];
390
396
  };
391
397
 
392
398
  type ReplyChunkInfo = {
@@ -534,6 +540,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
534
540
  return;
535
541
  }
536
542
 
543
+ // Preserve raw inbound text before any rewriting (e.g., sub-agent context hint)
544
+ // for use in card quoteContent which should show the user's original message.
545
+ const rawInboundText = extractedContent.text.trim();
546
+
537
547
  // Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise in agent context.
538
548
  if (subAgentOptions) {
539
549
  const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
@@ -808,21 +818,51 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
808
818
  if (commandHandled) {
809
819
  return;
810
820
  }
821
+
822
+ const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
823
+ const quotedRef = buildInboundQuotedRef(data, extractedContent);
824
+ const replyQuotedRef = createReplyQuotedRef(data.msgId);
825
+ const content = extractedContent;
826
+ const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
827
+ const taskInfoConversationId = groupId || to;
828
+ const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
829
+ const initialStatusLine = renderStatusLine({
830
+ model: sessionTaskState.model,
831
+ effort: sessionTaskState.effort,
832
+ agent: getAgentDisplayName({
833
+ subAgentOptions,
834
+ agentId: route.agentId,
835
+ agentsList: cfg.agents?.list,
836
+ }),
837
+ }, dingtalkConfig) || undefined;
838
+
811
839
  // 3) Select response mode (card vs markdown).
812
840
  // Card creation runs BEFORE media download so the user sees immediate visual
813
841
  // feedback while large files are still being downloaded.
842
+ // /btw is gated out here (`!isBtwBypass`) because it must never create a card —
843
+ // it has its own bypass dispatch later that returns before reaching the main
844
+ // run. Abort (/stop) is intentionally NOT gated: in card mode the existing
845
+ // abort branch finalizes the card with the abort confirmation text instead of
846
+ // sending a separate plain-text message.
814
847
  let useCardMode = dingtalkConfig.messageType === "card";
815
848
  let currentAICard: import("./types").AICardInstance | undefined;
816
849
 
817
- if (useCardMode) {
850
+ if (useCardMode && !isBtwBypass) {
818
851
  try {
819
852
  log?.debug?.(
820
853
  `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
821
854
  );
855
+ // quoteContent always shows the inbound message text so the user can
856
+ // identify which of their messages this card is replying to.
857
+ // Use rawInboundText ( preserved before sub-agent rewriting) to avoid
858
+ // showing internal routing context like "[你被 @ 为...]" in the card UI.
859
+ const inboundQuoteText = rawInboundText.slice(0, 200);
822
860
  const aiCard = await createAICard(dingtalkConfig, to, log, {
823
861
  accountId,
824
862
  storePath: accountStorePath,
825
863
  contextConversationId: groupId,
864
+ quoteContent: inboundQuoteText,
865
+ statusLine: initialStatusLine,
826
866
  });
827
867
  if (aiCard) {
828
868
  currentAICard = aiCard;
@@ -848,11 +888,6 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
848
888
  );
849
889
  }
850
890
  }
851
-
852
- const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
853
- const quotedRef = buildInboundQuotedRef(data, extractedContent);
854
- const replyQuotedRef = createReplyQuotedRef(data.msgId);
855
- const content = extractedContent;
856
891
  const hasLegacyQuoteContent =
857
892
  typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
858
893
 
@@ -1436,7 +1471,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1436
1471
  // "@Agent /stop" are correctly recognised as abort requests in both DM and group
1437
1472
  // chats. In groups DingTalk usually strips @BotName at the protocol level, but
1438
1473
  // in DMs with multi-agent routing the @mention prefix survives all the way here.
1439
- const textForAbortCheck = inboundText.replace(/^(?:@\S+\s+)*/u, "").trim();
1474
+ const textForAbortCheck = stripLeadingMentions(inboundText).trim();
1440
1475
  if (isAbortRequestText(textForAbortCheck)) {
1441
1476
  log?.info?.(
1442
1477
  `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
@@ -1488,9 +1523,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1488
1523
  }
1489
1524
  // Finalize the card that was created for this message before the abort check.
1490
1525
  // Without this, the card stays in PROCESSING ("处理中...") indefinitely.
1526
+ // Use V2 finalize (commitAICardBlocks) for consistent state transition.
1491
1527
  if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
1492
1528
  try {
1493
- await finishAICard(currentAICard, abortConfirmationText ?? "已停止", log);
1529
+ const abortText = abortConfirmationText ?? "已停止";
1530
+ const abortBlockList = [{ type: 0, markdown: abortText }];
1531
+ const blockListJson = JSON.stringify(abortBlockList);
1532
+
1533
+ await commitAICardBlocks(currentAICard, {
1534
+ blockListJson,
1535
+ content: abortText,
1536
+ }, log);
1537
+
1538
+ log?.debug?.(`[DingTalk] Abort card finalized via V2 API: card=${currentAICard.cardInstanceId}`);
1494
1539
  } catch (cardErr) {
1495
1540
  log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
1496
1541
  currentAICard.state = AICardStatus.FAILED;
@@ -1499,6 +1544,61 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1499
1544
  return;
1500
1545
  }
1501
1546
 
1547
+ // ---- Pre-lock BTW: bypass session lock for /btw side questions ----
1548
+ // /btw runs an isolated, tool-less side query in openclaw without polluting
1549
+ // the main run's transcript. The dispatch must NOT acquire the session lock,
1550
+ // otherwise it would queue behind the in-flight main task and lose its "side
1551
+ // question" semantics.
1552
+ //
1553
+ // The `isBtwBypass` flag is computed once early (just after `content` is
1554
+ // resolved, just before `createAICard`). The same constant gates `createAICard` above and
1555
+ // drives this branch — single decision, two consequences. See the comment
1556
+ // at the flag's definition for why /btw uses pre-OCR `content.text` while
1557
+ // abort uses `inboundText`.
1558
+ if (isBtwBypass) {
1559
+ log?.info?.(
1560
+ `[DingTalk] BTW request detected, bypassing session lock for session=${route.sessionKey}`,
1561
+ );
1562
+ // Empty fallback (NOT "Unknown" like the file's main `senderName` variable):
1563
+ // when the nickname is missing we want the blockquote to render as
1564
+ // `> /btw <question>` rather than `> Unknown: /btw <question>`. Read locally
1565
+ // here so the existing `senderName` semantics elsewhere in the file are not
1566
+ // affected.
1567
+ const btwSenderName = data.senderNick || "";
1568
+ try {
1569
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1570
+ ctx,
1571
+ cfg,
1572
+ dispatcherOptions: {
1573
+ responsePrefix: "",
1574
+ deliver: async (payload) => {
1575
+ if (!payload.text) {
1576
+ log?.debug?.(`[DingTalk] BTW deliver received non-text payload, skipping`);
1577
+ return;
1578
+ }
1579
+ await deliverBtwReply({
1580
+ config: dingtalkConfig,
1581
+ sessionWebhook,
1582
+ conversationId: groupId,
1583
+ to,
1584
+ senderName: btwSenderName,
1585
+ // Use pre-OCR content.text (consistent with isBtwBypass detection)
1586
+ // so the blockquote shows the user's typed body, not body + OCR.
1587
+ rawQuestion: content.text,
1588
+ replyText: payload.text,
1589
+ log,
1590
+ accountId,
1591
+ storePath: accountStorePath,
1592
+ });
1593
+ },
1594
+ },
1595
+ });
1596
+ } catch (btwErr) {
1597
+ log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
1598
+ }
1599
+ return;
1600
+ }
1601
+
1502
1602
  const ackReaction =
1503
1603
  typeof dingtalkConfig.ackReaction === "string"
1504
1604
  ? dingtalkConfig.ackReaction.trim()
@@ -1784,6 +1884,20 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1784
1884
  legacyCardStreamReasoning === undefined
1785
1885
  ? dingtalkConfig
1786
1886
  : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
1887
+ const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
1888
+ const taskMeta = {
1889
+ model: sessionTaskState?.model,
1890
+ effort: sessionTaskState?.effort,
1891
+ elapsedMs: typeof sessionTaskState?.taskStartTime === "number"
1892
+ ? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
1893
+ : undefined,
1894
+ agent: getAgentDisplayName({
1895
+ subAgentOptions,
1896
+ agentId: route.agentId,
1897
+ agentsList: cfg.agents?.list,
1898
+ }),
1899
+ };
1900
+
1787
1901
  const strategy = createReplyStrategy({
1788
1902
  config: strategyConfig,
1789
1903
  card: currentAICard,
@@ -1807,6 +1921,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1807
1921
  replyQuotedRef,
1808
1922
  deliverMedia: deliverMediaAttachments,
1809
1923
  isStopRequested: isCurrentCardStopRequested,
1924
+ inboundText: rawInboundText,
1925
+ taskMeta,
1810
1926
  });
1811
1927
 
1812
1928
  try {
@@ -1863,19 +1979,26 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1863
1979
  `queuedFinalType=${typeof bufferedFinal}`,
1864
1980
  );
1865
1981
 
1866
- if (deliveredFinalCount === 0 && typeof bufferedFinal === "string" && bufferedFinal.trim()) {
1867
- const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinal);
1868
- const richBufferedPayload = {
1869
- text: bufferedFinal,
1870
- mediaUrls: [],
1871
- } as ReplyStreamPayload;
1872
- await strategy.deliver({
1873
- text: inlineReplyPayload.text,
1874
- mediaUrls: extractMediaUrls(richBufferedPayload, inlineReplyPayload),
1875
- audioAsVoice: extractSharedAudioAsVoice(richBufferedPayload, inlineReplyPayload),
1876
- kind: "final",
1877
- isReasoning: false,
1878
- });
1982
+ const bufferedFinalPayload =
1983
+ typeof bufferedFinal === "string"
1984
+ ? ({ text: bufferedFinal } satisfies ReplyStreamPayload)
1985
+ : bufferedFinal && typeof bufferedFinal === "object"
1986
+ ? (bufferedFinal as ReplyStreamPayload)
1987
+ : undefined;
1988
+
1989
+ if (deliveredFinalCount === 0 && bufferedFinalPayload) {
1990
+ const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinalPayload.text);
1991
+ const mediaUrls = extractMediaUrls(bufferedFinalPayload, inlineReplyPayload);
1992
+ const hasBufferedText = typeof bufferedFinalPayload.text === "string" && bufferedFinalPayload.text.trim().length > 0;
1993
+ if (hasBufferedText || mediaUrls.length > 0) {
1994
+ await strategy.deliver({
1995
+ text: inlineReplyPayload.text,
1996
+ mediaUrls,
1997
+ audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
1998
+ kind: "final",
1999
+ isReasoning: bufferedFinalPayload.isReasoning === true,
2000
+ });
2001
+ }
1879
2002
  }
1880
2003
  } catch (dispatchErr: unknown) {
1881
2004
  const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
@@ -534,6 +534,12 @@ export function resolveOutboundMediaType(params: {
534
534
  return explicitType;
535
535
  }
536
536
 
537
+ // Audio files default to "file" (attachment) unless asVoice is explicitly set.
538
+ // This prevents mp3/wav/ogg/amr from being sent as voice messages unexpectedly.
539
+ if (detectedType === "voice") {
540
+ return "file";
541
+ }
542
+
537
543
  return detectedType;
538
544
  }
539
545
 
@@ -1,11 +1,18 @@
1
- import type { AtMention, DingTalkInboundMessage, MessageContent, QuotedInfo, SendMessageOptions } from "./types";
2
-
1
+ import type {
2
+ AtMention,
3
+ DingTalkInboundMessage,
4
+ MessageContent,
5
+ QuotedInfo,
6
+ SendMessageOptions,
7
+ } from "./types";
3
8
 
4
9
  interface DingTalkDocMeta {
5
10
  spaceId: string;
6
11
  fileId: string;
7
12
  }
8
13
 
14
+ const UNKNOWN_PERSON_LABEL = "某人";
15
+
9
16
  function parseBizCustomActionUrl(url: string | undefined): DingTalkDocMeta | null {
10
17
  if (!url || typeof url !== "string") {
11
18
  return null;
@@ -76,7 +83,7 @@ function extractRichTextQuoteParts(
76
83
  ? part.atName
77
84
  : typeof textValue === "string"
78
85
  ? textValue
79
- : "某人";
86
+ : UNKNOWN_PERSON_LABEL;
80
87
  textParts.push(`@${atName}`);
81
88
  continue;
82
89
  }
@@ -94,7 +101,8 @@ function extractRichTextQuoteParts(
94
101
  return {
95
102
  summary,
96
103
  pictureDownloadCode,
97
- pictureDownloadCodes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
104
+ pictureDownloadCodes:
105
+ uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
98
106
  };
99
107
  }
100
108
 
@@ -115,7 +123,101 @@ function trimString(value: string | undefined): string | undefined {
115
123
  return trimmed.length > 0 ? trimmed : undefined;
116
124
  }
117
125
 
118
- function buildQuotedMessageTypePlaceholder(messageType: string | undefined, fileName?: string): string | undefined {
126
+ const MAX_CHAT_RECORD_ENTRIES = 30;
127
+
128
+ function stringifyChatRecordContentValue(value: unknown): string | undefined {
129
+ if (typeof value === "string") {
130
+ return trimString(value);
131
+ }
132
+ if (!value || typeof value !== "object") {
133
+ return undefined;
134
+ }
135
+ const record = value as Record<string, unknown>;
136
+ const content = record.content;
137
+ const contentText =
138
+ typeof content === "string"
139
+ ? trimString(content)
140
+ : content && typeof content === "object"
141
+ ? trimString((content as Record<string, unknown>).text as string | undefined)
142
+ : undefined;
143
+
144
+ // Preserve DingTalk's observed chatRecord text priority: explicit text first,
145
+ // then nested content text, then the legacy message fallback.
146
+ return (
147
+ trimString(record.text as string | undefined) ||
148
+ contentText ||
149
+ trimString(record.message as string | undefined)
150
+ );
151
+ }
152
+
153
+ function getChatRecordEntriesSource(content: Record<string, unknown> | undefined): unknown {
154
+ return content?.chatRecord ?? content?.records ?? content?.messages;
155
+ }
156
+
157
+ function formatChatRecordEntries(rawRecord: unknown): string[] {
158
+ let entries = rawRecord;
159
+ if (typeof rawRecord === "string") {
160
+ const trimmed = rawRecord.trim();
161
+ if (!trimmed || trimmed === "[]") {
162
+ return [];
163
+ }
164
+ try {
165
+ entries = JSON.parse(trimmed);
166
+ } catch {
167
+ return [];
168
+ }
169
+ }
170
+ if (!Array.isArray(entries)) {
171
+ return [];
172
+ }
173
+ return entries
174
+ .map((entry) => {
175
+ if (!entry || typeof entry !== "object") {
176
+ return undefined;
177
+ }
178
+ const record = entry as Record<string, unknown>;
179
+ const sender =
180
+ trimString(record.senderName as string | undefined) ||
181
+ trimString(record.senderNick as string | undefined) ||
182
+ trimString(record.sender as string | undefined) ||
183
+ trimString(record.senderId as string | undefined) ||
184
+ UNKNOWN_PERSON_LABEL;
185
+ const body = stringifyChatRecordContentValue(
186
+ record.content ?? record.text ?? record.message ?? record.body,
187
+ );
188
+ return body ? `${sender}: ${body}` : undefined;
189
+ })
190
+ .filter((line): line is string => Boolean(line))
191
+ .slice(0, MAX_CHAT_RECORD_ENTRIES);
192
+ }
193
+
194
+ function formatChatRecordPreview(
195
+ content: Record<string, unknown> | undefined,
196
+ options: { useTitleAsLabel?: boolean } = {},
197
+ ): string | undefined {
198
+ const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
199
+ const title = typeof content?.title === "string" ? content.title.trim() : "";
200
+ const rawRecord = getChatRecordEntriesSource(content);
201
+ const recordLines = formatChatRecordEntries(rawRecord);
202
+ const parts: string[] = [];
203
+ if (summary && summary !== "[]") {
204
+ const label = options.useTitleAsLabel
205
+ ? title
206
+ ? `[${title}] `
207
+ : "[聊天记录] "
208
+ : "[聊天记录摘要] ";
209
+ parts.push(`${label}${summary}`);
210
+ }
211
+ if (recordLines.length > 0) {
212
+ parts.push(`[聊天记录内容]\n${recordLines.join("\n")}`);
213
+ }
214
+ return parts.join("\n\n") || undefined;
215
+ }
216
+
217
+ function buildQuotedMessageTypePlaceholder(
218
+ messageType: string | undefined,
219
+ fileName?: string,
220
+ ): string | undefined {
119
221
  switch (messageType) {
120
222
  case "text":
121
223
  return undefined;
@@ -147,8 +249,7 @@ function buildLegacyQuoteMessagePreview(message: DingTalkInboundMessage["quoteMe
147
249
  const previewMessageType = trimString(message?.msgtype);
148
250
  return {
149
251
  previewText:
150
- trimString(message?.text?.content) ||
151
- buildQuotedMessageTypePlaceholder(previewMessageType),
252
+ trimString(message?.text?.content) || buildQuotedMessageTypePlaceholder(previewMessageType),
152
253
  previewMessageType,
153
254
  previewSenderId: trimString(message?.senderId),
154
255
  };
@@ -202,7 +303,10 @@ function buildRepliedMessagePreview(params: {
202
303
  return {
203
304
  isQuotedFile: true,
204
305
  fileCreatedAt: repliedMsg.createdAt,
205
- previewText: buildQuotedMessageTypePlaceholder(repliedMsgType, hasFileName ? fileName : undefined),
306
+ previewText: buildQuotedMessageTypePlaceholder(
307
+ repliedMsgType,
308
+ hasFileName ? fileName : undefined,
309
+ ),
206
310
  previewMessageType: repliedMsgType,
207
311
  ...(hasFileName ? { previewFileName: fileName } : {}),
208
312
  previewSenderId: trimString(repliedMsg.senderId),
@@ -235,11 +339,11 @@ function buildRepliedMessagePreview(params: {
235
339
  }
236
340
 
237
341
  if (repliedMsgType === "chatRecord") {
238
- const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
239
- const title = typeof content?.title === "string" ? content.title.trim() : "";
240
- const chatRecordLabel = title ? `[${title}] ` : "[聊天记录] ";
241
342
  return {
242
- previewText: summary ? `${chatRecordLabel}${summary}` : buildQuotedMessageTypePlaceholder("chatRecord"),
343
+ previewText:
344
+ formatChatRecordPreview(content as Record<string, unknown> | undefined, {
345
+ useTitleAsLabel: true,
346
+ }) || buildQuotedMessageTypePlaceholder("chatRecord"),
243
347
  previewMessageType: "chatRecord",
244
348
  previewSenderId: trimString(repliedMsg.senderId),
245
349
  };
@@ -517,7 +621,10 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
517
621
  mediaPath: pictureDownloadCode,
518
622
  mediaPaths: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
519
623
  mediaType: pictureDownloadCode ? "image" : undefined,
520
- mediaTypes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes.map(() => "image") : undefined,
624
+ mediaTypes:
625
+ uniquePictureDownloadCodes.length > 0
626
+ ? uniquePictureDownloadCodes.map(() => "image")
627
+ : undefined,
521
628
  messageType: "richText",
522
629
  quoted: quoted ?? undefined,
523
630
  atMentions,
@@ -605,7 +712,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
605
712
  if (msgtype === "chatRecord") {
606
713
  const content = data.content as Record<string, unknown> | undefined;
607
714
  const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
608
- const rawRecord = content?.chatRecord;
715
+ const rawRecord = getChatRecordEntriesSource(content);
716
+ const chatRecordText = formatChatRecordPreview(content);
609
717
  if (
610
718
  summary === "[]" ||
611
719
  (typeof rawRecord === "string" && rawRecord.trim() === "[]") ||
@@ -619,9 +727,9 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
619
727
  atUserDingtalkIds,
620
728
  };
621
729
  }
622
- if (summary) {
730
+ if (chatRecordText) {
623
731
  return {
624
- text: `[聊天记录摘要] ${summary}`,
732
+ text: chatRecordText,
625
733
  messageType: "chatRecord",
626
734
  quoted: quoted ?? undefined,
627
735
  atMentions,
@@ -0,0 +1,85 @@
1
+ import { sendMessage } from "../send-service";
2
+ import type { DingTalkConfig, Logger } from "../types";
3
+
4
+ const MAX_QUESTION_LENGTH = 80;
5
+ const LEADING_MENTIONS_RE = /^(?:@\S+\s+)*/u;
6
+
7
+ /**
8
+ * Strip leading `@mention` tokens from inbound text. Used by both the abort and
9
+ * BTW bypass branches in `inbound-handler.ts` so that command detection works
10
+ * uniformly in DM and group chats.
11
+ */
12
+ export function stripLeadingMentions(text: string): string {
13
+ return text.replace(LEADING_MENTIONS_RE, "");
14
+ }
15
+
16
+ export function buildBtwBlockquote(senderName: string, rawQuestion: string): string {
17
+ const stripped = stripLeadingMentions(rawQuestion);
18
+ // Iterate by Unicode code points (not UTF-16 code units) so emoji /
19
+ // surrogate pairs aren't sliced in half at the truncation boundary.
20
+ const codePoints = Array.from(stripped);
21
+ const truncated =
22
+ codePoints.length > MAX_QUESTION_LENGTH
23
+ ? `${codePoints.slice(0, MAX_QUESTION_LENGTH).join("")}…`
24
+ : stripped;
25
+ const senderPrefix = senderName ? `${senderName}: ` : "";
26
+ return `> ${senderPrefix}${truncated}\n\n`;
27
+ }
28
+
29
+ export interface DeliverBtwReplyArgs {
30
+ config: DingTalkConfig;
31
+ sessionWebhook: string | undefined;
32
+ conversationId: string;
33
+ to: string;
34
+ senderName: string;
35
+ rawQuestion: string;
36
+ replyText: string;
37
+ log: Logger | undefined;
38
+ accountId?: string;
39
+ storePath?: string;
40
+ }
41
+
42
+ /**
43
+ * Deliver a BTW reply through the unified `sendMessage` entry point.
44
+ *
45
+ * BTW is a special inbound trigger, but the *outbound* reply is still a regular
46
+ * markdown/text message and must inherit the standard send-service semantics:
47
+ * persistence into the message context store, delivery metadata tracking, and
48
+ * the single `{ ok, error, ... }` contract. We pass `forceMarkdown: true` so
49
+ * that `sendMessage` skips the card branch even when the channel is configured
50
+ * for card mode — BTW must never create or touch an AI Card (see CLAUDE.md
51
+ * anti-pattern: "Do not create multiple active AI Cards for the same
52
+ * `accountId:conversationId`").
53
+ *
54
+ * When `sessionWebhook` is present `sendMessage` internally dispatches via
55
+ * `sendBySession`; otherwise it falls back to the proactive text/markdown API.
56
+ * Either way the caller sees the same return shape, and failures propagate as
57
+ * `{ ok: false }` instead of being silently swallowed.
58
+ */
59
+ export async function deliverBtwReply(
60
+ args: DeliverBtwReplyArgs,
61
+ ): Promise<{ ok: boolean; error?: string }> {
62
+ const blockquote = buildBtwBlockquote(args.senderName, args.rawQuestion);
63
+ const fullText = `${blockquote}${args.replyText}`;
64
+
65
+ try {
66
+ const result = await sendMessage(args.config, args.to, fullText, {
67
+ log: args.log,
68
+ accountId: args.accountId,
69
+ storePath: args.storePath,
70
+ conversationId: args.conversationId,
71
+ sessionWebhook: args.sessionWebhook,
72
+ forceMarkdown: true,
73
+ });
74
+ if (!result.ok) {
75
+ args.log?.warn?.(
76
+ `[DingTalk] BTW reply delivery returned not-ok: ${result.error ?? "unknown"}`,
77
+ );
78
+ }
79
+ return { ok: result.ok, error: result.error };
80
+ } catch (err) {
81
+ const error = err instanceof Error ? err.message : String(err);
82
+ args.log?.warn?.(`[DingTalk] BTW reply delivery threw: ${error}`);
83
+ return { ok: false, error };
84
+ }
85
+ }