@yaoyuanchao/dingtalk 1.3.6 → 1.3.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for Clawdbot with Stream Mode support",
6
6
  "license": "MIT",
@@ -0,0 +1,573 @@
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
+ console.log("[dingtalk-card] createCardInstance request:", JSON.stringify(body, null, 2));
163
+
164
+ const res = await jsonPost(
165
+ `${DINGTALK_API_BASE}/card/instances`,
166
+ body,
167
+ { "x-acs-dingtalk-access-token": token },
168
+ );
169
+
170
+ console.log("[dingtalk-card] createCardInstance response:", JSON.stringify(res, null, 2));
171
+
172
+ if (res.code || res.errcode) {
173
+ console.warn("[dingtalk-card] Failed to create card instance:", {
174
+ success: false,
175
+ errcode: res.errcode,
176
+ errmsg: res.errmsg,
177
+ code: res.code,
178
+ message: res.message,
179
+ });
180
+ return {
181
+ success: false,
182
+ errcode: res.errcode,
183
+ errmsg: res.errmsg,
184
+ code: res.code,
185
+ message: res.message,
186
+ };
187
+ }
188
+
189
+ return { success: true, result: res };
190
+ } catch (err) {
191
+ return {
192
+ success: false,
193
+ message: String(err),
194
+ };
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Add space to card instance
200
+ *
201
+ * API: POST /v1.0/card/instances/spaces
202
+ */
203
+ export async function createCardSpace(
204
+ clientId: string,
205
+ clientSecret: string,
206
+ outTrackId: string,
207
+ openSpaceId: string,
208
+ ): Promise<CardApiResponse> {
209
+ try {
210
+ const token = await getDingTalkAccessToken(clientId, clientSecret);
211
+
212
+ const res = await jsonPost(
213
+ `${DINGTALK_API_BASE}/card/instances/spaces`,
214
+ { outTrackId, openSpaceId },
215
+ { "x-acs-dingtalk-access-token": token },
216
+ );
217
+
218
+ if (res.code || res.errcode) {
219
+ return {
220
+ success: false,
221
+ errcode: res.errcode,
222
+ errmsg: res.errmsg,
223
+ code: res.code,
224
+ message: res.message,
225
+ };
226
+ }
227
+
228
+ return { success: true, result: res };
229
+ } catch (err) {
230
+ return {
231
+ success: false,
232
+ message: String(err),
233
+ };
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Deliver card to conversation
239
+ *
240
+ * API: POST /v1.0/card/instances/deliver
241
+ */
242
+ export async function deliverCard(
243
+ params: DeliverCardParams,
244
+ ): Promise<CardApiResponse> {
245
+ try {
246
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
247
+
248
+ const body: Record<string, any> = {
249
+ outTrackId: params.outTrackId,
250
+ openSpaceId: params.openSpaceId,
251
+ };
252
+
253
+ if (params.imGroupOpenDeliverModel) {
254
+ body.imGroupOpenDeliverModel = params.imGroupOpenDeliverModel;
255
+ }
256
+
257
+ if (params.imRobotOpenDeliverModel) {
258
+ body.imRobotOpenDeliverModel = params.imRobotOpenDeliverModel;
259
+ }
260
+
261
+ const res = await jsonPost(
262
+ `${DINGTALK_API_BASE}/card/instances/deliver`,
263
+ body,
264
+ { "x-acs-dingtalk-access-token": token },
265
+ );
266
+
267
+ if (res.code || res.errcode) {
268
+ return {
269
+ success: false,
270
+ errcode: res.errcode,
271
+ errmsg: res.errmsg,
272
+ code: res.code,
273
+ message: res.message,
274
+ };
275
+ }
276
+
277
+ return { success: true, result: res };
278
+ } catch (err) {
279
+ return {
280
+ success: false,
281
+ message: String(err),
282
+ };
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Update card data
288
+ *
289
+ * API: PUT /v1.0/card/instances
290
+ */
291
+ export async function updateCard(
292
+ params: UpdateCardParams,
293
+ ): Promise<CardApiResponse> {
294
+ try {
295
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
296
+
297
+ const body: Record<string, any> = {
298
+ outTrackId: params.outTrackId,
299
+ cardData: params.cardData,
300
+ };
301
+
302
+ if (params.userIdType !== undefined) {
303
+ body.userIdType = params.userIdType;
304
+ }
305
+
306
+ const res = await jsonPut(
307
+ `${DINGTALK_API_BASE}/card/instances`,
308
+ body,
309
+ { "x-acs-dingtalk-access-token": token },
310
+ );
311
+
312
+ if (res.code || res.errcode) {
313
+ return {
314
+ success: false,
315
+ errcode: res.errcode,
316
+ errmsg: res.errmsg,
317
+ code: res.code,
318
+ message: res.message,
319
+ };
320
+ }
321
+
322
+ return { success: true, result: res };
323
+ } catch (err) {
324
+ return {
325
+ success: false,
326
+ message: String(err),
327
+ };
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Streaming update for AI card (typewriter effect)
333
+ *
334
+ * API: PUT /v1.0/card/streaming
335
+ *
336
+ * Note: Requires Card.Streaming.Write permission
337
+ */
338
+ export async function updateCardStreaming(
339
+ params: StreamingUpdateParams,
340
+ ): Promise<CardApiResponse> {
341
+ try {
342
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
343
+
344
+ const body: Record<string, any> = {
345
+ outTrackId: params.outTrackId,
346
+ key: params.key,
347
+ content: params.content,
348
+ isFull: params.isFull ?? false,
349
+ };
350
+
351
+ if (params.guid) {
352
+ body.guid = params.guid;
353
+ }
354
+
355
+ const res = await jsonPut(
356
+ `${DINGTALK_API_BASE}/card/streaming`,
357
+ body,
358
+ { "x-acs-dingtalk-access-token": token },
359
+ );
360
+
361
+ if (res.code || res.errcode) {
362
+ return {
363
+ success: false,
364
+ errcode: res.errcode,
365
+ errmsg: res.errmsg,
366
+ code: res.code,
367
+ message: res.message,
368
+ };
369
+ }
370
+
371
+ return { success: true, result: res };
372
+ } catch (err) {
373
+ return {
374
+ success: false,
375
+ message: String(err),
376
+ };
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Build openSpaceId for different scenarios
382
+ */
383
+ export function buildOpenSpaceId(
384
+ type: "IM_GROUP" | "IM_ROBOT",
385
+ id: string,
386
+ ): string {
387
+ // Format: dtv1.card//IM_GROUP.{openConversationId}
388
+ // or: dtv1.card//IM_ROBOT.{senderStaffId}
389
+ return `dtv1.card//${type}.${id}`;
390
+ }
391
+
392
+ /**
393
+ * Generate unique outTrackId
394
+ */
395
+ export function generateOutTrackId(): string {
396
+ const timestamp = Date.now();
397
+ const random = Math.random().toString(36).substring(2, 10);
398
+ return `card_${timestamp}_${random}`;
399
+ }
400
+
401
+ /**
402
+ * High-level: Send a card message to a conversation
403
+ *
404
+ * This combines createCardInstance + createCardSpace + deliverCard
405
+ */
406
+ export async function sendCardMessage(params: {
407
+ clientId: string;
408
+ clientSecret: string;
409
+ robotCode: string;
410
+ cardTemplateId: string;
411
+ cardData: Record<string, string>;
412
+ /** For group chat */
413
+ openConversationId?: string;
414
+ /** For single chat (DM) */
415
+ senderStaffId?: string;
416
+ }): Promise<CardApiResponse & { outTrackId?: string }> {
417
+ const outTrackId = generateOutTrackId();
418
+
419
+ // Determine space type and ID
420
+ const isGroup = !!params.openConversationId;
421
+ const spaceType = isGroup ? "IM_GROUP" : "IM_ROBOT";
422
+ const spaceTargetId = isGroup ? params.openConversationId! : params.senderStaffId!;
423
+ const openSpaceId = buildOpenSpaceId(spaceType, spaceTargetId);
424
+
425
+ // Step 1: Create card instance
426
+ const createResult = await createCardInstance({
427
+ clientId: params.clientId,
428
+ clientSecret: params.clientSecret,
429
+ cardTemplateId: params.cardTemplateId,
430
+ outTrackId,
431
+ cardData: { cardParamMap: params.cardData },
432
+ robotCode: params.robotCode,
433
+ callbackType: "STREAM",
434
+ });
435
+
436
+ if (!createResult.success) {
437
+ console.warn("[dingtalk-card] Failed to create card instance:", createResult);
438
+ return createResult;
439
+ }
440
+
441
+ // Step 2: Add space
442
+ const spaceResult = await createCardSpace(
443
+ params.clientId,
444
+ params.clientSecret,
445
+ outTrackId,
446
+ openSpaceId,
447
+ );
448
+
449
+ if (!spaceResult.success) {
450
+ console.warn("[dingtalk-card] Failed to create card space:", spaceResult);
451
+ return spaceResult;
452
+ }
453
+
454
+ // Step 3: Deliver card
455
+ const deliverParams: DeliverCardParams = {
456
+ clientId: params.clientId,
457
+ clientSecret: params.clientSecret,
458
+ outTrackId,
459
+ openSpaceId,
460
+ };
461
+
462
+ if (isGroup) {
463
+ deliverParams.imGroupOpenDeliverModel = {
464
+ robotCode: params.robotCode,
465
+ };
466
+ } else {
467
+ deliverParams.imRobotOpenDeliverModel = {
468
+ spaceType: "IM_ROBOT",
469
+ robotCode: params.robotCode,
470
+ };
471
+ }
472
+
473
+ const deliverResult = await deliverCard(deliverParams);
474
+
475
+ if (!deliverResult.success) {
476
+ console.warn("[dingtalk-card] Failed to deliver card:", deliverResult);
477
+ return deliverResult;
478
+ }
479
+
480
+ return { success: true, outTrackId, result: deliverResult.result };
481
+ }
482
+
483
+ /**
484
+ * High-level: Send an AI card with streaming support
485
+ *
486
+ * Returns a controller object for streaming updates
487
+ */
488
+ export async function sendStreamingAICard(params: {
489
+ clientId: string;
490
+ clientSecret: string;
491
+ robotCode: string;
492
+ cardTemplateId: string;
493
+ /** Initial card data (use flowStatus="0" for loading state) */
494
+ initialData?: Record<string, string>;
495
+ /** Key for the streaming content variable in the template */
496
+ streamingKey?: string;
497
+ openConversationId?: string;
498
+ senderStaffId?: string;
499
+ }): Promise<{
500
+ success: boolean;
501
+ outTrackId?: string;
502
+ error?: string;
503
+ /** Update streaming content */
504
+ update: (content: string) => Promise<CardApiResponse>;
505
+ /** Mark streaming as complete */
506
+ finish: (finalContent: string) => Promise<CardApiResponse>;
507
+ }> {
508
+ const streamingKey = params.streamingKey || "content";
509
+
510
+ // Send initial card with loading state
511
+ const initialCardData: Record<string, string> = {
512
+ flowStatus: "0", // 0 = streaming in progress
513
+ ...params.initialData,
514
+ };
515
+
516
+ const sendResult = await sendCardMessage({
517
+ clientId: params.clientId,
518
+ clientSecret: params.clientSecret,
519
+ robotCode: params.robotCode,
520
+ cardTemplateId: params.cardTemplateId,
521
+ cardData: initialCardData,
522
+ openConversationId: params.openConversationId,
523
+ senderStaffId: params.senderStaffId,
524
+ });
525
+
526
+ if (!sendResult.success || !sendResult.outTrackId) {
527
+ return {
528
+ success: false,
529
+ error: sendResult.message || sendResult.errmsg || "Failed to send card",
530
+ update: async () => ({ success: false, message: "Card not initialized" }),
531
+ finish: async () => ({ success: false, message: "Card not initialized" }),
532
+ };
533
+ }
534
+
535
+ const outTrackId = sendResult.outTrackId;
536
+
537
+ // Return controller
538
+ return {
539
+ success: true,
540
+ outTrackId,
541
+ update: async (content: string) => {
542
+ return updateCardStreaming({
543
+ clientId: params.clientId,
544
+ clientSecret: params.clientSecret,
545
+ outTrackId,
546
+ key: streamingKey,
547
+ content,
548
+ isFull: false,
549
+ });
550
+ },
551
+ finish: async (finalContent: string) => {
552
+ // First update with final content
553
+ await updateCardStreaming({
554
+ clientId: params.clientId,
555
+ clientSecret: params.clientSecret,
556
+ outTrackId,
557
+ key: streamingKey,
558
+ content: finalContent,
559
+ isFull: true,
560
+ });
561
+
562
+ // Then update flowStatus to complete
563
+ return updateCard({
564
+ clientId: params.clientId,
565
+ clientSecret: params.clientSecret,
566
+ outTrackId,
567
+ cardData: {
568
+ cardParamMap: { flowStatus: "1" }, // 1 = complete
569
+ },
570
+ });
571
+ },
572
+ };
573
+ }
@@ -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 {
@@ -749,16 +750,89 @@ function resolveDeliverText(payload: any, log?: any): string | undefined {
749
750
  return text || undefined;
750
751
  }
751
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
+
752
808
  async function deliverReply(target: any, text: string, log?: any): Promise<void> {
753
809
  const now = Date.now();
754
810
  const chunkLimit = 2000;
755
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
+ }
756
827
 
757
828
  // Determine if this message should use markdown format
758
829
  let isMarkdown: boolean;
759
830
  if (messageFormat === 'auto') {
760
831
  isMarkdown = detectMarkdownContent(text);
761
832
  log?.info?.("[dingtalk] Auto-detected format: " + (isMarkdown ? "markdown" : "text"));
833
+ } else if (messageFormat === 'card') {
834
+ // Card failed, fallback to markdown
835
+ isMarkdown = true;
762
836
  } else {
763
837
  // Support both "markdown" and "richtext" (they're equivalent for DingTalk)
764
838
  isMarkdown = messageFormat === "markdown" || messageFormat === "richtext";