@soimy/dingtalk 3.5.2 → 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.
Files changed (40) hide show
  1. package/README.md +6 -23
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +799 -0
  4. package/package.json +5 -5
  5. package/src/card/card-markdown-image-reroute.ts +106 -0
  6. package/src/card/card-run-registry.ts +54 -1
  7. package/src/card/card-stop-handler.ts +10 -20
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/reasoning-answer-split.ts +162 -0
  11. package/src/card/statusline-renderer.ts +94 -0
  12. package/src/card-draft-controller.ts +326 -54
  13. package/src/card-service.ts +479 -8
  14. package/src/channel.ts +19 -1062
  15. package/src/config-schema.ts +81 -38
  16. package/src/config.ts +142 -4
  17. package/src/device-registration.ts +245 -0
  18. package/src/gateway/channel-gateway.ts +636 -0
  19. package/src/inbound-handler.ts +489 -49
  20. package/src/media-utils.ts +169 -7
  21. package/src/message-utils.ts +153 -17
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +173 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/messaging/quoted-file-service.ts +9 -4
  26. package/src/onboarding.ts +323 -205
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +568 -44
  30. package/src/reply-strategy-markdown.ts +2 -2
  31. package/src/reply-strategy-types.ts +93 -0
  32. package/src/reply-strategy-with-reaction.ts +1 -1
  33. package/src/reply-strategy.ts +14 -56
  34. package/src/run-usage-store.ts +59 -0
  35. package/src/send-service.ts +225 -7
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +44 -28
  39. package/src/types.ts +49 -117
  40. package/src/utils.ts +25 -0
@@ -13,6 +13,7 @@ import { lookup as dnsLookup } from "node:dns/promises";
13
13
  import { BlockList, isIP } from "node:net";
14
14
  import axios from "./http-client";
15
15
  import FormData from "form-data";
16
+ import { runFfmpeg, runFfprobe } from "openclaw/plugin-sdk/media-runtime";
16
17
  import type { DingTalkConfig, Logger } from "./types";
17
18
  import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
18
19
  import { getDingTalkRuntime } from "./runtime";
@@ -207,6 +208,122 @@ export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, l
207
208
  }
208
209
 
209
210
  const DEFAULT_VOICE_DURATION_MS = 1000;
211
+ const DINGTALK_VOICE_UPLOAD_EXTENSIONS = new Set([".ogg", ".amr"]);
212
+ const DINGTALK_VOICE_INPUT_EXTENSIONS = new Set([".ogg", ".amr", ".mp3", ".wav"]);
213
+
214
+ async function getDurationMsWithFfprobe(filePath: string, log?: Logger): Promise<number> {
215
+ try {
216
+ const stdout = await runFfprobe([
217
+ "-v",
218
+ "error",
219
+ "-show_entries",
220
+ "format=duration",
221
+ "-of",
222
+ "csv=p=0",
223
+ filePath,
224
+ ]);
225
+ const duration = Number.parseFloat(stdout.trim());
226
+ if (!Number.isFinite(duration) || duration <= 0) {
227
+ return 0;
228
+ }
229
+ return Math.max(1, Math.round(duration * 1000));
230
+ } catch (err: unknown) {
231
+ log?.warn?.(`[DingTalk] Failed to probe voice duration: ${err instanceof Error ? err.message : String(err)}`);
232
+ return 0;
233
+ }
234
+ }
235
+
236
+ async function prepareVoiceUploadPath(
237
+ mediaPath: string,
238
+ log?: Logger,
239
+ ): Promise<{ path: string; cleanup?: () => Promise<void> }> {
240
+ const ext = path.extname(mediaPath).toLowerCase();
241
+ if (DINGTALK_VOICE_UPLOAD_EXTENSIONS.has(ext)) {
242
+ return { path: mediaPath };
243
+ }
244
+
245
+ const outputPath = path.join(os.tmpdir(), `dingtalk_voice_${randomUUID()}.ogg`);
246
+ await runFfmpeg([
247
+ "-y",
248
+ "-i",
249
+ mediaPath,
250
+ "-vn",
251
+ "-sn",
252
+ "-dn",
253
+ "-ar",
254
+ "16000",
255
+ "-ac",
256
+ "1",
257
+ "-c:a",
258
+ "libopus",
259
+ "-b:a",
260
+ "24k",
261
+ outputPath,
262
+ ]);
263
+
264
+ log?.debug?.(`[DingTalk] Transcoded voice upload to OGG: ${mediaPath} -> ${outputPath}`);
265
+
266
+ return {
267
+ path: outputPath,
268
+ cleanup: async () => {
269
+ await fsPromises.rm(outputPath, { force: true });
270
+ },
271
+ };
272
+ }
273
+
274
+ function getWavDurationMsFromBuffer(buffer: Buffer, log?: Logger): number {
275
+ try {
276
+ if (buffer.length < 44 || buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") {
277
+ return 0;
278
+ }
279
+
280
+ let offset = 12;
281
+ let byteRate = 0;
282
+ let sampleRate = 0;
283
+ let channels = 0;
284
+ let bitsPerSample = 0;
285
+ let dataSize = 0;
286
+
287
+ while (offset + 8 <= buffer.length) {
288
+ const chunkId = buffer.toString("ascii", offset, offset + 4);
289
+ const chunkSize = buffer.readUInt32LE(offset + 4);
290
+ const chunkDataStart = offset + 8;
291
+ const paddedChunkSize = chunkSize + (chunkSize % 2);
292
+
293
+ if (chunkDataStart + chunkSize > buffer.length) {
294
+ break;
295
+ }
296
+
297
+ if (chunkId === "fmt " && chunkSize >= 16) {
298
+ channels = buffer.readUInt16LE(chunkDataStart + 2);
299
+ sampleRate = buffer.readUInt32LE(chunkDataStart + 4);
300
+ byteRate = buffer.readUInt32LE(chunkDataStart + 8);
301
+ bitsPerSample = buffer.readUInt16LE(chunkDataStart + 14);
302
+ } else if (chunkId === "data") {
303
+ dataSize = chunkSize;
304
+ }
305
+
306
+ if (dataSize > 0 && (byteRate > 0 || (sampleRate > 0 && channels > 0 && bitsPerSample > 0))) {
307
+ break;
308
+ }
309
+
310
+ offset = chunkDataStart + paddedChunkSize;
311
+ }
312
+
313
+ const effectiveByteRate = byteRate || (sampleRate > 0 && channels > 0 && bitsPerSample > 0
314
+ ? sampleRate * channels * (bitsPerSample / 8)
315
+ : 0);
316
+
317
+ if (effectiveByteRate <= 0 || dataSize <= 0) {
318
+ return 0;
319
+ }
320
+
321
+ return Math.max(1, Math.round((dataSize / effectiveByteRate) * 1000));
322
+ } catch (err: unknown) {
323
+ log?.warn?.(`[DingTalk] Failed to parse WAV duration: ${err instanceof Error ? err.message : String(err)}`);
324
+ return 0;
325
+ }
326
+ }
210
327
 
