@soimy/dingtalk 3.5.1 → 3.5.3

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 (36) hide show
  1. package/README.md +13 -24
  2. package/openclaw.plugin.json +695 -0
  3. package/package.json +12 -7
  4. package/src/ack-reaction-service.ts +1 -1
  5. package/src/auth.ts +1 -1
  6. package/src/card/card-action-handler.ts +1 -1
  7. package/src/card/card-stop-handler.ts +1 -1
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/reasoning-answer-split.ts +162 -0
  10. package/src/card/reasoning-block-assembler.ts +157 -0
  11. package/src/card-callback-service.ts +1 -1
  12. package/src/card-draft-controller.ts +117 -6
  13. package/src/card-service.ts +112 -1
  14. package/src/channel.ts +131 -96
  15. package/src/command/card-stop-command.ts +4 -22
  16. package/src/command/inbound-command-dispatch-service.ts +464 -0
  17. package/src/config-schema.ts +62 -38
  18. package/src/config.ts +25 -3
  19. package/src/docs-service.ts +5 -5
  20. package/src/http-client.ts +20 -0
  21. package/src/inbound-handler.ts +475 -501
  22. package/src/logger-context.ts +16 -2
  23. package/src/media-utils.ts +166 -10
  24. package/src/message-utils.ts +33 -5
  25. package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
  26. package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +14 -9
  27. package/src/onboarding.ts +29 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +294 -28
  30. package/src/reply-strategy-markdown.ts +124 -19
  31. package/src/reply-strategy.ts +22 -2
  32. package/src/send-service.ts +178 -7
  33. package/src/targeting/agent-routing.ts +55 -32
  34. package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
  35. package/src/types.ts +60 -4
  36. package/src/utils.ts +190 -0
@@ -1,17 +1,31 @@
1
1
  import type { Logger } from "./types";
2
2
 
3
3
  let currentLogger: Logger | undefined;
4
+ const loggerByAccountId = new Map<string, Logger>();
4
5
 
5
6
  /**
6
7
  * Persist current request logger for shared services invoked outside handler scope.
7
8
  */
