@yaoyuanchao/dingtalk 1.3.5 → 1.3.7

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/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.6] - 2026-01-28
9
+
10
+ ### Fixed
11
+
12
+ - **Stream ACK method name** — corrected `socketResponse()` to `socketCallBackResponse()` (the actual SDK method); previous typo caused ACK to silently fail, triggering DingTalk 60-second retry
13
+ - **Audio message handling** — skip .amr file download when DingTalk ASR recognition text is available; prevents agent from being confused by audio attachment and trying Whisper instead of reading the already-transcribed text
14
+
8
15
  ## [1.3.5] - 2026-01-28
9
16
 
10
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for Clawdbot with Stream Mode support",
6
6
  "license": "MIT",
@@ -0,0 +1,562 @@
1
+ /**
2
+ * DingTalk Interactive Card API
3
+ *
4
+ * 钉钉互动卡片 API 封装,支持:
5
+ * - 创建卡片实例
6
+ * - 投放卡片到会话
7
+ * - 普通更新卡片
8
+ * - 流式更新卡片(AI 打字机效果)
9
+ *
10
+ * 参考文档:
11
+ * - https://open.dingtalk.com/document/orgapp/api-streamingupdate
12
+ * - https://github.com/open-dingtalk/dingtalk-card-examples
13
+ */
14
+
15
+ import { getDingTalkAccessToken } from "./api.js";
16
+
17
+ const DINGTALK_API_BASE = "https://api.dingtalk.com/v1.0";
18
+
19
+ /** HTTP POST with JSON body */
20
+ async function jsonPost(
21
+ url: string,
22
+ body: unknown,
23
+ headers?: Record<string, string>,
24
+ ): Promise<any> {
25
+ const response = await fetch(url, {
26
+ method: "POST",
27
+ headers: {
28
+ "Content-Type": "application/json",
29
+ ...headers,
30
+ },
31
+ body: JSON.stringify(body),
32
+ });
33
+
34
+ const text = await response.text();
35
+ try {
36
+ return JSON.parse(text);
37
+ } catch {
38
+ return { raw: text, status: response.status };
39
+ }
40
+ }
41
+
42
+ /** HTTP PUT with JSON body */
43
+ async function jsonPut(
44
+ url: string,
45
+ body: unknown,
46
+ headers?: Record<string, string>,
47
+ ): Promise<any> {
48
+ const response = await fetch(url, {
49
+ method: "PUT",
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ ...headers,
53
+ },
54
+ body: JSON.stringify(body),
55
+ });
56
+
57
+ const text = await response.text();
58
+ try {
59
+ return JSON.parse(text);
60
+ } catch {
61
+ return { raw: text, status: response.status };
62
+ }
63
+ }
64
+
65
+ /** Card instance creation parameters */
66
+ export interface CreateCardInstanceParams {
67
+ clientId: string;
68
+ clientSecret: string;
69
+ cardTemplateId: string;
70
+ outTrackId: string;
71
+ cardData?: {
72
+ cardParamMap?: Record<string, string>;
73
+ cardMediaIdParamMap?: Record<string, string>;
74
+ };
75
+ robotCode?: string;
76
+ callbackType?: "STREAM" | "HTTP";
77
+ userIdType?: number;
78
+ }
79
+
80
+ /** Card delivery parameters */
81
+ export interface DeliverCardParams {
82
+ clientId: string;
83
+ clientSecret: string;
84
+ outTrackId: string;
85
+ openSpaceId: string;
86
+ robotCode?: string;
87
+ /** For group delivery */
88
+ imGroupOpenDeliverModel?: {
89
+ robotCode: string;
90
+ atUserIds?: Record<string, string>;
91
+ };
92
+ /** For single chat delivery */
93
+ imRobotOpenDeliverModel?: {
94
+ spaceType: "IM_ROBOT";
95
+ robotCode: string;
96
+ };
97
+ }
98
+
99
+ /** Card update parameters */
100
+ export interface UpdateCardParams {
101
+ clientId: string;
102
+ clientSecret: string;
103
+ outTrackId: string;
104
+ cardData: {
105
+ cardParamMap?: Record<string, string>;
106
+ cardMediaIdParamMap?: Record<string, string>;
107
+ };
108
+ userIdType?: number;
109
+ }
110
+
111
+ /** Streaming update parameters (AI typewriter effect) */
112
+ export interface StreamingUpdateParams {
113
+ clientId: string;
114
+ clientSecret: string;
115
+ outTrackId: string;
116
+ /** Unique identifier for this streaming session */
117
+ key: string;
118
+ /** Content to append */
119
+ content: string;
120
+ /** Whether this is the final update */
121
+ isFull?: boolean;
122
+ /** GUID for idempotency */
123
+ guid?: string;
124
+ }
125
+
126
+ /** API response with error info */
127
+ export interface CardApiResponse {
128
+ success: boolean;
129
+ result?: any;
130
+ errcode?: number;
131
+ errmsg?: string;
132
+ code?: string;
133
+ message?: string;
134
+ }
135
+
136
+ /**
137
+ * Create a card instance
138
+ *
139
+ * API: POST /v1.0/card/instances
140
+ */
141
+ export async function createCardInstance(
142
+ params: CreateCardInstanceParams,
143
+ ): Promise<CardApiResponse> {
144
+ try {
145
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
146
+
147
+ const body: Record<string, any> = {
148
+ cardTemplateId: params.cardTemplateId,
149
+ outTrackId: params.outTrackId,
150
+ cardData: params.cardData || { cardParamMap: {} },
151
+ callbackType: params.callbackType || "STREAM",
152
+ };
153
+
154
+ if (params.robotCode) {
155
+ body.robotCode = params.robotCode;
156
+ }
157
+
158
+ if (params.userIdType !== undefined) {
159
+ body.userIdType = params.userIdType;
160
+ }
161
+
162
+ const res = await jsonPost(
163
+ `${DINGTALK_API_BASE}/card/instances`,
164
+ body,
165
+ { "x-acs-dingtalk-access-token": token },
166
+ );
167
+
168
+ if (res.code || res.errcode) {
169
+ return {
170
+ success: false,
171
+ errcode: res.errcode,
172
+ errmsg: res.errmsg,
173
+ code: res.code,
174
+ message: res.message,
175
+ };
176
+ }
177
+
178
+ return { success: true, result: res };
179
+ } catch (err) {
180
+ return {
181
+ success: false,
182
+ message: String(err),
183
+ };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Add space to card instance
189
+ *
190
+ * API: POST /v1.0/card/instances/spaces
191
+ */
192
+ export async function createCardSpace(
193
+ clientId: string,
194
+ clientSecret: string,
195
+ outTrackId: string,
196
+ openSpaceId: string,
197
+ ): Promise<CardApiResponse> {
198
+ try {
199
+ const token = await getDingTalkAccessToken(clientId, clientSecret);
200
+
201
+ const res = await jsonPost(
202
+ `${DINGTALK_API_BASE}/card/instances/spaces`,
203
+ { outTrackId, openSpaceId },
204
+ { "x-acs-dingtalk-access-token": token },
205
+ );
206
+
207
+ if (res.code || res.errcode) {
208
+ return {
209
+ success: false,
210
+ errcode: res.errcode,
211
+ errmsg: res.errmsg,
212
+ code: res.code,
213
+ message: res.message,
214
+ };
215
+ }
216
+
217
+ return { success: true, result: res };
218
+ } catch (err) {
219
+ return {
220
+ success: false,
221
+ message: String(err),
222
+ };
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Deliver card to conversation
228
+ *
229
+ * API: POST /v1.0/card/instances/deliver
230
+ */
231
+ export async function deliverCard(
232
+ params: DeliverCardParams,
233
+ ): Promise<CardApiResponse> {
234
+ try {
235
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
236
+
237
+ const body: Record<string, any> = {
238
+ outTrackId: params.outTrackId,
239
+ openSpaceId: params.openSpaceId,
240
+ };
241
+
242
+ if (params.imGroupOpenDeliverModel) {
243
+ body.imGroupOpenDeliverModel = params.imGroupOpenDeliverModel;
244
+ }
245
+
246
+ if (params.imRobotOpenDeliverModel) {
247
+ body.imRobotOpenDeliverModel = params.imRobotOpenDeliverModel;
248
+ }
249
+
250
+ const res = await jsonPost(
251
+ `${DINGTALK_API_BASE}/card/instances/deliver`,
252
+ body,
253
+ { "x-acs-dingtalk-access-token": token },
254
+ );
255
+
256
+ if (res.code || res.errcode) {
257
+ return {
258
+ success: false,
259
+ errcode: res.errcode,
260
+ errmsg: res.errmsg,
261
+ code: res.code,
262
+ message: res.message,
263
+ };
264
+ }
265
+
266
+ return { success: true, result: res };
267
+ } catch (err) {
268
+ return {
269
+ success: false,
270
+ message: String(err),
271
+ };
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Update card data
277
+ *
278
+ * API: PUT /v1.0/card/instances
279
+ */
280
+ export async function updateCard(
281
+ params: UpdateCardParams,
282
+ ): Promise<CardApiResponse> {
283
+ try {
284
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
285
+
286
+ const body: Record<string, any> = {
287
+ outTrackId: params.outTrackId,
288
+ cardData: params.cardData,
289
+ };
290
+
291
+ if (params.userIdType !== undefined) {
292
+ body.userIdType = params.userIdType;
293
+ }
294
+
295
+ const res = await jsonPut(
296
+ `${DINGTALK_API_BASE}/card/instances`,
297
+ body,
298
+ { "x-acs-dingtalk-access-token": token },
299
+ );
300
+
301
+ if (res.code || res.errcode) {
302
+ return {
303
+ success: false,
304
+ errcode: res.errcode,
305
+ errmsg: res.errmsg,
306
+ code: res.code,
307
+ message: res.message,
308
+ };
309
+ }
310
+
311
+ return { success: true, result: res };
312
+ } catch (err) {
313
+ return {
314
+ success: false,
315
+ message: String(err),
316
+ };
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Streaming update for AI card (typewriter effect)
322
+ *
323
+ * API: PUT /v1.0/card/streaming
324
+ *
325
+ * Note: Requires Card.Streaming.Write permission
326
+ */
327
+ export async function updateCardStreaming(
328
+ params: StreamingUpdateParams,
329
+ ): Promise<CardApiResponse> {
330
+ try {
331
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
332
+
333
+ const body: Record<string, any> = {
334
+ outTrackId: params.outTrackId,
335
+ key: params.key,
336
+ content: params.content,
337
+ isFull: params.isFull ?? false,
338
+ };
339
+
340
+ if (params.guid) {
341
+ body.guid = params.guid;
342
+ }
343
+
344
+ const res = await jsonPut(
345
+ `${DINGTALK_API_BASE}/card/streaming`,
346
+ body,
347
+ { "x-acs-dingtalk-access-token": token },
348
+ );
349
+
350
+ if (res.code || res.errcode) {
351
+ return {
352
+ success: false,
353
+ errcode: res.errcode,
354
+ errmsg: res.errmsg,
355
+ code: res.code,
356
+ message: res.message,
357
+ };
358
+ }
359
+
360
+ return { success: true, result: res };
361
+ } catch (err) {
362
+ return {
363
+ success: false,
364
+ message: String(err),
365
+ };
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Build openSpaceId for different scenarios
371
+ */
372
+ export function buildOpenSpaceId(
373
+ type: "IM_GROUP" | "IM_ROBOT",
374
+ id: string,
375
+ ): string {
376
+ // Format: dtv1.card//IM_GROUP.{openConversationId}
377
+ // or: dtv1.card//IM_ROBOT.{senderStaffId}
378
+ return `dtv1.card//${type}.${id}`;
379
+ }
380
+
381
+ /**
382
+ * Generate unique outTrackId
383
+ */
384
+ export function generateOutTrackId(): string {
385
+ const timestamp = Date.now();
386
+ const random = Math.random().toString(36).substring(2, 10);
387
+ return `card_${timestamp}_${random}`;
388
+ }
389
+
390
+ /**
391
+ * High-level: Send a card message to a conversation
392
+ *
393
+ * This combines createCardInstance + createCardSpace + deliverCard
394
+ */
395
+ export async function sendCardMessage(params: {
396
+ clientId: string;
397
+ clientSecret: string;
398
+ robotCode: string;
399
+ cardTemplateId: string;
400
+ cardData: Record<string, string>;
401
+ /** For group chat */
402
+ openConversationId?: string;
403
+ /** For single chat (DM) */
404
+ senderStaffId?: string;
405
+ }): Promise<CardApiResponse & { outTrackId?: string }> {
406
+ const outTrackId = generateOutTrackId();
407
+
408
+ // Determine space type and ID
409
+ const isGroup = !!params.openConversationId;
410
+ const spaceType = isGroup ? "IM_GROUP" : "IM_ROBOT";
411
+ const spaceTargetId = isGroup ? params.openConversationId! : params.senderStaffId!;
412
+ const openSpaceId = buildOpenSpaceId(spaceType, spaceTargetId);
413
+
414
+ // Step 1: Create card instance
415
+ const createResult = await createCardInstance({
416
+ clientId: params.clientId,
417
+ clientSecret: params.clientSecret,
418
+ cardTemplateId: params.cardTemplateId,
419
+ outTrackId,
420
+ cardData: { cardParamMap: params.cardData },
421
+ robotCode: params.robotCode,
422
+ callbackType: "STREAM",
423
+ });
424
+
425
+ if (!createResult.success) {
426
+ console.warn("[dingtalk-card] Failed to create card instance:", createResult);
427
+ return createResult;
428
+ }
429
+
430
+ // Step 2: Add space
431
+ const spaceResult = await createCardSpace(
432
+ params.clientId,
433
+ params.clientSecret,
434
+ outTrackId,
435
+ openSpaceId,
436
+ );
437
+
438
+ if (!spaceResult.success) {
439
+ console.warn("[dingtalk-card] Failed to create card space:", spaceResult);
440
+ return spaceResult;
441
+ }
442
+
443
+ // Step 3: Deliver card
444
+ const deliverParams: DeliverCardParams = {
445
+ clientId: params.clientId,
446
+ clientSecret: params.clientSecret,
447
+ outTrackId,
448
+ openSpaceId,
449
+ };
450
+
451
+ if (isGroup) {
452
+ deliverParams.imGroupOpenDeliverModel = {
453
+ robotCode: params.robotCode,
454
+ };
455
+ } else {
456
+ deliverParams.imRobotOpenDeliverModel = {
457
+ spaceType: "IM_ROBOT",
458
+ robotCode: params.robotCode,
459
+ };
460
+ }
461
+
462
+ const deliverResult = await deliverCard(deliverParams);
463
+
464
+ if (!deliverResult.success) {
465
+ console.warn("[dingtalk-card] Failed to deliver card:", deliverResult);
466
+ return deliverResult;
467
+ }
468
+
469
+ return { success: true, outTrackId, result: deliverResult.result };
470
+ }
471
+
472
+ /**
473
+ * High-level: Send an AI card with streaming support
474
+ *
475
+ * Returns a controller object for streaming updates
476
+ */
477
+ export async function sendStreamingAICard(params: {
478
+ clientId: string;
479
+ clientSecret: string;
480
+ robotCode: string;
481
+ cardTemplateId: string;
482
+ /** Initial card data (use flowStatus="0" for loading state) */
483
+ initialData?: Record<string, string>;
484
+ /** Key for the streaming content variable in the template */
485
+ streamingKey?: string;
486
+ openConversationId?: string;
487
+ senderStaffId?: string;
488
+ }): Promise<{
489
+ success: boolean;
490
+ outTrackId?: string;
491
+ error?: string;
492
+ /** Update streaming content */
493
+ update: (content: string) => Promise<CardApiResponse>;
494
+ /** Mark streaming as complete */
495
+ finish: (finalContent: string) => Promise<CardApiResponse>;
496
+ }> {
497
+ const streamingKey = params.streamingKey || "content";
498
+
499
+ // Send initial card with loading state
500
+ const initialCardData: Record<string, string> = {
501
+ flowStatus: "0", // 0 = streaming in progress
502
+ ...params.initialData,
503
+ };
504
+
505
+ const sendResult = await sendCardMessage({
506
+ clientId: params.clientId,
507
+ clientSecret: params.clientSecret,
508
+ robotCode: params.robotCode,
509
+ cardTemplateId: params.cardTemplateId,
510
+ cardData: initialCardData,
511
+ openConversationId: params.openConversationId,
512
+ senderStaffId: params.senderStaffId,
513
+ });
514
+
515
+ if (!sendResult.success || !sendResult.outTrackId) {
516
+ return {
517
+ success: false,
518
+ error: sendResult.message || sendResult.errmsg || "Failed to send card",
519
+ update: async () => ({ success: false, message: "Card not initialized" }),
520
+ finish: async () => ({ success: false, message: "Card not initialized" }),
521
+ };
522
+ }
523
+
524
+ const outTrackId = sendResult.outTrackId;
525
+
526
+ // Return controller
527
+ return {
528
+ success: true,
529
+ outTrackId,
530
+ update: async (content: string) => {
531
+ return updateCardStreaming({
532
+ clientId: params.clientId,
533
+ clientSecret: params.clientSecret,
534
+ outTrackId,
535
+ key: streamingKey,
536
+ content,
537
+ isFull: false,
538
+ });
539
+ },
540
+ finish: async (finalContent: string) => {
541
+ // First update with final content
542
+ await updateCardStreaming({
543
+ clientId: params.clientId,
544
+ clientSecret: params.clientSecret,
545
+ outTrackId,
546
+ key: streamingKey,
547
+ content: finalContent,
548
+ isFull: true,
549
+ });
550
+
551
+ // Then update flowStatus to complete
552
+ return updateCard({
553
+ clientId: params.clientId,
554
+ clientSecret: params.clientSecret,
555
+ outTrackId,
556
+ cardData: {
557
+ cardParamMap: { flowStatus: "1" }, // 1 = complete
558
+ },
559
+ });
560
+ },
561
+ };
562
+ }
@@ -9,8 +9,8 @@ export const groupPolicySchema = z.enum(['disabled', 'allowlist', 'open'], {
9
9
  description: 'Group chat access control policy',
10
10
  });
11
11
 
12
- export const messageFormatSchema = z.enum(['text', 'markdown', 'richtext', 'auto'], {
13
- description: 'Message format for bot responses (richtext is an alias for markdown, auto detects markdown features)',
12
+ export const messageFormatSchema = z.enum(['text', 'markdown', 'richtext', 'auto', 'card'], {
13
+ description: 'Message format for bot responses (richtext is an alias for markdown, auto detects markdown features, card uses interactive cards)',
14
14
  });
15
15
 
16
16
  // DingTalk 配置 Schema
@@ -61,9 +61,25 @@ export const dingTalkConfigSchema = z.object({
61
61
  ' - text: Plain text (recommended, supports tables)\n' +
62
62
  ' - markdown: DingTalk markdown (limited support, no tables)\n' +
63
63
  ' - richtext: Alias for markdown (deprecated, use markdown instead)\n' +
64
- ' - auto: Auto-detect markdown features in response'
64
+ ' - auto: Auto-detect markdown features in response\n' +
65
+ ' - card: Interactive card (requires cardTemplateId)'
65
66
  ),
66
67
 
68
+ // 卡片配置(当 messageFormat 为 card 时使用)
69
+ card: z.object({
70
+ templateId: z.string().min(1)
71
+ .describe('Card template ID from DingTalk card platform (e.g., "xxx.schema")'),
72
+ title: z.string().optional()
73
+ .describe('Card title (optional, if template has a title variable)'),
74
+ streamingEnabled: z.boolean().default(false)
75
+ .describe('Enable streaming update for AI typewriter effect (costs more API quota)'),
76
+ streamingKey: z.string().default('content')
77
+ .describe('Variable key for streaming content in the card template'),
78
+ fallbackToMarkdown: z.boolean().default(true)
79
+ .describe('Fall back to markdown when card delivery fails'),
80
+ }).optional()
81
+ .describe('Interactive card configuration (required when messageFormat is "card")'),
82
+
67
83
  // 思考反馈
68
84
  showThinking: z.boolean().default(false)
69
85
  .describe('Send "正在思考..." feedback before AI responds'),
package/src/monitor.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { DingTalkRobotMessage, ResolvedDingTalkAccount, ExtractedMessage } from "./types.js";
2
2
  import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia } from "./api.js";
3
+ import { sendCardMessage, sendStreamingAICard } from "./card-api.js";
3
4
  import { getDingTalkRuntime } from "./runtime.js";
4
5
 
5
6
  export interface DingTalkMonitorContext {
@@ -53,8 +54,9 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
53
54
 
54
55
  client.registerCallbackListener(TOPIC_ROBOT, async (downstream: any) => {
55
56
  // Immediately ACK to prevent DingTalk from retrying (60s timeout)
57
+ // SDK method is socketCallBackResponse, not socketResponse
56
58
  try {
57
- client.socketResponse(downstream.headers.messageId, { status: 'SUCCESS' });
59
+ client.socketCallBackResponse(downstream.headers.messageId, { status: 'SUCCESS' });
58
60
  } catch (_) { /* best-effort ACK */ }
59
61
 
60
62
  try {
@@ -318,11 +320,16 @@ async function processInboundMessage(
318
320
  // Extract message content using structured extractor
319
321
  const extracted = await extractMessageContent(msg, account, log);
320
322
 
321
- // Download media if present (picture/audio/video/file)
323
+ // Download media if present (picture/video/file — but skip audio when ASR text exists)
322
324
  let mediaPath: string | undefined;
323
325
  let mediaType: string | undefined;
324
326
 
325
- if (extracted.mediaDownloadCode && account.clientId && account.clientSecret) {
327
+ // For audio messages with successful ASR recognition, use the text directly
328
+ // and skip downloading the .amr file (which would confuse the agent into
329
+ // trying Whisper instead of reading the already-transcribed text).
330
+ const skipMediaDownload = extracted.messageType === 'audio' && !!extracted.text;
331
+
332
+ if (!skipMediaDownload && extracted.mediaDownloadCode && account.clientId && account.clientSecret) {
326
333
  const robotCode = account.robotCode || account.clientId;
327
334
  try {
328
335
  const result = await downloadMediaFile(
@@ -342,6 +349,8 @@ async function processInboundMessage(
342
349
  } catch (err) {
343
350
  log?.warn?.(`[dingtalk] Media download error: ${err}`);
344
351
  }
352
+ } else if (skipMediaDownload) {
353
+ log?.info?.("[dingtalk] Audio ASR text available, skipping .amr download");
345
354
  }
346
355
 
347
356
  let rawBody = extracted.text;
@@ -741,16 +750,89 @@ function resolveDeliverText(payload: any, log?: any): string | undefined {
741
750
  return text || undefined;
742
751
  }
743
752
 
753
+ /**
754
+ * Deliver reply using interactive card
755
+ * Returns true if card was sent successfully, false otherwise
756
+ */
757
+ async function deliverCardReply(
758
+ target: any,
759
+ text: string,
760
+ cardConfig: { templateId: string; title?: string; streamingEnabled?: boolean; streamingKey?: string },
761
+ log?: any,
762
+ ): Promise<boolean> {
763
+ try {
764
+ const { clientId, clientSecret, robotCode } = target.account;
765
+
766
+ if (!clientId || !clientSecret) {
767
+ log?.info?.("[dingtalk-card] Missing credentials for card delivery");
768
+ return false;
769
+ }
770
+
771
+ const robotCodeValue = robotCode || clientId;
772
+
773
+ // Prepare card data - use 'content' as the main variable for markdown content
774
+ const isStreaming = cardConfig.streamingEnabled;
775
+ const cardData: Record<string, string> = {
776
+ content: text,
777
+ // flowStatus: "0" = streaming, "1" = complete
778
+ flowStatus: isStreaming ? "0" : "1",
779
+ // title: use configured title, or "完成" for non-streaming mode
780
+ title: cardConfig.title || (isStreaming ? "" : "完成"),
781
+ };
782
+
783
+ log?.info?.("[dingtalk-card] Sending card message, templateId=" + cardConfig.templateId);
784
+
785
+ const result = await sendCardMessage({
786
+ clientId,
787
+ clientSecret,
788
+ robotCode: robotCodeValue,
789
+ cardTemplateId: cardConfig.templateId,
790
+ cardData,
791
+ openConversationId: target.isDm ? undefined : target.conversationId,
792
+ senderStaffId: target.isDm ? target.senderId : undefined,
793
+ });
794
+
795
+ if (result.success) {
796
+ log?.info?.("[dingtalk-card] Card sent successfully, outTrackId=" + result.outTrackId);
797
+ return true;
798
+ } else {
799
+ log?.info?.("[dingtalk-card] Card send failed: " + (result.message || result.errmsg || JSON.stringify(result)));
800
+ return false;
801
+ }
802
+ } catch (err) {
803
+ log?.info?.("[dingtalk-card] Card delivery error: " + (err instanceof Error ? err.message : String(err)));
804
+ return false;
805
+ }
806
+ }
807
+
744
808
  async function deliverReply(target: any, text: string, log?: any): Promise<void> {
745
809
  const now = Date.now();
746
810
  const chunkLimit = 2000;
747
811
  const messageFormat = target.account.config.messageFormat ?? "text";
812
+ const cardConfig = target.account.config.card;
813
+
814
+ // Handle card format
815
+ if (messageFormat === 'card' && cardConfig?.templateId) {
816
+ const cardSent = await deliverCardReply(target, text, cardConfig, log);
817
+ if (cardSent) return;
818
+
819
+ // Fallback to markdown if card failed and fallback is enabled
820
+ if (cardConfig.fallbackToMarkdown !== false) {
821
+ log?.info?.("[dingtalk] Card delivery failed, falling back to markdown");
822
+ } else {
823
+ log?.info?.("[dingtalk] Card delivery failed, no fallback configured");
824
+ return;
825
+ }
826
+ }
748
827
 
749
828
  // Determine if this message should use markdown format
750
829
  let isMarkdown: boolean;
751
830
  if (messageFormat === 'auto') {
752
831
  isMarkdown = detectMarkdownContent(text);
753
832
  log?.info?.("[dingtalk] Auto-detected format: " + (isMarkdown ? "markdown" : "text"));
833
+ } else if (messageFormat === 'card') {
834
+ // Card failed, fallback to markdown
835
+ isMarkdown = true;
754
836
  } else {
755
837
  // Support both "markdown" and "richtext" (they're equivalent for DingTalk)
756
838
  isMarkdown = messageFormat === "markdown" || messageFormat === "richtext";