211
328
  export async function getVoiceDurationMs(
212
329
  filePath: string,
@@ -240,6 +357,26 @@ export async function getVoiceDurationMs(
240
357
  return DEFAULT_VOICE_DURATION_MS;
241
358
  }
242
359
 
360
+ if (ext === ".wav") {
361
+ try {
362
+ const buffer = options?.preReadBuffer
363
+ ?? (await readMediaBuffer(filePath, options, log)).buffer;
364
+ const durationMs = getWavDurationMsFromBuffer(buffer, log);
365
+ if (durationMs > 0) {
366
+ return durationMs;
367
+ }
368
+ } catch {
369
+ // Fall through to the safe default below.
370
+ }
371
+ }
372
+
373
+ if (ext === ".ogg" || ext === ".amr") {
374
+ const durationMs = await getDurationMsWithFfprobe(filePath, log);
375
+ if (durationMs > 0) {
376
+ return durationMs;
377
+ }
378
+ }
379
+
243
380
  return DEFAULT_VOICE_DURATION_MS;
244
381
  }
245
382
 
@@ -339,7 +476,7 @@ function isAllowedByMediaUrlAllowlist(url: URL, mediaUrlAllowlist: string[]): bo
339
476
  * Detect media type from file extension
340
477
  * Matches DingTalk's supported media types:
341
478
  * - image: jpg, gif, png, bmp (max 20MB)
342
- * - voice: amr, mp3, wav (max 2MB)
479
+ * - voice: ogg, amr, mp3, wav (max 2MB)
343
480
  * - video: mp4 (max 20MB)
344
481
  * - file: doc, docx, xls, xlsx, ppt, pptx, zip, pdf, rar (max 20MB)
345
482
  *
@@ -351,7 +488,7 @@ export function detectMediaTypeFromExtension(filePath: string): DingTalkMediaTyp
351
488
 
