@soimy/dingtalk 3.1.3 → 3.2.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.
package/src/channel.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
- import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
3
+ import type {
4
+ ChannelMessageActionAdapter,
5
+ OpenClawConfig,
6
+ } from "openclaw/plugin-sdk";
7
+ import * as pluginSdk from "openclaw/plugin-sdk";
5
8
  import { getAccessToken } from "./auth";
6
9
  import { createAICard, streamAICard, finishAICard } from "./card-service";
7
10
  import { getConfig, isConfigured, resolveRelativePath, stripTargetPrefix } from "./config";
@@ -10,15 +13,10 @@ import { ConnectionManager } from "./connection-manager";
10
13
  import { isMessageProcessed, markMessageProcessed } from "./dedup";
11
14
  import { handleDingTalkMessage } from "./inbound-handler";
12
15
  import { getLogger } from "./logger-context";
16
+ import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
13
17
  import { dingtalkOnboardingAdapter } from "./onboarding.js";
14
18
  import { resolveOriginalPeerId } from "./peer-id-registry";
15
- import {
16
- detectMediaTypeFromExtension,
17
- sendMessage,
18
- sendProactiveMedia,
19
- sendBySession,
20
- uploadMedia,
21
- } from "./send-service";
19
+ import { sendMessage, sendProactiveMedia, sendBySession, uploadMedia } from "./send-service";
22
20
  import type {
23
21
  DingTalkInboundMessage,
24
22
  GatewayStartContext,
@@ -30,7 +28,8 @@ import type {
30
28
  import { ConnectionState } from "./types";
31
29
  import { cleanupOrphanedTempFiles, formatDingTalkErrorPayloadLog, getCurrentTimestamp } from "./utils";
32
30
 
33
- const processingDedupKeys = new Set<string>();
31
+ const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
32
+ const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
34
33
  const inboundCountersByAccount = new Map<
35
34
  string,
36
35
  {
@@ -70,6 +69,132 @@ function logInboundCounters(log: any, accountId: string, reason: string): void {
70
69
  );
71
70
  }
72
71
 
72
+ function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
73
+ const value = params[key];
74
+ if (typeof value === "boolean") {
75
+ return value;
76
+ }
77
+ if (typeof value === "number") {
78
+ if (value === 1) {
79
+ return true;
80
+ }
81
+ if (value === 0) {
82
+ return false;
83
+ }
84
+ return undefined;
85
+ }
86
+ if (typeof value === "string") {
87
+ const normalized = value.trim().toLowerCase();
88
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) {
89
+ return true;
90
+ }
91
+ if (["0", "false", "no", "n", "off"].includes(normalized)) {
92
+ return false;
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ const dingtalkMessageActions: ChannelMessageActionAdapter = {
99
+ listActions: () => ["send"],
100
+ supportsAction: ({ action }) => action === "send",
101
+ extractToolSend: ({ args }) => pluginSdk.extractToolSend(args, "sendMessage"),
102
+ handleAction: async ({ action, params, cfg, accountId, dryRun }) => {
103
+ if (action !== "send") {
104
+ throw new Error(`Action ${action} is not supported for provider dingtalk.`);
105
+ }
106
+
107
+ const to = pluginSdk.readStringParam(params, "to", { required: true });
108
+ const mediaInput =
109
+ pluginSdk.readStringParam(params, "media", { trim: false }) ??
110
+ pluginSdk.readStringParam(params, "path", { trim: false }) ??
111
+ pluginSdk.readStringParam(params, "filePath", { trim: false }) ??
112
+ pluginSdk.readStringParam(params, "mediaUrl", { trim: false });
113
+
114
+ const hasMedia = Boolean(mediaInput && mediaInput.trim());
115
+ const caption = pluginSdk.readStringParam(params, "caption", { allowEmpty: true }) ?? "";
116
+ let message =
117
+ pluginSdk.readStringParam(params, "message", {
118
+ required: !hasMedia,
119
+ allowEmpty: true,
120
+ }) ?? "";
121
+
122
+ if (!message.trim() && caption.trim()) {
123
+ message = caption;
124
+ }
125
+
126
+ const asVoice = readBooleanLikeParam(params, "asVoice") === true;
127
+ const requestedMediaType = pluginSdk.readStringParam(params, "mediaType");
128
+
129
+ const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
130
+
131
+ if (dryRun) {
132
+ return pluginSdk.jsonResult({
133
+ ok: true,
134
+ dryRun: true,
135
+ to: target,
136
+ hasMedia,
137
+ asVoice,
138
+ });
139
+ }
140
+
141
+ const log = getLogger();
142
+ const config = getConfig(cfg, accountId ?? undefined);
143
+
144
+ if (hasMedia && mediaInput) {
145
+ const mediaPath = resolveRelativePath(mediaInput);
146
+ const mediaType = resolveOutboundMediaType({
147
+ mediaType: requestedMediaType ?? undefined,
148
+ mediaPath,
149
+ asVoice,
150
+ });
151
+ const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
152
+ log,
153
+ accountId: accountId ?? undefined,
154
+ });
155
+
156
+ if (!result.ok) {
157
+ throw new Error(result.error || "send media failed");
158
+ }
159
+
160
+ return pluginSdk.jsonResult({
161
+ ok: true,
162
+ to: target,
163
+ mediaType,
164
+ messageId: result.messageId ?? null,
165
+ result: result.data ?? null,
166
+ });
167
+ }
168
+
169
+ if (asVoice) {
170
+ throw new Error(
171
+ "DingTalk send with asVoice requires media/path/filePath/mediaUrl pointing to an audio file.",
172
+ );
173
+ }
174
+
175
+ if (!message.trim()) {
176
+ throw new Error("send requires message when media is not provided");
177
+ }
178
+
179
+ const result = await sendMessage(config, target, message, {
180
+ log,
181
+ accountId: accountId ?? undefined,
182
+ });
183
+
184
+ if (!result.ok) {
185
+ throw new Error(result.error || "send message failed");
186
+ }
187
+
188
+ const data = result.data as any;
189
+ return pluginSdk.jsonResult({
190
+ ok: true,
191
+ to: target,
192
+ messageId: data?.processQueryKey || data?.messageId || null,
193
+ result: data ?? null,
194
+ });
195
+ },
196
+ };
197
+
73
198
  // DingTalk Channel Definition (assembly layer).
74
199
  // Heavy logic is delegated to service modules for maintainability.
75
200
  export const dingtalkPlugin: DingTalkChannelPlugin = {
@@ -82,7 +207,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
82
207
  blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
83
208
  aliases: ["dd", "ding"],
84
209
  },
85
- configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
210
+ configSchema: pluginSdk.buildChannelConfigSchema(DingTalkConfigSchema),
86
211
  onboarding: dingtalkOnboardingAdapter,
87
212
  capabilities: {
88
213
  chatTypes: ["direct", "group"] as Array<"direct" | "group">,
@@ -153,6 +278,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
153
278
  hint: "<conversationId>",
154
279
  },
155
280
  },