8
- export function setCurrentLogger(log?: Logger): void {
9
+ export function setCurrentLogger(log?: Logger, accountId?: string | null): void {
9
10
  currentLogger = log;
11
+ const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
12
+ if (!normalizedAccountId) {
13
+ return;
14
+ }
15
+ if (log) {
16
+ loggerByAccountId.set(normalizedAccountId, log);
17
+ return;
18
+ }
19
+ loggerByAccountId.delete(normalizedAccountId);
10
20
  }
11
21
 
12
22
  /**
13
23
  * Read current logger bound by inbound handler.
14
24
  */
15
- export function getLogger(): Logger | undefined {
25
+ export function getLogger(accountId?: string | null): Logger | undefined {
26
+ const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
27
+ if (normalizedAccountId) {
28
+ return loggerByAccountId.get(normalizedAccountId);
29
+ }
16
30
  return currentLogger;
17
31
  }
@@ -11,8 +11,9 @@ import * as path from "node:path";
11
11
  import { promises as fsPromises } from "node:fs";
12
12
  import { lookup as dnsLookup } from "node:dns/promises";
13
13
  import { BlockList, isIP } from "node:net";
14
- import axios from "axios";
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";
@@ -26,7 +27,7 @@ interface PluginRuntimeWithMedia {
26
27
  media?: {
27
28
  loadWebMedia(
28
29
  mediaPath: string,
29
- options?: { mediaLocalRoots?: string[] },
30
+ options?: { localRoots?: readonly string[] | "any" },
30
31
  ): Promise<{ buffer: Buffer | ArrayBuffer; fileName?: string; contentType?: string } | null>;
31
32
  };
32
33
  [key: string]: unknown;
@@ -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";
@@ -667,7 +804,7 @@ async function readMediaBuffer(
667
804
  }
668
805
 
669
806
  const media = await rt.media.loadWebMedia(mediaPath, {
670
- mediaLocalRoots: options?.mediaLocalRoots,
807
+ localRoots: options?.mediaLocalRoots,
671
808
  });
672
809
 
673
810
  if (!media || !media.buffer) {
@@ -687,6 +824,12 @@ export interface UploadMediaResult {
687
824
  mediaId: string;
688
825
  /** The file buffer read during upload, reusable for voice duration parsing etc. */
689
826
  buffer: Buffer;
827
+ /**
828
+ * Voice duration captured before any temporary transcoded file is cleaned up.
829
+ * This is the stable field callers should use instead of depending on any
830
+ * upload-time temp path lifecycle.
831
+ */
832
+ durationMs?: number;
690
833
  }
691
834
 
692
835
  export async function uploadMedia(
@@ -697,11 +840,22 @@ export async function uploadMedia(
697
840
  log?: Logger,
698
841
  options?: { mediaLocalRoots?: string[] },
699
842
  ): Promise<UploadMediaResult | null> {
843
+ let voicePreparedCleanup: (() => Promise<void>) | undefined;
700
844
  try {
701
845
  const token = await getAccessToken(config, log);
846
+ let resolvedMediaPath = mediaPath;
847
+
848
+ if (mediaType === "voice") {
849
+ const prepared = await prepareVoiceUploadPath(mediaPath, log);
850
+ resolvedMediaPath = prepared.path;
851
+ voicePreparedCleanup = prepared.cleanup;
852
+ }
702
853
 
703
854
  // Read file via sandbox-aware bridge (falls back to direct fs for host paths)
704
- const { buffer, size } = await readMediaBuffer(mediaPath, options, log);
855
+ const { buffer, size } = await readMediaBuffer(resolvedMediaPath, options, log);
856
+ const durationMs = mediaType === "voice"
857
+ ? await getVoiceDurationMs(resolvedMediaPath, mediaType, log, { ...options, preReadBuffer: buffer })
858
+ : undefined;
705
859
 
706
860
  // Check file size
707
861
  const sizeLimit = FILE_SIZE_LIMITS[mediaType];
@@ -714,7 +868,7 @@ export async function uploadMedia(
714
868
  return null;
715
869
  }
716
870
 
717
- const filename = path.basename(mediaPath);
871
+ const filename = path.basename(resolvedMediaPath);
718
872
 
719
873
  // Upload to DingTalk's media server using form-data
720
874
  const form = new FormData();
@@ -735,7 +889,7 @@ export async function uploadMedia(
735
889
  log?.debug?.(
736
890
  `[DingTalk] Media uploaded successfully: ${response.data.media_id} (${size} bytes)`,
737
891
  );
738
- return { mediaId: response.data.media_id, buffer };
892
+ return { mediaId: response.data.media_id, buffer, durationMs };
739
893
  } else {
740
894
  log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
741
895
  return null;
@@ -758,5 +912,7 @@ export async function uploadMedia(
758
912
  }
759
913
  }
760
914
  return null;
915
+ } finally {
916
+ await voicePreparedCleanup?.();
761
917
  }
762
918
  }
@@ -98,6 +98,15 @@ function extractRichTextQuoteParts(
98
98
  };
99
99
  }
100
100
 
101
+ function extractAtMentionsFromText(text: string): AtMention[] {
102
+ const mentions: AtMention[] = [];
103
+ const matches = text.matchAll(/(?<!\w)@([^\s@.]+)(?!\.\w)/g);
104
+ for (const match of matches) {
105
+ mentions.push({ name: match[1].trim() });
106
+ }
107
+ return mentions;
108
+ }
109
+
101
110
  function trimString(value: string | undefined): string | undefined {
102
111
  if (typeof value !== "string") {
103
112
  return undefined;
@@ -225,6 +234,17 @@ function buildRepliedMessagePreview(params: {
225
234
  };
226
235
  }
227
236
 
237
+ 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
+ return {
242
+ previewText: summary ? `${chatRecordLabel}${summary}` : buildQuotedMessageTypePlaceholder("chatRecord"),
243
+ previewMessageType: "chatRecord",
244
+ previewSenderId: trimString(repliedMsg.senderId),
245
+ };
246
+ }
247
+
228
248
  const textPreview =
229
249
  trimString(content?.text) ||
230
250
  trimString(richTextQuote?.summary) ||
@@ -458,11 +478,7 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
458
478
 
459
479
  // Strip quoted prefix before extracting @mentions to avoid matching @names inside quotes.
460
480
  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
- }
481
+ atMentions.push(...extractAtMentionsFromText(textForAtExtraction));
466
482
 
467
483
  return {
468
484
  text: textContent || quoted?.previewText || "",
@@ -553,6 +569,18 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
553
569
  };
554
570
  }
555
571
 
572
+ if (msgtype === "markdown") {
573
+ const mdText = typeof data.content?.text === "string" ? data.content.text.trim() : "";
574
+ atMentions.push(...extractAtMentionsFromText(mdText));
575
+ return {
576
+ text: mdText || "[markdown消息]",
577
+ messageType: "markdown",
578
+ quoted: quoted ?? undefined,
579
+ atMentions,
580
+ atUserDingtalkIds,
581
+ };
582
+ }
583
+
556
584
  if (msgtype === "interactiveCard") {
557
585
  const docMeta = parseBizCustomActionUrl(data.content?.biz_custom_action_url);
558
586
  if (docMeta) {
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import type { AttachmentTextSource } from "./types";
3
+ import type { AttachmentTextSource } from "../types";
4
4
 
5
5
  const MAX_EXTRACTED_TEXT_CHARS = 6000;
6
6
  const MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
@@ -1,10 +1,10 @@
1
1
  import http from "node:http";
2
2
  import https from "node:https";
3
- import axios from "axios";
4
- import { getAccessToken } from "./auth";
5
- import { getDingTalkRuntime } from "./runtime";
6
- import type { DingTalkConfig, Logger, MediaFile } from "./types";
7
- import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "./utils";
3
+ import axios from "../http-client";
4
+ import { getAccessToken } from "../auth";
5
+ import { getDingTalkRuntime } from "../runtime";
6
+ import type { DingTalkConfig, Logger, MediaFile } from "../types";
7
+ import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "../utils";
8
8
 
9
9
  function asRecord(value: unknown): Record<string, unknown> | undefined {
10
10
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -229,6 +229,7 @@ export async function downloadGroupFile(
229
229
  dentryId: string,
230
230
  unionId: string,
231
231
  log?: Logger,
232
+ originalFilename?: string,
232
233
  ): Promise<MediaFile | null> {
233
234
  const rt = getDingTalkRuntime();
234
235
  const token = await getAccessToken(config, log);
@@ -294,9 +295,13 @@ export async function downloadGroupFile(
294
295
  const maxBytes =
295
296
  config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
296
297
  try {
297
- const saved = maxBytes
298
- ? await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes)
299
- : await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
298
+ const saved = await rt.channel.media.saveMediaBuffer(
299
+ buffer,
300
+ contentType,
301
+ "inbound",
302
+ maxBytes,
303
+ originalFilename,
304
+ );
300
305
 
301
306
  return { path: saved.path, mimeType: saved.contentType ?? contentType };
302
307
  } catch (err: unknown) {
@@ -353,7 +358,7 @@ export async function resolveQuotedFile(
353
358
  }
354
359
 
355
360
  stage = "download-file";
356
- const media = await downloadGroupFile(config, spaceId, match.dentryId, unionId, log);
361
+ const media = await downloadGroupFile(config, spaceId, match.dentryId, unionId, log, match.name);
357
362
  if (!media) {
358
363
  return null;
359
364
  }
package/src/onboarding.ts CHANGED
@@ -122,10 +122,12 @@ function applyAccountConfig(params: {
122
122
  ? { groupAllowFrom: input.groupAllowFrom }
123
123
  : {}),
124
124
  ...(input.displayNameResolution ? { displayNameResolution: input.displayNameResolution } : {}),
125
+ ...(input.contextVisibility ? { contextVisibility: input.contextVisibility } : {}),
125
126
  ...(input.mediaUrlAllowlist && input.mediaUrlAllowlist.length > 0
126
127
  ? { mediaUrlAllowlist: input.mediaUrlAllowlist }
127
128
  : {}),
128
129
  ...(input.messageType ? { messageType: input.messageType } : {}),
130
+ ...(input.cardStreamingMode ? { cardStreamingMode: input.cardStreamingMode } : {}),
129
131
  ...(typeof input.maxReconnectCycles === "number"
130
132
  ? { maxReconnectCycles: input.maxReconnectCycles }
131
133
  : {}),
@@ -233,6 +235,7 @@ async function configureDingTalkAccount(params: {
233
235
  });
234
236
 
235
237
  let messageType: "markdown" | "card" = "markdown";
238
+ let cardStreamingMode: DingTalkConfig["cardStreamingMode"];
236
239
 
237
240
  if (wantsCardMode) {
238
241
  await prompter.note(
@@ -244,6 +247,15 @@ async function configureDingTalkAccount(params: {
244
247
  "Built-in AI Card Template",
245
248
  );
246
249
  messageType = "card";
250
+ cardStreamingMode = (await prompter.select({
251
+ message: "Card streaming mode",
252
+ options: [
253
+ { label: "Off - answer does not stream incrementally", value: "off" },
254
+ { label: "Answer - only answer streams incrementally", value: "answer" },
255
+ { label: "All - answer and thinking stream incrementally", value: "all" },
256
+ ],
257
+ initialValue: resolved.cardStreamingMode ?? (resolved.cardRealTimeStream ? "all" : "off"),
258
+ })) as DingTalkConfig["cardStreamingMode"];
247
259
  }
248
260
 
249
261
  const dmPolicyValue = await prompter.select({
@@ -333,6 +345,22 @@ async function configureDingTalkAccount(params: {
333
345
  initialValue: resolved.displayNameResolution ?? "disabled",
334
346
  });
335
347
 
348
+ await prompter.note(
349
+ [
350
+ "Advanced host context visibility is available as channels.dingtalk.contextVisibility.",
351
+ "Recommended advanced mode: allowlist_quote.",
352
+ "Modes:",
353
+ "- all: preserve the current host supplemental-context behavior",
354
+ "- allowlist: keep only host allowlisted supplemental context",
355
+ "- allowlist_quote: keep explicit quote/reply context while filtering extra context",
356
+ "This is separate from displayNameResolution and should be set manually if needed.",
357
+ resolved.contextVisibility
358
+ ? `Current resolved value: ${resolved.contextVisibility}`
359
+ : "Current resolved value: host default",
360
+ ].join("\n"),
361
+ "Advanced context visibility",
362
+ );
363
+
336
364
  let maxReconnectCycles: number | undefined;
337
365
  const wantsReconnectLimits = await prompter.confirm({
338
366
  message: "Configure runtime reconnect cycle limit? (recommended)",
@@ -410,6 +438,7 @@ async function configureDingTalkAccount(params: {
410
438
  displayNameResolution: displayNameResolutionValue as "disabled" | "all",
411
439
  mediaUrlAllowlist,
412
440
  messageType,
441
+ cardStreamingMode,
413
442
  maxReconnectCycles,
414
443
  mediaMaxMb,
415
444
  journalTTLDays,
@@ -0,0 +1,11 @@
1
+ import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
2
+
3
+ type JsonResultReturn = NonNullable<ChannelMessageActionAdapter["handleAction"]> extends (
4
+ ...args: unknown[]
5
+ ) => Promise<infer TResult>
6
+ ? TResult
7
+ : never;
8
+
9
+ declare module "openclaw/plugin-sdk/channel-actions" {
10
+ export function jsonResult(payload: unknown): JsonResultReturn;
11
+ }