352
489
  if ([".jpg", ".jpeg", ".png", ".gif", ".bmp"].includes(ext)) {
353
490
  return "image";
354
- } else if ([".mp3", ".amr", ".wav"].includes(ext)) {
491
+ } else if ([".ogg", ".mp3", ".amr", ".wav"].includes(ext)) {
355
492
  return "voice";
356
493
  } else if ([".mp4", ".avi", ".mov"].includes(ext)) {
357
494
  return "video";
@@ -386,8 +523,8 @@ export function resolveOutboundMediaType(params: {
386
523
  throw new Error('asVoice requires mediaType="voice" when mediaType is provided.');
387
524
  }
388
525
 
389
- if (detectedType !== "voice") {
390
- throw new Error("asVoice requires an audio file (mp3, amr, wav).");
526
+ if (detectedType !== "voice" || !DINGTALK_VOICE_INPUT_EXTENSIONS.has(path.extname(params.mediaPath).toLowerCase())) {
527
+ throw new Error("asVoice requires an audio file (ogg, amr, mp3, wav).");
391
528
  }
392
529
 
393
530
  return "voice";
@@ -397,6 +534,12 @@ export function resolveOutboundMediaType(params: {
397
534
  return explicitType;
398
535
  }
399
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
+
400
543
  return detectedType;
401
544
  }
402
545
 
@@ -687,6 +830,12 @@ export interface UploadMediaResult {
687
830
  mediaId: string;
688
831
  /** The file buffer read during upload, reusable for voice duration parsing etc. */
689
832
  buffer: Buffer;
833
+ /**
834
+ * Voice duration captured before any temporary transcoded file is cleaned up.
835
+ * This is the stable field callers should use instead of depending on any
836
+ * upload-time temp path lifecycle.
837
+ */
838
+ durationMs?: number;
690
839
  }
691
840
 
692
841
  export async function uploadMedia(
@@ -697,11 +846,22 @@ export async function uploadMedia(
697
846
  log?: Logger,
698
847
  options?: { mediaLocalRoots?: string[] },
699
848
  ): Promise<UploadMediaResult | null> {
849
+ let voicePreparedCleanup: (() => Promise<void>) | undefined;
700
850
  try {
701
851
  const token = await getAccessToken(config, log);
852
+ let resolvedMediaPath = mediaPath;
853
+
854
+ if (mediaType === "voice") {
855
+ const prepared = await prepareVoiceUploadPath(mediaPath, log);
856
+ resolvedMediaPath = prepared.path;
857
+ voicePreparedCleanup = prepared.cleanup;
858
+ }
702
859
 
703
860
  // Read file via sandbox-aware bridge (falls back to direct fs for host paths)
704
- const { buffer, size } = await readMediaBuffer(mediaPath, options, log);
861
+ const { buffer, size } = await readMediaBuffer(resolvedMediaPath, options, log);
862
+ const durationMs = mediaType === "voice"
863
+ ? await getVoiceDurationMs(resolvedMediaPath, mediaType, log, { ...options, preReadBuffer: buffer })
864
+ : undefined;
705
865
 
706
866
  // Check file size
707
867
  const sizeLimit = FILE_SIZE_LIMITS[mediaType];
@@ -714,7 +874,7 @@ export async function uploadMedia(
714
874
  return null;
715
875
  }
716
876
 
717
- const filename = path.basename(mediaPath);
877
+ const filename = path.basename(resolvedMediaPath);
718
878
 
719
879
  // Upload to DingTalk's media server using form-data
720
880
  const form = new FormData();
@@ -735,7 +895,7 @@ export async function uploadMedia(
735
895
  log?.debug?.(
736
896
  `[DingTalk] Media uploaded successfully: ${response.data.media_id} (${size} bytes)`,
737
897
  );
738
- return { mediaId: response.data.media_id, buffer };
898
+ return { mediaId: response.data.media_id, buffer, durationMs };
739
899
  } else {
740
900
  log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
741
901
  return null;
@@ -758,5 +918,7 @@ export async function uploadMedia(
758
918
  }
759
919
  }
760
920
  return null;
921
+ } finally {
922
+ await voicePreparedCleanup?.();
761
923
  }
762
924
  }
@@ -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,10 +101,20 @@ 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
 
109
+ function extractAtMentionsFromText(text: string): AtMention[] {
110
+ const mentions: AtMention[] = [];
111
+ const matches = text.matchAll(/(?<!\w)@([^\s@.]+)(?!\.\w)/g);
112
+ for (const match of matches) {
113
+ mentions.push({ name: match[1].trim() });
114
+ }
115
+ return mentions;
116
+ }
117
+
101
118
  function trimString(value: string | undefined): string | undefined {
102
119
  if (typeof value !== "string") {
103
120
  return undefined;
@@ -106,7 +123,101 @@ function trimString(value: string | undefined): string | undefined {
106
123
  return trimmed.length > 0 ? trimmed : undefined;
107
124
  }
108
125
 
109
- 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 {
110
221
  switch (messageType) {
111
222
  case "text":
112
223
  return undefined;
@@ -138,8 +249,7 @@ function buildLegacyQuoteMessagePreview(message: DingTalkInboundMessage["quoteMe
138
249
  const previewMessageType = trimString(message?.msgtype);
139
250
  return {
140
251
  previewText:
141
- trimString(message?.text?.content) ||
142
- buildQuotedMessageTypePlaceholder(previewMessageType),
252
+ trimString(message?.text?.content) || buildQuotedMessageTypePlaceholder(previewMessageType),
143
253
  previewMessageType,
144
254
  previewSenderId: trimString(message?.senderId),
145
255
  };
@@ -193,7 +303,10 @@ function buildRepliedMessagePreview(params: {
193
303
  return {
194
304
  isQuotedFile: true,
195
305
  fileCreatedAt: repliedMsg.createdAt,
196
- previewText: buildQuotedMessageTypePlaceholder(repliedMsgType, hasFileName ? fileName : undefined),
306
+ previewText: buildQuotedMessageTypePlaceholder(
307
+ repliedMsgType,
308
+ hasFileName ? fileName : undefined,
309
+ ),
197
310
  previewMessageType: repliedMsgType,
198
311
  ...(hasFileName ? { previewFileName: fileName } : {}),
199
312
  previewSenderId: trimString(repliedMsg.senderId),
@@ -225,6 +338,17 @@ function buildRepliedMessagePreview(params: {
225
338
  };
226
339
  }
227
340
 
341
+ if (repliedMsgType === "chatRecord") {
342
+ return {
343
+ previewText:
344
+ formatChatRecordPreview(content as Record<string, unknown> | undefined, {
345
+ useTitleAsLabel: true,
346
+ }) || buildQuotedMessageTypePlaceholder("chatRecord"),
347
+ previewMessageType: "chatRecord",
348
+ previewSenderId: trimString(repliedMsg.senderId),
349
+ };
350
+ }
351
+
228
352
  const textPreview =
229
353
  trimString(content?.text) ||
230
354
  trimString(richTextQuote?.summary) ||
@@ -458,11 +582,7 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
458
582
 
459
583
  // Strip quoted prefix before extracting @mentions to avoid matching @names inside quotes.
460
584
  const textForAtExtraction = textContent.replace(/^\[引用[^\]]*\]\s*/, "");
461
- // Match @name but exclude email-like patterns (user@domain.com) and emoji (@_@).
462
- const atMatches = textForAtExtraction.matchAll(/(?<!\w)@([^\s@.]+)(?!\.\w)/g);
463
- for (const match of atMatches) {
464
- atMentions.push({ name: match[1].trim() });
465
- }
585
+ atMentions.push(...extractAtMentionsFromText(textForAtExtraction));
466
586
 
467
587
  return {
468
588
  text: textContent || quoted?.previewText || "",
@@ -501,7 +621,10 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
501
621
  mediaPath: pictureDownloadCode,
502
622
  mediaPaths: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
503
623
  mediaType: pictureDownloadCode ? "image" : undefined,
504
- mediaTypes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes.map(() => "image") : undefined,
624
+ mediaTypes:
625
+ uniquePictureDownloadCodes.length > 0
626
+ ? uniquePictureDownloadCodes.map(() => "image")
627
+ : undefined,
505
628
  messageType: "richText",
506
629
  quoted: quoted ?? undefined,
507
630
  atMentions,
@@ -553,6 +676,18 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
553
676
  };
554
677
  }
555
678
 
679
+ if (msgtype === "markdown") {
680
+ const mdText = typeof data.content?.text === "string" ? data.content.text.trim() : "";
681
+ atMentions.push(...extractAtMentionsFromText(mdText));
682
+ return {
683
+ text: mdText || "[markdown消息]",
684
+ messageType: "markdown",
685
+ quoted: quoted ?? undefined,
686
+ atMentions,
687
+ atUserDingtalkIds,
688
+ };
689
+ }
690
+
556
691
  if (msgtype === "interactiveCard") {
557
692
  const docMeta = parseBizCustomActionUrl(data.content?.biz_custom_action_url);
558
693
  if (docMeta) {
@@ -577,7 +712,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
577
712
  if (msgtype === "chatRecord") {
578
713
  const content = data.content as Record<string, unknown> | undefined;
579
714
  const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
580
- const rawRecord = content?.chatRecord;
715
+ const rawRecord = getChatRecordEntriesSource(content);
716
+ const chatRecordText = formatChatRecordPreview(content);
581
717
  if (
582
718
  summary === "[]" ||
583
719
  (typeof rawRecord === "string" && rawRecord.trim() === "[]") ||
@@ -591,9 +727,9 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
591
727
  atUserDingtalkIds,
592
728
  };
593
729
  }
594
- if (summary) {
730
+ if (chatRecordText) {
595
731
  return {
596
- text: `[聊天记录摘要] ${summary}`,
732
+ text: chatRecordText,
597
733
  messageType: "chatRecord",
598
734
  quoted: quoted ?? undefined,
599
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
+ }