281
+ actions: dingtalkMessageActions,
156
282
  outbound: {
157
283
  deliveryMode: "direct" as const,
158
284
  resolveTarget: ({ to }: any) => {
@@ -172,20 +298,18 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
172
298
  try {
173
299
  const result = await sendMessage(config, to, text, { log, accountId });
174
300
  getLogger()?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
175
- if (result.ok) {
176
- const data = result.data as any;
177
- const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
178
- return {
179
- channel: "dingtalk",
180
- messageId,
181
- meta: result.data
182
- ? { data: result.data as unknown as Record<string, unknown> }
183
- : undefined,
184
- };
301
+ if (!result.ok) {
302
+ throw new Error(result.error || "sendText failed");
185
303
  }
186
- throw new Error(
187
- typeof result.error === "string" ? result.error : JSON.stringify(result.error),
188
- );
304
+ const data = result.data as any;
305
+ const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
306
+ return {
307
+ channel: "dingtalk",
308
+ messageId,
309
+ meta: result.data
310
+ ? { data: result.data as unknown as Record<string, unknown> }
311
+ : undefined,
312
+ };
189
313
  } catch (err: any) {
190
314
  if (err?.response?.data !== undefined) {
191
315
  log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendText", err.response.data));
@@ -205,6 +329,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
205
329
  filePath,
206
330
  mediaUrl,
207
331
  mediaType: providedMediaType,
332
+ asVoice,
208
333
  accountId,
209
334
  log,
210
335
  }: any) => {
@@ -231,18 +356,47 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
231
356
  );
232
357
  }
233
358
 
234
- const actualMediaPath = resolveRelativePath(rawMediaPath);
359
+ let preparedMedia;
360
+ try {
361
+ try {
362
+ preparedMedia = await prepareMediaInput(rawMediaPath, log, config.mediaUrlAllowlist);
363
+ } catch (err: any) {
364
+ if (err?.response?.data !== undefined) {
365
+ log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data));
366
+ }
367
+ const errorCode = typeof err?.code === "string" ? `[${err.code}] ` : "";
368
+ throw new Error(`remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`, {
369
+ cause: err,
370
+ });
371
+ }
235
372
 
236
- getLogger()?.debug?.(
237
- `[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
238
- );
373
+ const actualMediaPath = preparedMedia.cleanup
374
+ ? preparedMedia.path
375
+ : resolveRelativePath(preparedMedia.path);
239
376
 
240
- try {
241
- const mediaType = providedMediaType || detectMediaTypeFromExtension(actualMediaPath);
242
- const result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
243
- log,
244
- accountId,
377
+ getLogger()?.debug?.(
378
+ `[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
379
+ );
380
+
381
+ const mediaType = resolveOutboundMediaType({
382
+ mediaType: typeof providedMediaType === "string" ? providedMediaType : undefined,
383
+ mediaPath: actualMediaPath,
384
+ asVoice: asVoice === true,
245
385
  });
386
+ let result;
387
+ try {
388
+ result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
389
+ log,
390
+ accountId,
391
+ });
392
+ } catch (err: any) {
393
+ if (err?.response?.data !== undefined) {
394
+ log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data));
395
+ }
396
+ throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
397
+ cause: err,
398
+ });
399
+ }
246
400
  getLogger()?.debug?.(
247
401
  `[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`,
248
402
  );
@@ -273,6 +427,8 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
273
427
  : err?.message || "sendMedia failed",
274
428
  { cause: err },
275
429
  );
430
+ } finally {
431
+ await preparedMedia?.cleanup?.();
276
432
  }
277
433
  },
278
434
  },
@@ -288,25 +444,35 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
288
444
 
289
445
  cleanupOrphanedTempFiles(ctx.log);
290
446
 
447
+ const useConnectionManager = config.useConnectionManager ?? true;
448
+
291
449
  const client = new DWClient({
292
450
  clientId: config.clientId,
293
451
  clientSecret: config.clientSecret,
294
452
  debug: config.debug || false,
295
- keepAlive: true,
453
+ keepAlive: !useConnectionManager,
296
454
  });
297
455
 
298
- // Disable built-in reconnect so ConnectionManager owns all retry/backoff behavior.
299
- (client as any).config.autoReconnect = false;
456
+ (client as any).config.autoReconnect = !useConnectionManager;
300
457
 
301
458
  client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
302
459
  const messageId = res.headers?.messageId;
303
460
  const stats = getInboundCounters(account.accountId);
304
461
  stats.received += 1;
305
- try {
306
- if (messageId) {
462
+ const acknowledge = () => {
463
+ if (!messageId) {
464
+ return;
465
+ }
466
+ try {
307
467
  client.socketCallBackResponse(messageId, { success: true });
308
468
  stats.acked += 1;
469
+ } catch (ackError: any) {
470
+ ctx.log?.warn?.(
471
+ `[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
472
+ );
309
473
  }
474
+ };
475
+ try {
310
476
  const data = JSON.parse(res.data) as DingTalkInboundMessage;
311
477
 
312
478
  // Message deduplication key is bot-scoped to avoid cross-account conflicts.
@@ -326,6 +492,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
326
492
  dingtalkConfig: config,
327
493
  });
328
494
  stats.processed += 1;
495
+ acknowledge();
329
496
  if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
330
497
  logInboundCounters(ctx.log, account.accountId, "periodic");
331
498
  }
@@ -335,20 +502,31 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
335
502
  if (isMessageProcessed(dedupKey)) {
336
503
  ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
337
504
  stats.dedupSkipped += 1;
505
+ acknowledge();
338
506
  logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
339
507
  return;
340
508
  }
341
509
 
342
- if (processingDedupKeys.has(dedupKey)) {
343
- ctx.log?.debug?.(
344
- `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
345
- );
346
- stats.inflightSkipped += 1;
347
- logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
348
- return;
510
+ const inflightSince = processingDedupKeys.get(dedupKey);
511
+ if (inflightSince !== undefined) {
512
+ if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
513
+ ctx.log?.warn?.(
514
+ `[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
515
+ );
516
+ processingDedupKeys.delete(dedupKey);
517
+ } else {
518
+ ctx.log?.debug?.(
519
+ `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
520
+ );
521
+ stats.inflightSkipped += 1;
522
+ // Do not acknowledge in-flight duplicates before the original handler succeeds.
523
+ // If the original later fails, early-acking the duplicate can suppress server redelivery.
524
+ logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
525
+ return;
526
+ }
349
527
  }
350
528
 
351
- processingDedupKeys.add(dedupKey);
529
+ processingDedupKeys.set(dedupKey, Date.now());
352
530
  try {
353
531
  await handleDingTalkMessage({
354
532
  cfg,
@@ -360,6 +538,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
360
538
  });
361
539
  stats.processed += 1;
362
540
  markMessageProcessed(dedupKey);
541
+ acknowledge();
363
542
  if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
364
543
  logInboundCounters(ctx.log, account.accountId, "periodic");
365
544
  }
@@ -375,6 +554,94 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
375
554
 
376
555
  // Guard against duplicate stop paths (abort signal + explicit stop).
377
556
  let stopped = false;
557
+ let nativeStopResolve: (() => void) | undefined;
558
+ const nativeStopPromise = new Promise<void>((resolve) => {
559
+ nativeStopResolve = resolve;
560
+ });
561
+ let connectionManager: ConnectionManager | undefined;
562
+
563
+ const stopClient = () => {
564
+ if (stopped) {
565
+ return;
566
+ }
567
+ stopped = true;
568
+ ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
569
+ if (useConnectionManager) {
570
+ connectionManager?.stop();
571
+ } else {
572
+ try {
573
+ client.disconnect();
574
+ } catch (err: any) {
575
+ ctx.log?.warn?.(`[${account.accountId}] Error during disconnect: ${err.message}`);
576
+ }
577
+ nativeStopResolve?.();
578
+ }
579
+
580
+ ctx.setStatus({
581
+ ...ctx.getStatus(),
582
+ running: false,
583
+ lastStopAt: getCurrentTimestamp(),
584
+ });
585
+
586
+ ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
587
+ };
588
+
589
+ if (abortSignal) {
590
+ if (abortSignal.aborted) {
591
+ ctx.log?.warn?.(
592
+ `[${account.accountId}] Abort signal already active, skipping connection`,
593
+ );
594
+
595
+ ctx.setStatus({
596
+ ...ctx.getStatus(),
597
+ running: false,
598
+ lastStopAt: getCurrentTimestamp(),
599
+ lastError: "Connection aborted before start",
600
+ });
601
+
602
+ throw new Error("Connection aborted before start");
603
+ }
604
+
605
+ abortSignal.addEventListener("abort", () => {
606
+ if (stopped) {
607
+ return;
608
+ }
609
+ ctx.log?.info?.(
610
+ `[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
611
+ );
612
+ stopClient();
613
+ });
614
+ }
615
+
616
+ if (!useConnectionManager) {
617
+ try {
618
+ await client.connect();
619
+ if (!stopped) {
620
+ ctx.setStatus({
621
+ ...ctx.getStatus(),
622
+ running: true,
623
+ lastStartAt: getCurrentTimestamp(),
624
+ lastError: null,
625
+ });
626
+ ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
627
+ await nativeStopPromise;
628
+ }
629
+ } catch (err: any) {
630
+ ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
631
+ ctx.setStatus({
632
+ ...ctx.getStatus(),
633
+ running: false,
634
+ lastError: err.message || "Connection failed",
635
+ });
636
+ throw err;
637
+ }
638
+
639
+ return {
640
+ stop: () => {
641
+ stopClient();
642
+ },
643
+ };
644
+ }
378
645
 
379
646
  const connectionConfig: ConnectionManagerConfig = {
380
647
  maxAttempts: config.maxConnectionAttempts ?? 10,
@@ -397,6 +664,22 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
397
664
  lastError: null,
398
665
  });
399
666
  } else if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
667
+ // Clear stale in-flight locks for this account on disconnect.
668
+ // DingTalk will redeliver unacknowledged messages on reconnect; without
669
+ // this cleanup the redelivered messages would be silently skipped forever.
670
+ const robotKey = config.robotCode || config.clientId || account.accountId;
671
+ let cleared = 0;
672
+ for (const key of processingDedupKeys.keys()) {
673
+ if (key.startsWith(`${robotKey}:`)) {
674
+ processingDedupKeys.delete(key);
675
+ cleared++;
676
+ }
677
+ }
678
+ if (cleared > 0) {
679
+ ctx.log?.info?.(
680
+ `[${account.accountId}] Cleared ${cleared} stale in-flight lock(s) on disconnect`,
681
+ );
682
+ }
400
683
  ctx.setStatus({
401
684
  ...ctx.getStatus(),
402
685
  running: false,
@@ -412,48 +695,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
412
695
  `jitter=${connectionConfig.jitter}`,
413
696
  );
414
697
 
415
- const connectionManager = new ConnectionManager(
698
+ connectionManager = new ConnectionManager(
416
699
  client,
417
700
  account.accountId,
418
701
  connectionConfig,
419
702
  ctx.log,
420
703
  );
421
704
 
422
- // Register abort listener before connect() so startup can be cancelled safely.
423
- if (abortSignal) {
424
- if (abortSignal.aborted) {
425
- ctx.log?.warn?.(
426
- `[${account.accountId}] Abort signal already active, skipping connection`,
427
- );
428
-
429
- ctx.setStatus({
430
- ...ctx.getStatus(),
431
- running: false,
432
- lastStopAt: getCurrentTimestamp(),
433
- lastError: "Connection aborted before start",
434
- });
435
-
436
- throw new Error("Connection aborted before start");
437
- }
438
-
439
- abortSignal.addEventListener("abort", () => {
440
- if (stopped) {
441
- return;
442
- }
443
- stopped = true;
444
- ctx.log?.info?.(
445
- `[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
446
- );
447
- connectionManager.stop();
448
-
449
- ctx.setStatus({
450
- ...ctx.getStatus(),
451
- running: false,
452
- lastStopAt: getCurrentTimestamp(),
453
- });
454
- });
455
- }
456
-
457
705
  try {
458
706
  await connectionManager.connect();
459
707
 
@@ -486,20 +734,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
486
734
 
487
735
  return {
488
736
  stop: () => {
489
- if (stopped) {
490
- return;
491
- }
492
- stopped = true;
493
- ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
494
- connectionManager.stop();
495
-
496
- ctx.setStatus({
497
- ...ctx.getStatus(),
498
- running: false,
499
- lastStopAt: getCurrentTimestamp(),
500
- });
501
-
502
- ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
737
+ stopClient();
503
738
  },
504
739
  };
505
740
  },
@@ -31,6 +31,8 @@ const DingTalkAccountConfigSchema = z.object({
31
31
  /** List of allowed user IDs for allowlist policy */
32
32
  allowFrom: z.array(z.string()).optional(),
33
33
 
34
+ mediaUrlAllowlist: z.array(z.string()).optional(),
35
+
34
36
  /** Show thinking indicator while processing */
35
37
  showThinking: z.boolean().optional().default(true),
36
38
 
@@ -47,7 +49,7 @@ const DingTalkAccountConfigSchema = z.object({
47
49
  cardTemplateId: z.string().optional(),
48
50
 
49
51
  /** Card template key for streaming updates
50
- * Default: 'msgContent' - maps to the content field in the card template
52
+ * Default: 'content' - maps to the content field in the card template
51
53
  * This key is used in the streaming API to update specific fields in the card.
52
54
  */
53
55
  cardTemplateKey: z.string().optional().default("content"),
@@ -79,6 +81,12 @@ const DingTalkAccountConfigSchema = z.object({
79
81
  /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
80
82
  maxReconnectCycles: z.number().int().min(1).optional().default(10),
81
83
 
84
+ /** Whether to use ConnectionManager (default: true). When false, rely on DWClient native keepAlive+autoReconnect. */
85
+ useConnectionManager: z.boolean().optional().default(true),
86
+
87
+ /** Maximum inbound media file size in MB (overrides runtime default when set) */
88
+ mediaMaxMb: z.number().int().min(1).optional(),
89
+
82
90
  proactivePermissionHint: z
83
91
  .object({
84
92
  enabled: z.boolean().optional().default(true),
@@ -129,7 +129,20 @@ export class ConnectionManager {
129
129
  );
130
130
 
131
131
  try {
132
- // Call DWClient connect method
132
+ // Ensure previous connection resources (heartbeat timers, old sockets) are
133
+ // fully cleaned up before establishing a new connection. The DWClient
134
+ // _connect() method does not clear its internal heartbeat interval, so a
135
+ // stale timer from a prior session can terminate the newly created socket
136
+ // before it finishes the handshake (manifests as code-1006 / "WebSocket was
137
+ // closed before the connection was established").
138
+ try {
139
+ this.client.disconnect();
140
+ } catch (disconnectErr: any) {
141
+ this.log?.debug?.(
142
+ `[${this.accountId}] pre-connect cleanup disconnect failed: ${disconnectErr.message}`,
143
+ );
144
+ }
145
+
133
146
  await this.client.connect();
134
147
 
135
148
  // Re-check stopped flag after async connect() completes