@soimy/dingtalk 2.6.5

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 ADDED
@@ -0,0 +1,1807 @@
1
+ import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream';
2
+ import axios from 'axios';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk';
8
+ import { buildChannelConfigSchema } from 'openclaw/plugin-sdk';
9
+ import { maskSensitiveData, cleanupOrphanedTempFiles, retryWithBackoff } from './utils';
10
+ import { getDingTalkRuntime } from './runtime';
11
+ import { DingTalkConfigSchema } from './config-schema.js';
12
+ import { registerPeerId, resolveOriginalPeerId } from './peer-id-registry';
13
+ import { ConnectionManager } from './connection-manager';
14
+ import { dingtalkOnboardingAdapter } from './onboarding.js';
15
+ import type {
16
+ DingTalkConfig,
17
+ TokenInfo,
18
+ DingTalkInboundMessage,
19
+ MessageContent,
20
+ SendMessageOptions,
21
+ MediaFile,
22
+ HandleDingTalkMessageParams,
23
+ ProactiveMessagePayload,
24
+ SessionWebhookResponse,
25
+ AxiosResponse,
26
+ Logger,
27
+ ResolvedAccount,
28
+ GatewayStartContext,
29
+ GatewayStopResult,
30
+ AICardInstance,
31
+ AICardStreamingRequest,
32
+ ConnectionManagerConfig,
33
+ DingTalkChannelPlugin,
34
+ } from './types';
35
+ import { AICardStatus, ConnectionState } from './types';
36
+ import { detectMediaTypeFromExtension, uploadMedia as uploadMediaUtil } from './media-utils';
37
+
38
+ /**
39
+ * Get current timestamp in ISO 8601 format for status tracking
40
+ */
41
+ function getCurrentTimestamp(): number {
42
+ return Date.now();
43
+ }
44
+
45
+ // Access Token cache - keyed by clientId for multi-account support
46
+ interface TokenCache {
47
+ accessToken: string;
48
+ expiry: number;
49
+ }
50
+ const accessTokenCache = new Map<string, TokenCache>();
51
+
52
+ // Global logger reference for use across module methods
53
+ let currentLogger: Logger | undefined;
54
+
55
+ // AI Card instance cache for streaming updates
56
+ const aiCardInstances = new Map<string, AICardInstance>();
57
+
58
+ // Target to active AI Card instance ID mapping (accountId:conversationId -> cardInstanceId)
59
+ // Used to quickly lookup existing active cards for a target
60
+ const activeCardsByTarget = new Map<string, string>();
61
+
62
+ // Card cache TTL (1 hour)
63
+ const CARD_CACHE_TTL = 60 * 60 * 1000; // 1 hour
64
+
65
+ // DingTalk API base URL
66
+ const DINGTALK_API = 'https://api.dingtalk.com';
67
+
68
+ // ============ Message Deduplication ============
69
+ // Prevents duplicate message processing when DingTalk retries delivery
70
+ // Uses pure in-memory storage with short TTL and lazy cleanup during processing
71
+
72
+ const processedMessages = new Map<string, number>(); // Map<dedupKey, expiresAt>
73
+ const MESSAGE_DEDUP_TTL = 60000; // 60 seconds
74
+ const MESSAGE_DEDUP_MAX_SIZE = 1000; // Hard cap to prevent memory pressure during bursts
75
+ let messageCounter = 0; // Counter for deterministic cleanup triggering (safe due to Node.js single-threaded event loop)
76
+
77
+ // Check if message was already processed (with lazy cleanup of expired entries)
78
+ function isMessageProcessed(dedupKey: string): boolean {
79
+ const now = Date.now();
80
+ const expiresAt = processedMessages.get(dedupKey);
81
+
82
+ if (expiresAt === undefined) {
83
+ return false;
84
+ }
85
+
86
+ // Lazy cleanup: remove expired entry if found
87
+ if (now >= expiresAt) {
88
+ processedMessages.delete(dedupKey);
89
+ return false;
90
+ }
91
+
92
+ return true;
93
+ }
94
+
95
+ // Mark message as processed with bot-scoped key
96
+ function markMessageProcessed(dedupKey: string): void {
97
+ const expiresAt = Date.now() + MESSAGE_DEDUP_TTL;
98
+ processedMessages.set(dedupKey, expiresAt);
99
+
100
+ // Hard cap: if Map exceeds max size, force immediate cleanup
101
+ if (processedMessages.size > MESSAGE_DEDUP_MAX_SIZE) {
102
+ const now = Date.now();
103
+ for (const [key, expiry] of processedMessages.entries()) {
104
+ if (now >= expiry) {
105
+ processedMessages.delete(key);
106
+ }
107
+ }
108
+ // If still over limit after cleanup, clear oldest entries (safety valve)
109
+ // Maps maintain insertion order, so we can delete early entries
110
+ if (processedMessages.size > MESSAGE_DEDUP_MAX_SIZE) {
111
+ const removeCount = processedMessages.size - MESSAGE_DEDUP_MAX_SIZE;
112
+ let removed = 0;
113
+ for (const key of processedMessages.keys()) {
114
+ processedMessages.delete(key);
115
+ if (++removed >= removeCount) break;
116
+ }
117
+ }
118
+ return; // Skip regular cleanup since we just did a full sweep
119
+ }
120
+
121
+ // Lazy cleanup: remove expired entries deterministically every 10 messages
122
+ // With 60s TTL, Map stays small under normal load, but we avoid cleanup on every message for performance
123
+ messageCounter++;
124
+ if (messageCounter >= 10) {
125
+ messageCounter = 0;
126
+ const now = Date.now();
127
+ for (const [key, expiry] of processedMessages.entries()) {
128
+ if (now >= expiry) {
129
+ processedMessages.delete(key);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // Authorization helpers
136
+ type NormalizedAllowFrom = {
137
+ entries: string[];
138
+ entriesLower: string[];
139
+ hasWildcard: boolean;
140
+ hasEntries: boolean;
141
+ };
142
+
143
+ /**
144
+ * Normalize allowFrom list to standardized format
145
+ */
146
+ function normalizeAllowFrom(list?: Array<string>): NormalizedAllowFrom {
147
+ const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
148
+ const hasWildcard = entries.includes('*');
149
+ const normalized = entries
150
+ .filter((value) => value !== '*')
151
+ .map((value) => value.replace(/^(dingtalk|dd|ding):/i, ''));
152
+ const normalizedLower = normalized.map((value) => value.toLowerCase());
153
+ return {
154
+ entries: normalized,
155
+ entriesLower: normalizedLower,
156
+ hasWildcard,
157
+ hasEntries: entries.length > 0,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Check if sender is allowed based on allowFrom list
163
+ */
164
+ function isSenderAllowed(params: { allow: NormalizedAllowFrom; senderId?: string }): boolean {
165
+ const { allow, senderId } = params;
166
+ if (!allow.hasEntries) return true;
167
+ if (allow.hasWildcard) return true;
168
+ if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) return true;
169
+ return false;
170
+ }
171
+
172
+ // 群组通道授权
173
+ function isSenderGroupAllowed(params: { allow: NormalizedAllowFrom; groupId?: string }): boolean {
174
+ const { allow, groupId } = params;
175
+
176
+ if (groupId && allow.entriesLower.includes(groupId.toLowerCase())) return true;
177
+ return false;
178
+ }
179
+
180
+ // Helper function to check if a card is in a terminal state
181
+ function isCardInTerminalState(state: string): boolean {
182
+ return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
183
+ }
184
+
185
+ // Clean up old AI card instances from cache
186
+ function cleanupCardCache() {
187
+ const now = Date.now();
188
+
189
+ // Clean up AI card instances that are in FINISHED or FAILED state
190
+ // Active cards (PROCESSING, INPUTING) are not cleaned up even if they exceed TTL
191
+ for (const [cardInstanceId, instance] of aiCardInstances.entries()) {
192
+ if (isCardInTerminalState(instance.state) && now - instance.lastUpdated > CARD_CACHE_TTL) {
193
+ // Remove from aiCardInstances
194
+ aiCardInstances.delete(cardInstanceId);
195
+
196
+ // Remove from activeCardsByTarget mapping (break after first match for efficiency)
197
+ for (const [targetKey, mappedCardId] of activeCardsByTarget.entries()) {
198
+ if (mappedCardId === cardInstanceId) {
199
+ activeCardsByTarget.delete(targetKey);
200
+ break; // Each card should only have one target mapping
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Get the current logger instance
209
+ * Useful for methods that don't receive log as a parameter
210
+ */
211
+ function getLogger(): Logger | undefined {
212
+ return currentLogger;
213
+ }
214
+
215
+ // Helper function to detect markdown and extract title
216
+ function detectMarkdownAndExtractTitle(
217
+ text: string,
218
+ options: SendMessageOptions,
219
+ defaultTitle: string
220
+ ): { useMarkdown: boolean; title: string } {
221
+ const hasMarkdown = /^[#*>-]|[*_`#[\]]/.test(text) || text.includes('\n');
222
+ const useMarkdown = options.useMarkdown !== false && (options.useMarkdown || hasMarkdown);
223
+
224
+ const title =
225
+ options.title ||
226
+ (useMarkdown
227
+ ? text
228
+ .split('\n')[0]
229
+ .replace(/^[#*\s\->]+/, '')
230
+ .slice(0, 20) || defaultTitle
231
+ : defaultTitle);
232
+
233
+ return { useMarkdown, title };
234
+ }
235
+
236
+ // ============ Group Members Persistence ============
237
+
238
+ function groupMembersFilePath(storePath: string, groupId: string): string {
239
+ const dir = path.join(path.dirname(storePath), 'dingtalk-members');
240
+ const safeId = groupId.replace(/\+/g, '-').replace(/\//g, '_');
241
+ return path.join(dir, `${safeId}.json`);
242
+ }
243
+
244
+ function noteGroupMember(storePath: string, groupId: string, userId: string, name: string): void {
245
+ if (!userId || !name) return;
246
+ const filePath = groupMembersFilePath(storePath, groupId);
247
+ let roster: Record<string, string> = {};
248
+ try {
249
+ roster = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
250
+ } catch {}
251
+ if (roster[userId] === name) return;
252
+ roster[userId] = name;
253
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
254
+ fs.writeFileSync(filePath, JSON.stringify(roster, null, 2));
255
+ }
256
+
257
+ function formatGroupMembers(storePath: string, groupId: string): string | undefined {
258
+ const filePath = groupMembersFilePath(storePath, groupId);
259
+ let roster: Record<string, string> = {};
260
+ try {
261
+ roster = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ const entries = Object.entries(roster);
266
+ if (entries.length === 0) return undefined;
267
+ return entries.map(([id, name]) => `${name} (${id})`).join(', ');
268
+ }
269
+
270
+ // ============ Group Config Resolution ============
271
+
272
+ function resolveGroupConfig(cfg: DingTalkConfig, groupId: string): { systemPrompt?: string } | undefined {
273
+ const groups = cfg.groups;
274
+ if (!groups) return undefined;
275
+ return groups[groupId] || groups['*'] || undefined;
276
+ }
277
+
278
+ // ============ Target Prefix Helpers ============
279
+
280
+ /**
281
+ * Strip group: or user: prefix from target ID
282
+ * Returns the raw targetId and whether it was explicitly marked as a user
283
+ */
284
+ function stripTargetPrefix(target: string): { targetId: string; isExplicitUser: boolean } {
285
+ if (target.startsWith('group:')) {
286
+ return { targetId: target.slice(6), isExplicitUser: false };
287
+ }
288
+ if (target.startsWith('user:')) {
289
+ return { targetId: target.slice(5), isExplicitUser: true };
290
+ }
291
+ return { targetId: target, isExplicitUser: false };
292
+ }
293
+
294
+ // ============ Config Helpers ============
295
+
296
+ function getConfig(cfg: OpenClawConfig, accountId?: string): DingTalkConfig {
297
+ const dingtalkCfg = cfg?.channels?.dingtalk as DingTalkConfig | undefined;
298
+ if (!dingtalkCfg) return {} as DingTalkConfig;
299
+
300
+ if (accountId && dingtalkCfg.accounts?.[accountId]) {
301
+ return dingtalkCfg.accounts[accountId];
302
+ }
303
+
304
+ return dingtalkCfg;
305
+ }
306
+
307
+ function isConfigured(cfg: OpenClawConfig, accountId?: string): boolean {
308
+ const config = getConfig(cfg, accountId);
309
+ return Boolean(config.clientId && config.clientSecret);
310
+ }
311
+
312
+ const DEFAULT_AGENT_ID = 'main';
313
+ const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
314
+ const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/g;
315
+ const LEADING_DASH_RE = /^-+/;
316
+ const TRAILING_DASH_RE = /-+$/;
317
+
318
+ function normalizeAgentId(value: string | undefined | null): string {
319
+ const trimmed = (value ?? '').trim();
320
+ if (!trimmed) return DEFAULT_AGENT_ID;
321
+ if (VALID_AGENT_ID_RE.test(trimmed)) return trimmed.toLowerCase();
322
+ return (
323
+ trimmed
324
+ .toLowerCase()
325
+ .replace(INVALID_AGENT_ID_CHARS_RE, '-')
326
+ .replace(LEADING_DASH_RE, '')
327
+ .replace(TRAILING_DASH_RE, '')
328
+ .slice(0, 64) || DEFAULT_AGENT_ID
329
+ );
330
+ }
331
+
332
+ function resolveUserPath(input: string): string {
333
+ const trimmed = input.trim();
334
+ if (!trimmed) return trimmed;
335
+ if (trimmed.startsWith('~')) {
336
+ const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
337
+ return path.resolve(expanded);
338
+ }
339
+ return path.resolve(trimmed);
340
+ }
341
+
342
+ function resolveDefaultAgentWorkspaceDir(): string {
343
+ const profile = process.env.OPENCLAW_PROFILE?.trim();
344
+ if (profile && profile.toLowerCase() !== 'default') {
345
+ return path.join(os.homedir(), '.openclaw', `workspace-${profile}`);
346
+ }
347
+ return path.join(os.homedir(), '.openclaw', 'workspace');
348
+ }
349
+
350
+ interface AgentConfig {
351
+ id?: string;
352
+ default?: boolean;
353
+ workspace?: string;
354
+ }
355
+
356
+ function resolveDefaultAgentId(cfg: OpenClawConfig): string {
357
+ const agents: AgentConfig[] = cfg.agents?.list ?? [];
358
+ if (agents.length === 0) return DEFAULT_AGENT_ID;
359
+ const defaults = agents.filter((agent: AgentConfig) => agent?.default);
360
+ const chosen = (defaults[0] ?? agents[0])?.id?.trim();
361
+ return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
362
+ }
363
+
364
+ function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string): string {
365
+ const id = normalizeAgentId(agentId);
366
+ const agents: AgentConfig[] = cfg.agents?.list ?? [];
367
+ const agent = agents.find((entry: AgentConfig) => normalizeAgentId(entry?.id) === id);
368
+ const configured = agent?.workspace?.trim();
369
+ if (configured) return resolveUserPath(configured);
370
+ const defaultAgentId = resolveDefaultAgentId(cfg);
371
+ if (id === defaultAgentId) {
372
+ const fallback = cfg.agents?.defaults?.workspace?.trim();
373
+ if (fallback) return resolveUserPath(fallback);
374
+ return resolveDefaultAgentWorkspaceDir();
375
+ }
376
+ return path.join(os.homedir(), '.openclaw', `workspace-${id}`);
377
+ }
378
+
379
+ // Get Access Token with retry logic - uses clientId-based cache for multi-account support
380
+ async function getAccessToken(config: DingTalkConfig, log?: Logger): Promise<string> {
381
+ const cacheKey = config.clientId;
382
+ const now = Date.now();
383
+ const cached = accessTokenCache.get(cacheKey);
384
+
385
+ if (cached && cached.expiry > now + 60000) {
386
+ return cached.accessToken;
387
+ }
388
+
389
+ const token = await retryWithBackoff(
390
+ async () => {
391
+ const response = await axios.post<TokenInfo>('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
392
+ appKey: config.clientId,
393
+ appSecret: config.clientSecret,
394
+ });
395
+
396
+ // Store in cache with clientId as key
397
+ accessTokenCache.set(cacheKey, {
398
+ accessToken: response.data.accessToken,
399
+ expiry: now + response.data.expireIn * 1000,
400
+ });
401
+
402
+ return response.data.accessToken;
403
+ },
404
+ { maxRetries: 3, log }
405
+ );
406
+
407
+ return token;
408
+ }
409
+
410
+ // Wrapper function to upload media via uploadMediaUtil with getAccessToken bound
411
+ async function uploadMedia(
412
+ config: DingTalkConfig,
413
+ mediaPath: string,
414
+ mediaType: 'image' | 'voice' | 'video' | 'file',
415
+ log?: Logger
416
+ ): Promise<string | null> {
417
+ return uploadMediaUtil(config, mediaPath, mediaType, getAccessToken, log);
418
+ }
419
+
420
+ // Send text/markdown proactive message via DingTalk OpenAPI
421
+ async function sendProactiveTextOrMarkdown(
422
+ config: DingTalkConfig,
423
+ target: string,
424
+ text: string,
425
+ options: SendMessageOptions = {}
426
+ ): Promise<AxiosResponse> {
427
+ const token = await getAccessToken(config, options.log);
428
+ const log = options.log || getLogger();
429
+
430
+ // Support group: and user: prefixes, and resolve case-sensitive conversationId
431
+ const { targetId, isExplicitUser } = stripTargetPrefix(target);
432
+ const resolvedTarget = resolveOriginalPeerId(targetId);
433
+ const isGroup = !isExplicitUser && resolvedTarget.startsWith('cid');
434
+
435
+ const url = isGroup
436
+ ? 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'
437
+ : 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
438
+
439
+ // Use shared helper function for markdown detection and title extraction
440
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, 'OpenClaw 提醒');
441
+
442
+ log?.debug?.(
443
+ `[DingTalk] Sending proactive message to ${isGroup ? 'group' : 'user'} ${resolvedTarget} with title "${title}"`
444
+ );
445
+
446
+ // Choose msgKey based on whether we're sending markdown or plain text
447
+ // Note: DingTalk's proactive message API uses predefined message templates
448
+ // sampleMarkdown supports markdown formatting, sampleText for plain text
449
+ const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
450
+ const msgParam = useMarkdown ? JSON.stringify({ title, text }) : JSON.stringify({ content: text });
451
+
452
+ const payload: ProactiveMessagePayload = {
453
+ robotCode: config.robotCode || config.clientId,
454
+ msgKey,
455
+ msgParam,
456
+ };
457
+
458
+ if (isGroup) {
459
+ payload.openConversationId = resolvedTarget;
460
+ } else {
461
+ payload.userIds = [resolvedTarget];
462
+ }
463
+
464
+ const result = await axios({
465
+ url,
466
+ method: 'POST',
467
+ data: payload,
468
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
469
+ });
470
+ return result.data;
471
+ }
472
+
473
+ // Send proactive media message via DingTalk OpenAPI
474
+ async function sendProactiveMedia(
475
+ config: DingTalkConfig,
476
+ target: string,
477
+ mediaPath: string,
478
+ mediaType: 'image' | 'voice' | 'video' | 'file',
479
+ options: SendMessageOptions & { accountId?: string } = {}
480
+ ): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
481
+ const log = options.log || getLogger();
482
+
483
+ try {
484
+ // Upload media first to get media_id
485
+ const mediaId = await uploadMedia(config, mediaPath, mediaType, log);
486
+ if (!mediaId) {
487
+ return { ok: false, error: 'Failed to upload media' };
488
+ }
489
+
490
+ const token = await getAccessToken(config, log);
491
+ const { targetId, isExplicitUser } = stripTargetPrefix(target);
492
+ const resolvedTarget = resolveOriginalPeerId(targetId);
493
+ const isGroup = !isExplicitUser && resolvedTarget.startsWith('cid');
494
+
495
+ const DINGTALK_API = 'https://api.dingtalk.com';
496
+ const url = isGroup
497
+ ? `${DINGTALK_API}/v1.0/robot/groupMessages/send`
498
+ : `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`;
499
+
500
+ // Build payload based on media type
501
+ // For personal messages (oToMessages), use native media message types
502
+ // For group messages (groupMessages), use sampleImageMsg/sampleAudio/etc.
503
+ let msgKey: string;
504
+ let msgParam: string;
505
+
506
+ if (mediaType === 'image') {
507
+ msgKey = 'sampleImageMsg';
508
+ msgParam = JSON.stringify({ photoURL: mediaId });
509
+ } else if (mediaType === 'voice') {
510
+ msgKey = 'sampleAudio';
511
+ msgParam = JSON.stringify({ mediaId, duration: '0' });
512
+ } else {
513
+ // File-like media (including video)
514
+ // Note: sampleVideo requires a cover image (picMediaId) which we don't have,
515
+ // so we fall back to sampleFile for video to avoid .octet-stream issues.
516
+ const filename = path.basename(mediaPath);
517
+ const defaultExt = mediaType === 'video' ? 'mp4' : 'file';
518
+ const ext = path.extname(mediaPath).slice(1) || defaultExt;
519
+ msgKey = 'sampleFile';
520
+ msgParam = JSON.stringify({ mediaId, fileName: filename, fileType: ext });
521
+ }
522
+
523
+ const payload: ProactiveMessagePayload = {
524
+ robotCode: config.robotCode || config.clientId,
525
+ msgKey,
526
+ msgParam,
527
+ };
528
+
529
+ if (isGroup) {
530
+ payload.openConversationId = resolvedTarget;
531
+ } else {
532
+ payload.userIds = [resolvedTarget];
533
+ }
534
+
535
+ log?.debug?.(
536
+ `[DingTalk] Sending proactive ${mediaType} message to ${isGroup ? 'group' : 'user'} ${resolvedTarget}`
537
+ );
538
+
539
+ const result = await axios({
540
+ url,
541
+ method: 'POST',
542
+ data: payload,
543
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
544
+ });
545
+
546
+ const messageId = result.data?.processQueryKey || result.data?.messageId;
547
+ return { ok: true, data: result.data, messageId };
548
+ } catch (err: any) {
549
+ log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
550
+ if (axios.isAxiosError(err) && err.response) {
551
+ log?.error?.(`[DingTalk] Response: ${JSON.stringify(err.response.data)}`);
552
+ }
553
+ return { ok: false, error: err.message };
554
+ }
555
+ }
556
+
557
+ // Download media file to agent workspace (sandbox-compatible)
558
+ // workspacePath: the agent's workspace directory (e.g., /home/node/.openclaw/workspace)
559
+ async function downloadMedia(
560
+ config: DingTalkConfig,
561
+ downloadCode: string,
562
+ workspacePath: string,
563
+ log?: Logger
564
+ ): Promise<MediaFile | null> {
565
+ const formatAxiosErrorData = (value: unknown): string | undefined => {
566
+ if (value === null || value === undefined) return undefined;
567
+ if (Buffer.isBuffer(value)) return `<buffer ${value.length} bytes>`;
568
+ if (value instanceof ArrayBuffer) return `<arraybuffer ${value.byteLength} bytes>`;
569
+ if (typeof value === 'string') return value.length > 500 ? `${value.slice(0, 500)}…` : value;
570
+ try {
571
+ return JSON.stringify(maskSensitiveData(value));
572
+ } catch {
573
+ return String(value);
574
+ }
575
+ };
576
+
577
+ if (!downloadCode) {
578
+ log?.error?.('[DingTalk] downloadMedia requires downloadCode to be provided.');
579
+ return null;
580
+ }
581
+ if (!config.robotCode) {
582
+ if (log?.error) {
583
+ log.error('[DingTalk] downloadMedia requires robotCode to be configured.');
584
+ }
585
+ return null;
586
+ }
587
+ try {
588
+ const token = await getAccessToken(config, log);
589
+ const response = await axios.post(
590
+ 'https://api.dingtalk.com/v1.0/robot/messageFiles/download',
591
+ { downloadCode, robotCode: config.robotCode },
592
+ { headers: { 'x-acs-dingtalk-access-token': token } }
593
+ );
594
+ const payload = response.data as Record<string, any>;
595
+ const downloadUrl = payload?.downloadUrl ?? payload?.data?.downloadUrl;
596
+ if (!downloadUrl) {
597
+ const payloadDetail = formatAxiosErrorData(payload);
598
+ log?.error?.(`[DingTalk] downloadMedia missing downloadUrl. payload=${payloadDetail ?? 'unknown'}`);
599
+ return null;
600
+ }
601
+ const mediaResponse = await axios.get(downloadUrl, { responseType: 'arraybuffer' });
602
+ const contentType = mediaResponse.headers['content-type'] || 'application/octet-stream';
603
+ const buffer = Buffer.from(mediaResponse.data as ArrayBuffer);
604
+
605
+ // Save to agent workspace's media/inbound/ directory (sandbox-compatible)
606
+ // This ensures the file is accessible within the sandbox
607
+ const mediaDir = path.join(workspacePath, 'media', 'inbound');
608
+ fs.mkdirSync(mediaDir, { recursive: true });
609
+
610
+ const ext = contentType.split('/')[1]?.split(';')[0] || 'bin';
611
+ const filename = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
612
+ const mediaPath = path.join(mediaDir, filename);
613
+
614
+ fs.writeFileSync(mediaPath, buffer);
615
+ log?.debug?.(`[DingTalk] Media saved to workspace: ${mediaPath}`);
616
+ return { path: mediaPath, mimeType: contentType };
617
+ } catch (err: any) {
618
+ if (log?.error) {
619
+ if (axios.isAxiosError(err)) {
620
+ const status = err.response?.status;
621
+ const statusText = err.response?.statusText;
622
+ const dataDetail = formatAxiosErrorData(err.response?.data);
623
+ const code = err.code ? ` code=${err.code}` : '';
624
+ const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ''}` : '';
625
+ log.error(`[DingTalk] Failed to download media:${statusLabel}${code} message=${err.message}`);
626
+ if (dataDetail) {
627
+ log.error(`[DingTalk] downloadMedia response data: ${dataDetail}`);
628
+ }
629
+ } else {
630
+ log.error(`[DingTalk] Failed to download media: ${err.message}`);
631
+ }
632
+ }
633
+ return null;
634
+ }
635
+ }
636
+
637
+ function extractMessageContent(data: DingTalkInboundMessage): MessageContent {
638
+ const msgtype = data.msgtype || 'text';
639
+
640
+ // Logic for different message types
641
+ if (msgtype === 'text') {
642
+ return { text: data.text?.content?.trim() || '', messageType: 'text' };
643
+ }
644
+
645
+ // Improved richText parsing: join all text/at components and extract first picture
646
+ if (msgtype === 'richText') {
647
+ const richTextParts = data.content?.richText || [];
648
+ let text = '';
649
+ let pictureDownloadCode: string | undefined;
650
+ for (const part of richTextParts) {
651
+ // Handle text content: include explicit text type or undefined type field (DingTalk may omit type)
652
+ if (part.text && (part.type === 'text' || part.type === undefined)) text += part.text;
653
+ if (part.type === 'at' && part.atName) text += `@${part.atName} `;
654
+ // Extract first picture's downloadCode from richText
655
+ if (part.type === 'picture' && part.downloadCode && !pictureDownloadCode) {
656
+ pictureDownloadCode = part.downloadCode;
657
+ }
658
+ }
659
+ return {
660
+ text: text.trim() || (pictureDownloadCode ? '<media:image>' : '[富文本消息]'),
661
+ mediaPath: pictureDownloadCode,
662
+ mediaType: pictureDownloadCode ? 'image' : undefined,
663
+ messageType: 'richText',
664
+ };
665
+ }
666
+
667
+ if (msgtype === 'picture') {
668
+ return { text: '<media:image>', mediaPath: data.content?.downloadCode, mediaType: 'image', messageType: 'picture' };
669
+ }
670
+
671
+ if (msgtype === 'audio') {
672
+ return {
673
+ text: data.content?.recognition || '<media:voice>',
674
+ mediaPath: data.content?.downloadCode,
675
+ mediaType: 'audio',
676
+ messageType: 'audio',
677
+ };
678
+ }
679
+
680
+ if (msgtype === 'video') {
681
+ return { text: '<media:video>', mediaPath: data.content?.downloadCode, mediaType: 'video', messageType: 'video' };
682
+ }
683
+
684
+ if (msgtype === 'file') {
685
+ return {
686
+ text: `<media:file> (${data.content?.fileName || '文件'})`,
687
+ mediaPath: data.content?.downloadCode,
688
+ mediaType: 'file',
689
+ messageType: 'file',
690
+ };
691
+ }
692
+
693
+ // Fallback
694
+ return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype };
695
+ }
696
+
697
+ // Send message via sessionWebhook
698
+ async function sendBySession(
699
+ config: DingTalkConfig,
700
+ sessionWebhook: string,
701
+ text: string,
702
+ options: SendMessageOptions = {}
703
+ ): Promise<AxiosResponse> {
704
+ const token = await getAccessToken(config, options.log);
705
+ const log = options.log || getLogger();
706
+
707
+ // If mediaPath is provided, upload media and send as native media message
708
+ if (options.mediaPath && options.mediaType) {
709
+ const mediaId = await uploadMedia(config, options.mediaPath, options.mediaType, log);
710
+ if (mediaId) {
711
+ let body: any;
712
+
713
+ // Construct message body based on media type
714
+ if (options.mediaType === 'image') {
715
+ body = { msgtype: 'image', image: { media_id: mediaId } };
716
+ } else if (options.mediaType === 'voice') {
717
+ body = { msgtype: 'voice', voice: { media_id: mediaId } };
718
+ } else if (options.mediaType === 'video') {
719
+ body = { msgtype: 'video', video: { media_id: mediaId } };
720
+ } else if (options.mediaType === 'file') {
721
+ body = { msgtype: 'file', file: { media_id: mediaId } };
722
+ }
723
+
724
+ if (body) {
725
+ const result = await axios({
726
+ url: sessionWebhook,
727
+ method: 'POST',
728
+ data: body,
729
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
730
+ });
731
+ return result.data;
732
+ }
733
+ } else {
734
+ log?.warn?.('[DingTalk] Media upload failed, falling back to text description');
735
+ }
736
+ }
737
+
738
+ // Use shared helper function for markdown detection and title extraction
739
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, 'Clawdbot 消息');
740
+
741
+ let body: SessionWebhookResponse;
742
+ if (useMarkdown) {
743
+ let finalText = text;
744
+ if (options.atUserId) finalText = `${finalText} @${options.atUserId}`;
745
+ body = { msgtype: 'markdown', markdown: { title, text: finalText } };
746
+ } else {
747
+ body = { msgtype: 'text', text: { content: text } };
748
+ }
749
+
750
+ if (options.atUserId) body.at = { atUserIds: [options.atUserId], isAtAll: false };
751
+
752
+ const result = await axios({
753
+ url: sessionWebhook,
754
+ method: 'POST',
755
+ data: body,
756
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
757
+ });
758
+ return result.data;
759
+ }
760
+
761
+ // ============ AI Card API Functions ============
762
+
763
+ /**
764
+ * Create and deliver an AI Card using the new DingTalk API (createAndDeliver)
765
+ * @param config DingTalk configuration
766
+ * @param conversationId Conversation ID (starts with 'cid' for groups, user ID for DM)
767
+ * @param data Original message data for context
768
+ * @param accountId Account ID for multi-account support
769
+ * @param log Logger instance
770
+ * @returns AI Card instance or null on failure
771
+ */
772
+ async function createAICard(
773
+ config: DingTalkConfig,
774
+ conversationId: string,
775
+ data: DingTalkInboundMessage,
776
+ accountId: string,
777
+ log?: Logger
778
+ ): Promise<AICardInstance | null> {
779
+ try {
780
+ const token = await getAccessToken(config, log);
781
+ // Use crypto.randomUUID() for robust GUID generation instead of Date.now() + random
782
+ const cardInstanceId = `card_${randomUUID()}`;
783
+
784
+ log?.info?.(`[DingTalk][AICard] Creating and delivering card outTrackId=${cardInstanceId}`);
785
+ log?.debug?.(`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${conversationId}`);
786
+
787
+ const isGroup = conversationId.startsWith('cid');
788
+
789
+ if (!config.cardTemplateId) {
790
+ throw new Error('DingTalk cardTemplateId is not configured.');
791
+ }
792
+
793
+ // Build the createAndDeliver request body
794
+ const createAndDeliverBody = {
795
+ cardTemplateId: config.cardTemplateId,
796
+ outTrackId: cardInstanceId,
797
+ cardData: {
798
+ cardParamMap: {},
799
+ },
800
+ callbackType: 'STREAM',
801
+ imGroupOpenSpaceModel: { supportForward: true },
802
+ imRobotOpenSpaceModel: { supportForward: true },
803
+ openSpaceId: isGroup ? `dtv1.card//IM_GROUP.${conversationId}` : `dtv1.card//IM_ROBOT.${conversationId}`,
804
+ userIdType: 1,
805
+ imGroupOpenDeliverModel: isGroup ? { robotCode: config.robotCode || config.clientId } : undefined,
806
+ imRobotOpenDeliverModel: !isGroup ? { spaceType: 'IM_ROBOT' } : undefined,
807
+ };
808
+
809
+ if (isGroup && !config.robotCode) {
810
+ log?.warn?.(
811
+ '[DingTalk][AICard] robotCode not configured, using clientId as fallback. ' +
812
+ 'For best compatibility, set robotCode explicitly in config.'
813
+ );
814
+ }
815
+
816
+ log?.debug?.(
817
+ `[DingTalk][AICard] POST /v1.0/card/instances/createAndDeliver body=${JSON.stringify(createAndDeliverBody)}`
818
+ );
819
+ const resp = await axios.post(`${DINGTALK_API}/v1.0/card/instances/createAndDeliver`, createAndDeliverBody, {
820
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
821
+ });
822
+ log?.debug?.(
823
+ `[DingTalk][AICard] CreateAndDeliver response: status=${resp.status} data=${JSON.stringify(resp.data)}`
824
+ );
825
+
826
+ // Cache the AI card instance with config reference for token refresh
827
+ const aiCardInstance: AICardInstance = {
828
+ cardInstanceId,
829
+ accessToken: token,
830
+ conversationId,
831
+ createdAt: Date.now(),
832
+ lastUpdated: Date.now(),
833
+ state: AICardStatus.PROCESSING, // Initial state after creation
834
+ config, // Store config reference for token refresh
835
+ };
836
+ aiCardInstances.set(cardInstanceId, aiCardInstance);
837
+
838
+ // Add mapping from target to active card ID (accountId:conversationId -> cardInstanceId)
839
+ const targetKey = `${accountId}:${conversationId}`;
840
+ activeCardsByTarget.set(targetKey, cardInstanceId);
841
+ log?.debug?.(`[DingTalk][AICard] Registered active card mapping: ${targetKey} -> ${cardInstanceId}`);
842
+
843
+ return aiCardInstance;
844
+ } catch (err: any) {
845
+ log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
846
+ if (err.response) {
847
+ log?.error?.(
848
+ `[DingTalk][AICard] Error response: status=${err.response.status} data=${JSON.stringify(err.response.data)}`
849
+ );
850
+ }
851
+ return null;
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Stream update AI Card content using the new DingTalk API
857
+ * Always use isFull=true to fully replace the Markdown content
858
+ * @param card AI Card instance
859
+ * @param content Content to stream
860
+ * @param finished Whether this is the final update (isFinalize=true)
861
+ * @param log Logger instance
862
+ */
863
+ async function streamAICard(
864
+ card: AICardInstance,
865
+ content: string,
866
+ finished: boolean = false,
867
+ log?: Logger
868
+ ): Promise<void> {
869
+ // Refresh token if it's been more than 1.5 hours since card creation (tokens expire after 2 hours)
870
+ const tokenAge = Date.now() - card.createdAt;
871
+ const TOKEN_REFRESH_THRESHOLD = 90 * 60 * 1000; // 1.5 hours in milliseconds
872
+
873
+ if (tokenAge > TOKEN_REFRESH_THRESHOLD && card.config) {
874
+ log?.debug?.('[DingTalk][AICard] Token age exceeds threshold, refreshing...');
875
+ try {
876
+ card.accessToken = await getAccessToken(card.config, log);
877
+ log?.debug?.('[DingTalk][AICard] Token refreshed successfully');
878
+ } catch (err: any) {
879
+ log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${err.message}`);
880
+ // Continue with old token, let the API call fail if token is invalid
881
+ }
882
+ }
883
+
884
+ // Call streaming API to update content with full replacement
885
+ const streamBody: AICardStreamingRequest = {
886
+ outTrackId: card.cardInstanceId,
887
+ guid: randomUUID(), // Use crypto.randomUUID() for robust GUID generation
888
+ key: card.config?.cardTemplateKey || 'content',
889
+ content: content,
890
+ isFull: true, // Always full replacement for Markdown content
891
+ isFinalize: finished, // Set to true on final update to close the streaming channel
892
+ isError: false,
893
+ };
894
+
895
+ log?.debug?.(
896
+ `[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFull=true isFinalize=${finished} guid=${streamBody.guid} payload=${JSON.stringify(streamBody)}`
897
+ );
898
+
899
+ try {
900
+ const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
901
+ headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' },
902
+ });
903
+ log?.debug?.(
904
+ `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`
905
+ );
906
+
907
+ // Update last updated time and state
908
+ card.lastUpdated = Date.now();
909
+ if (finished) {
910
+ card.state = AICardStatus.FINISHED;
911
+ } else if (card.state === AICardStatus.PROCESSING) {
912
+ card.state = AICardStatus.INPUTING;
913
+ }
914
+ } catch (err: any) {
915
+ // Handle 500 unknownError - likely cardTemplateKey mismatch with card template variables
916
+ if (err.response?.status === 500 && err.response?.data?.code === 'unknownError') {
917
+ const usedKey = streamBody.key;
918
+ const cardTemplateId = card.config?.cardTemplateId || '(unknown)';
919
+ const errorMsg =
920
+ `⚠️ **[DingTalk] AI Card 串流更新失败 (500 unknownError)**\n\n` +
921
+ `这通常是因为 \`cardTemplateKey\` (当前值: \`${usedKey}\`) 与钉钉卡片模板 \`${cardTemplateId}\` 中定义的正文变量名不匹配。\n\n` +
922
+ `**建议操作**:\n` +
923
+ `1. 前往钉钉开发者后台检查该模板的“变量管理”\n` +
924
+ `2. 确保配置中的 \`cardTemplateKey\` 与模板中用于显示内容的字段变量名完全一致\n\n` +
925
+ `*注意:当前及后续消息将自动转为 Markdown 发送,直到问题修复。*\n` +
926
+ `*参考文档: https://github.com/soimy/openclaw-channel-dingtalk/blob/main/README.md#3-%E5%BB%BA%E7%AB%8B%E5%8D%A1%E7%89%87%E6%A8%A1%E6%9D%BF%E5%8F%AF%E9%80%89`;
927
+
928
+ log?.error?.(
929
+ `[DingTalk][AICard] Streaming failed with 500 unknownError. Key: ${usedKey}, Template: ${cardTemplateId}. ` +
930
+ `Verify that "cardTemplateKey" matches the content field variable name in your card template.`
931
+ );
932
+
933
+ card.state = AICardStatus.FAILED;
934
+ card.lastUpdated = Date.now();
935
+
936
+ if (card.config) {
937
+ // Send notification directly to user via Markdown fallback
938
+ // We don't pass accountId here to ensure it doesn't try to use AI Card recursion
939
+ try {
940
+ await sendMessage(card.config, card.conversationId, errorMsg, { log });
941
+ } catch (sendErr: any) {
942
+ log?.warn?.(`[DingTalk][AICard] Failed to send error notification to user: ${sendErr.message}`);
943
+ }
944
+ }
945
+
946
+ throw err;
947
+ }
948
+
949
+ // Handle 401 errors specifically - try to refresh token once
950
+ if (err.response?.status === 401 && card.config) {
951
+ log?.warn?.('[DingTalk][AICard] Received 401 error, attempting token refresh and retry...');
952
+ try {
953
+ card.accessToken = await getAccessToken(card.config, log);
954
+ // Retry the streaming request with refreshed token
955
+ const retryResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
956
+ headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' },
957
+ });
958
+ log?.debug?.(`[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`);
959
+ // Update state on successful retry
960
+ card.lastUpdated = Date.now();
961
+ if (finished) {
962
+ card.state = AICardStatus.FINISHED;
963
+ } else if (card.state === AICardStatus.PROCESSING) {
964
+ card.state = AICardStatus.INPUTING;
965
+ }
966
+ return; // Success, exit function
967
+ } catch (retryErr: any) {
968
+ log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
969
+ // Fall through to mark as failed and throw
970
+ }
971
+ }
972
+
973
+ // Ensure card state reflects the failure to prevent retry loops
974
+ card.state = AICardStatus.FAILED;
975
+ card.lastUpdated = Date.now();
976
+ log?.error?.(
977
+ `[DingTalk][AICard] Streaming update failed: ${err.message}, resp=${JSON.stringify(err.response?.data)}`
978
+ );
979
+ throw err;
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Finalize AI Card: close streaming channel and update to FINISHED state
985
+ * @param card AI Card instance
986
+ * @param content Final content
987
+ * @param log Logger instance
988
+ */
989
+ async function finishAICard(card: AICardInstance, content: string, log?: Logger): Promise<void> {
990
+ log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
991
+
992
+ // Send final content with isFull=true and isFinalize=true to close streaming
993
+ // No separate state update needed - the streaming API handles everything
994
+ await streamAICard(card, content, true, log);
995
+ }
996
+
997
+ // ============ End of New AI Card API Functions ============
998
+
999
+ // Send message with automatic mode selection (card/markdown)
1000
+ // Card mode: if an active AI Card exists for the target, stream updates; otherwise fall back to markdown.
1001
+ async function sendMessage(
1002
+ config: DingTalkConfig,
1003
+ conversationId: string,
1004
+ text: string,
1005
+ options: SendMessageOptions & { sessionWebhook?: string; accountId?: string } = {}
1006
+ ): Promise<{ ok: boolean; error?: string; data?: AxiosResponse }> {
1007
+ try {
1008
+ const messageType = config.messageType || 'markdown';
1009
+ const log = options.log || getLogger();
1010
+
1011
+ if (messageType === 'card' && options.accountId) {
1012
+ const targetKey = `${options.accountId}:${conversationId}`;
1013
+ const activeCardId = activeCardsByTarget.get(targetKey);
1014
+ if (activeCardId) {
1015
+ const activeCard = aiCardInstances.get(activeCardId);
1016
+ if (activeCard && !isCardInTerminalState(activeCard.state)) {
1017
+ try {
1018
+ await streamAICard(activeCard, text, false, log);
1019
+ return { ok: true };
1020
+ } catch (err: any) {
1021
+ log?.warn?.(`[DingTalk] AI Card streaming failed, fallback to markdown: ${err.message}`);
1022
+ activeCard.state = AICardStatus.FAILED;
1023
+ activeCard.lastUpdated = Date.now();
1024
+ }
1025
+ } else {
1026
+ activeCardsByTarget.delete(targetKey);
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ // Fallback to markdown mode
1032
+ if (options.sessionWebhook) {
1033
+ await sendBySession(config, options.sessionWebhook, text, options);
1034
+ return { ok: true };
1035
+ }
1036
+
1037
+ const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
1038
+ return { ok: true, data: result };
1039
+ } catch (err: any) {
1040
+ options.log?.error?.(`[DingTalk] Send message failed: ${err.message}`);
1041
+ return { ok: false, error: err.message };
1042
+ }
1043
+ }
1044
+
1045
+ // Message handler
1046
+ async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
1047
+ const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig } = params;
1048
+ const rt = getDingTalkRuntime();
1049
+
1050
+ // Save logger reference globally for use by other methods
1051
+ currentLogger = log;
1052
+
1053
+ log?.debug?.('[DingTalk] Full Inbound Data:', JSON.stringify(maskSensitiveData(data)));
1054
+
1055
+ // 0. 清理过期的卡片缓存
1056
+ cleanupCardCache();
1057
+
1058
+ // 1. 过滤机器人自身消息
1059
+ if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
1060
+ log?.debug?.('[DingTalk] Ignoring robot self-message');
1061
+ return;
1062
+ }
1063
+
1064
+ const content = extractMessageContent(data);
1065
+ if (!content.text) return;
1066
+
1067
+ const isDirect = data.conversationType === '1';
1068
+ const senderId = data.senderStaffId || data.senderId;
1069
+ const senderName = data.senderNick || 'Unknown';
1070
+ const groupId = data.conversationId;
1071
+ const groupName = data.conversationTitle || 'Group';
1072
+
1073
+ // Register original peer IDs to preserve case-sensitive conversationId (base64)
1074
+ if (groupId) registerPeerId(groupId);
1075
+ if (senderId) registerPeerId(senderId);
1076
+
1077
+ // 2. Check authorization for direct messages based on dmPolicy
1078
+ let commandAuthorized = true;
1079
+ if (isDirect) {
1080
+ const dmPolicy = dingtalkConfig.dmPolicy || 'open';
1081
+ const allowFrom = dingtalkConfig.allowFrom || [];
1082
+
1083
+ if (dmPolicy === 'allowlist') {
1084
+ const normalizedAllowFrom = normalizeAllowFrom(allowFrom);
1085
+ const isAllowed = isSenderAllowed({ allow: normalizedAllowFrom, senderId });
1086
+
1087
+ if (!isAllowed) {
1088
+ log?.debug?.(`[DingTalk] DM blocked: senderId=${senderId} not in allowlist (dmPolicy=allowlist)`);
1089
+
1090
+ // Notify user with their sender ID so they can request access
1091
+ try {
1092
+ await sendBySession(
1093
+ dingtalkConfig,
1094
+ sessionWebhook,
1095
+ `⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到允许列表中。`,
1096
+ { log }
1097
+ );
1098
+ } catch (err: any) {
1099
+ log?.debug?.(`[DingTalk] Failed to send access denied message: ${err.message}`);
1100
+ }
1101
+
1102
+ return;
1103
+ }
1104
+
1105
+ log?.debug?.(`[DingTalk] DM authorized: senderId=${senderId} in allowlist`);
1106
+ } else if (dmPolicy === 'pairing') {
1107
+ // For pairing mode, SDK will handle the authorization
1108
+ // Set commandAuthorized to true to let SDK check pairing status
1109
+ commandAuthorized = true;
1110
+ } else {
1111
+ // 'open' policy - allow all
1112
+ commandAuthorized = true;
1113
+ }
1114
+ } else {
1115
+ // 群组通道授权
1116
+ const groupPolicy = dingtalkConfig.groupPolicy || 'open';
1117
+ const allowFrom = dingtalkConfig.allowFrom || [];
1118
+
1119
+ if (groupPolicy === 'allowlist') {
1120
+ const normalizedAllowFrom = normalizeAllowFrom(allowFrom);
1121
+ const isAllowed = isSenderGroupAllowed({ allow: normalizedAllowFrom, groupId });
1122
+
1123
+ if (!isAllowed) {
1124
+ log?.debug?.(
1125
+ `[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} not in allowlist (groupPolicy=allowlist)`
1126
+ );
1127
+
1128
+ try {
1129
+ await sendBySession(
1130
+ dingtalkConfig,
1131
+ sessionWebhook,
1132
+ `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`,
1133
+ { log, atUserId: senderId }
1134
+ );
1135
+ } catch (err: any) {
1136
+ log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
1137
+ }
1138
+
1139
+ return;
1140
+ }
1141
+
1142
+ log?.debug?.(`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId} in allowlist`);
1143
+ }
1144
+ }
1145
+
1146
+ const route = rt.channel.routing.resolveAgentRoute({
1147
+ cfg,
1148
+ channel: 'dingtalk',
1149
+ accountId,
1150
+ // agent权限和openclaw保持一致
1151
+ peer: { kind: isDirect ? 'direct' : 'group', id: isDirect ? senderId : groupId },
1152
+ });
1153
+
1154
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
1155
+ const workspacePath = resolveAgentWorkspaceDir(cfg, route.agentId);
1156
+
1157
+ // Download media to agent workspace (must be after route is resolved for correct workspace)
1158
+ let mediaPath: string | undefined;
1159
+ let mediaType: string | undefined;
1160
+ if (content.mediaPath && dingtalkConfig.robotCode) {
1161
+ const media = await downloadMedia(dingtalkConfig, content.mediaPath, workspacePath, log);
1162
+ if (media) {
1163
+ mediaPath = media.path;
1164
+ mediaType = media.mimeType;
1165
+ }
1166
+ }
1167
+ const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
1168
+ const previousTimestamp = rt.channel.session.readSessionUpdatedAt({ storePath, sessionKey: route.sessionKey });
1169
+
1170
+ // Group-specific: resolve config, track members, format member list
1171
+ const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
1172
+ // GroupSystemPrompt is injected into the system prompt on every turn (unlike
1173
+ // group intro which only fires on the first turn). Embed DingTalk IDs here so
1174
+ // the AI always has access to conversationId.
1175
+ const groupSystemPrompt = !isDirect
1176
+ ? [`DingTalk group context: conversationId=${groupId}`, groupConfig?.systemPrompt?.trim()]
1177
+ .filter(Boolean)
1178
+ .join('\n')
1179
+ : undefined;
1180
+
1181
+ if (!isDirect) {
1182
+ noteGroupMember(storePath, groupId, senderId, senderName);
1183
+ }
1184
+ const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
1185
+
1186
+ const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
1187
+ const body = rt.channel.reply.formatInboundEnvelope({
1188
+ channel: 'DingTalk',
1189
+ from: fromLabel,
1190
+ timestamp: data.createAt,
1191
+ body: content.text,
1192
+ chatType: isDirect ? 'direct' : 'group',
1193
+ sender: { name: senderName, id: senderId },
1194
+ previousTimestamp,
1195
+ envelope: envelopeOptions,
1196
+ });
1197
+
1198
+ const to = isDirect ? senderId : groupId;
1199
+ const ctx = rt.channel.reply.finalizeInboundContext({
1200
+ Body: body,
1201
+ RawBody: content.text,
1202
+ CommandBody: content.text,
1203
+ From: to,
1204
+ To: to,
1205
+ SessionKey: route.sessionKey,
1206
+ AccountId: accountId,
1207
+ ChatType: isDirect ? 'direct' : 'group',
1208
+ ConversationLabel: fromLabel,
1209
+ GroupSubject: isDirect ? undefined : groupName,
1210
+ SenderName: senderName,
1211
+ SenderId: senderId,
1212
+ Provider: 'dingtalk',
1213
+ Surface: 'dingtalk',
1214
+ MessageSid: data.msgId,
1215
+ Timestamp: data.createAt,
1216
+ MediaPath: mediaPath,
1217
+ MediaType: mediaType,
1218
+ MediaUrl: mediaPath,
1219
+ GroupMembers: groupMembers,
1220
+ GroupSystemPrompt: groupSystemPrompt,
1221
+ GroupChannel: isDirect ? undefined : route.sessionKey,
1222
+ CommandAuthorized: commandAuthorized,
1223
+ OriginatingChannel: 'dingtalk',
1224
+ OriginatingTo: to,
1225
+ });
1226
+
1227
+ await rt.channel.session.recordInboundSession({
1228
+ storePath,
1229
+ sessionKey: ctx.SessionKey || route.sessionKey,
1230
+ ctx,
1231
+ updateLastRoute: { sessionKey: route.mainSessionKey, channel: 'dingtalk', to, accountId },
1232
+ onRecordError: (err: unknown) => {
1233
+ log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
1234
+ },
1235
+ });
1236
+
1237
+ log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
1238
+
1239
+ // Determine if we are in card mode, if so, create or reuse card instance first
1240
+ const useCardMode = dingtalkConfig.messageType === 'card';
1241
+ let currentAICard: AICardInstance | undefined;
1242
+ let lastCardContent = '';
1243
+
1244
+ if (useCardMode) {
1245
+ // Try to reuse an existing active AI card for this target, if available
1246
+ const targetKey = `${accountId}:${to}`;
1247
+ const existingCardId = activeCardsByTarget.get(targetKey);
1248
+ const existingCard = existingCardId ? aiCardInstances.get(existingCardId) : undefined;
1249
+
1250
+ // Only reuse cards that are not in terminal states
1251
+ if (existingCard && !isCardInTerminalState(existingCard.state)) {
1252
+ currentAICard = existingCard;
1253
+ log?.debug?.('[DingTalk] Reusing existing active AI card for this conversation.');
1254
+ } else {
1255
+ // Create a new AI card
1256
+ try {
1257
+ const aiCard = await createAICard(dingtalkConfig, to, data, accountId, log);
1258
+ if (aiCard) {
1259
+ currentAICard = aiCard;
1260
+ } else {
1261
+ log?.warn?.('[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.');
1262
+ }
1263
+ } catch (err: any) {
1264
+ log?.warn?.(`[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`);
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ // Feedback: Thinking...
1270
+ if (dingtalkConfig.showThinking !== false) {
1271
+ try {
1272
+ const thinkingText = '🤔 思考中,请稍候...';
1273
+ // AI card already has thinking state visually, so we only send thinking message for non-card modes
1274
+ if (useCardMode && currentAICard) {
1275
+ log?.debug?.('[DingTalk] AI Card in thinking state, skipping thinking message send.');
1276
+ } else {
1277
+ lastCardContent = thinkingText;
1278
+ await sendMessage(dingtalkConfig, to, thinkingText, {
1279
+ sessionWebhook,
1280
+ atUserId: !isDirect ? senderId : null,
1281
+ log,
1282
+ accountId,
1283
+ });
1284
+ }
1285
+ } catch (err: any) {
1286
+ log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
1287
+ }
1288
+ }
1289
+
1290
+ const { queuedFinal } = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1291
+ ctx,
1292
+ cfg,
1293
+ dispatcherOptions: {
1294
+ responsePrefix: '',
1295
+ deliver: async (payload: any) => {
1296
+ try {
1297
+ const textToSend = payload.markdown || payload.text;
1298
+ if (!textToSend) return;
1299
+
1300
+ lastCardContent = textToSend;
1301
+ await sendMessage(dingtalkConfig, to, textToSend, {
1302
+ sessionWebhook,
1303
+ atUserId: !isDirect ? senderId : null,
1304
+ log,
1305
+ accountId,
1306
+ });
1307
+ } catch (err: any) {
1308
+ log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
1309
+ throw err;
1310
+ }
1311
+ },
1312
+ },
1313
+ });
1314
+
1315
+ // Finalize AI card
1316
+ if (useCardMode && currentAICard) {
1317
+ try {
1318
+ // Helper function to check if a value is a non-empty string
1319
+ const isNonEmptyString = (value: any): boolean => typeof value === 'string' && value.trim().length > 0;
1320
+
1321
+ // Validate that we have actual content before finalization
1322
+ const hasLastCardContent = isNonEmptyString(lastCardContent);
1323
+ const hasQueuedFinalString = isNonEmptyString(queuedFinal);
1324
+
1325
+ if (hasLastCardContent || hasQueuedFinalString) {
1326
+ const finalContent =
1327
+ hasLastCardContent && typeof lastCardContent === 'string'
1328
+ ? lastCardContent
1329
+ : typeof queuedFinal === 'string'
1330
+ ? queuedFinal
1331
+ : '';
1332
+ await finishAICard(currentAICard, finalContent, log);
1333
+ } else {
1334
+ // No textual content was produced; skip finalization with empty content
1335
+ log?.debug?.('[DingTalk] Skipping AI Card finalization because no textual content was produced.');
1336
+ // Still mark the card as finished to allow cleanup
1337
+ currentAICard.state = AICardStatus.FINISHED;
1338
+ currentAICard.lastUpdated = Date.now();
1339
+ }
1340
+ } catch (err: any) {
1341
+ log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
1342
+ // Ensure the AI card transitions to a terminal error state
1343
+ try {
1344
+ if (currentAICard.state !== AICardStatus.FINISHED) {
1345
+ currentAICard.state = AICardStatus.FAILED;
1346
+ currentAICard.lastUpdated = Date.now();
1347
+ }
1348
+ } catch (stateErr: any) {
1349
+ // Log state update failure at debug level to aid production debugging
1350
+ log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
1351
+ }
1352
+ }
1353
+ }
1354
+ // Note: Media cleanup is handled by openclaw's media storage mechanism
1355
+ // Files saved via rt.channel.media.saveMediaBuffer are managed automatically
1356
+ }
1357
+
1358
+ // DingTalk Channel Definition
1359
+ export const dingtalkPlugin: DingTalkChannelPlugin = {
1360
+ id: 'dingtalk',
1361
+ meta: {
1362
+ id: 'dingtalk',
1363
+ label: 'DingTalk',
1364
+ selectionLabel: 'DingTalk (钉钉)',
1365
+ docsPath: '/channels/dingtalk',
1366
+ blurb: '钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。',
1367
+ aliases: ['dd', 'ding'],
1368
+ },
1369
+ configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
1370
+ onboarding: dingtalkOnboardingAdapter,
1371
+ capabilities: {
1372
+ chatTypes: ['direct', 'group'] as Array<'direct' | 'group'>,
1373
+ reactions: false,
1374
+ threads: false,
1375
+ media: true,
1376
+ nativeCommands: false,
1377
+ blockStreaming: false,
1378
+ },
1379
+ reload: { configPrefixes: ['channels.dingtalk'] },
1380
+ config: {
1381
+ listAccountIds: (cfg: OpenClawConfig): string[] => {
1382
+ const config = getConfig(cfg);
1383
+ return config.accounts && Object.keys(config.accounts).length > 0
1384
+ ? Object.keys(config.accounts)
1385
+ : isConfigured(cfg)
1386
+ ? ['default']
1387
+ : [];
1388
+ },
1389
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
1390
+ const config = getConfig(cfg);
1391
+ const id = accountId || 'default';
1392
+ const account = config.accounts?.[id];
1393
+ const resolvedConfig = account || config;
1394
+ const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
1395
+ return {
1396
+ accountId: id,
1397
+ config: resolvedConfig,
1398
+ enabled: resolvedConfig.enabled !== false,
1399
+ configured,
1400
+ name: resolvedConfig.name || null,
1401
+ };
1402
+ },
1403
+ defaultAccountId: (): string => 'default',
1404
+ isConfigured: (account: ResolvedAccount): boolean => Boolean(account.config?.clientId && account.config?.clientSecret),
1405
+ describeAccount: (account: ResolvedAccount) => ({
1406
+ accountId: account.accountId,
1407
+ name: account.config?.name || 'DingTalk',
1408
+ enabled: account.enabled,
1409
+ configured: Boolean(account.config?.clientId),
1410
+ }),
1411
+ },
1412
+ security: {
1413
+ resolveDmPolicy: ({ account }: any) => ({
1414
+ policy: account.config?.dmPolicy || 'open',
1415
+ allowFrom: account.config?.allowFrom || [],
1416
+ policyPath: 'channels.dingtalk.dmPolicy',
1417
+ allowFromPath: 'channels.dingtalk.allowFrom',
1418
+ approveHint: '使用 /allow dingtalk:<userId> 批准用户',
1419
+ normalizeEntry: (raw: string) => raw.replace(/^(dingtalk|dd|ding):/i, ''),
1420
+ }),
1421
+ },
1422
+ groups: {
1423
+ resolveRequireMention: ({ cfg }: any): boolean => getConfig(cfg).groupPolicy !== 'open',
1424
+ resolveGroupIntroHint: ({ groupId, groupChannel }: any): string | undefined => {
1425
+ const parts = [`conversationId=${groupId}`];
1426
+ if (groupChannel) parts.push(`sessionKey=${groupChannel}`);
1427
+ return `DingTalk IDs: ${parts.join(', ')}.`;
1428
+ },
1429
+ },
1430
+ messaging: {
1431
+ normalizeTarget: (raw: string) => (raw ? raw.replace(/^(dingtalk|dd|ding):/i, '') : undefined),
1432
+ targetResolver: { looksLikeId: (id: string): boolean => /^[\w+\-/=]+$/.test(id), hint: '<conversationId>' },
1433
+ },
1434
+ outbound: {
1435
+ deliveryMode: 'direct' as const,
1436
+ resolveTarget: ({ to }: any) => {
1437
+ const trimmed = to?.trim();
1438
+ if (!trimmed) {
1439
+ return {
1440
+ ok: false as const,
1441
+ error: new Error('DingTalk message requires --to <conversationId>'),
1442
+ };
1443
+ }
1444
+ // Strip group: or user: prefix and resolve original case-sensitive conversationId
1445
+ const { targetId } = stripTargetPrefix(trimmed);
1446
+ const resolved = resolveOriginalPeerId(targetId);
1447
+ return { ok: true as const, to: resolved };
1448
+ },
1449
+ sendText: async ({ cfg, to, text, accountId, log }: any) => {
1450
+ const config = getConfig(cfg, accountId);
1451
+ try {
1452
+ const result = await sendMessage(config, to, text, { log, accountId });
1453
+ getLogger()?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
1454
+ if (result.ok) {
1455
+ const data = result.data as any;
1456
+ const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
1457
+ return {
1458
+ channel: 'dingtalk',
1459
+ messageId,
1460
+ meta: result.data ? { data: result.data as unknown as Record<string, unknown> } : undefined,
1461
+ };
1462
+ }
1463
+ throw new Error(typeof result.error === 'string' ? result.error : JSON.stringify(result.error));
1464
+ } catch (err: any) {
1465
+ throw new Error(typeof err?.response?.data === 'string' ? err.response.data : err?.message || 'sendText failed');
1466
+ }
1467
+ },
1468
+ sendMedia: async ({
1469
+ cfg,
1470
+ to,
1471
+ mediaPath,
1472
+ filePath,
1473
+ mediaUrl,
1474
+ mediaType: providedMediaType,
1475
+ accountId,
1476
+ log,
1477
+ }: any) => {
1478
+ const config = getConfig(cfg, accountId);
1479
+ if (!config.clientId) throw new Error('DingTalk not configured');
1480
+
1481
+ // Support mediaPath, filePath, and mediaUrl parameter names
1482
+ const actualMediaPath = mediaPath || filePath || mediaUrl;
1483
+
1484
+ getLogger()?.debug?.(
1485
+ `[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, actualMediaPath=${actualMediaPath}`
1486
+ );
1487
+
1488
+ if (!actualMediaPath) {
1489
+ throw new Error(
1490
+ `mediaPath, filePath, or mediaUrl is required. Received: ${JSON.stringify({
1491
+ to,
1492
+ mediaPath,
1493
+ filePath,
1494
+ mediaUrl,
1495
+ })}`
1496
+ );
1497
+ }
1498
+
1499
+ try {
1500
+ // Detect media type from file extension if not provided
1501
+ const mediaType = providedMediaType || detectMediaTypeFromExtension(actualMediaPath);
1502
+
1503
+ // Send as native media via proactive API
1504
+ const result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, { log, accountId });
1505
+ getLogger()?.debug?.(
1506
+ `[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`
1507
+ );
1508
+
1509
+ if (result.ok) {
1510
+ // Extract messageId from DingTalk response for CLI display
1511
+ const data = result.data;
1512
+ const messageId = String(result.messageId || data?.processQueryKey || data?.messageId || randomUUID());
1513
+ return {
1514
+ channel: 'dingtalk',
1515
+ messageId,
1516
+ meta: result.data ? { data: result.data as unknown as Record<string, unknown> } : undefined,
1517
+ };
1518
+ }
1519
+ throw new Error(typeof result.error === 'string' ? result.error : JSON.stringify(result.error));
1520
+ } catch (err: any) {
1521
+ throw new Error(typeof err?.response?.data === 'string' ? err.response.data : err?.message || 'sendMedia failed');
1522
+ }
1523
+ },
1524
+ },
1525
+ gateway: {
1526
+ startAccount: async (ctx: GatewayStartContext): Promise<GatewayStopResult> => {
1527
+ const { account, cfg, abortSignal } = ctx;
1528
+ const config = account.config;
1529
+ if (!config.clientId || !config.clientSecret) {
1530
+ throw new Error('DingTalk clientId and clientSecret are required');
1531
+ }
1532
+
1533
+ ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
1534
+
1535
+ // Cleanup orphaned temp files from previous sessions
1536
+ cleanupOrphanedTempFiles(ctx.log);
1537
+
1538
+ // Create DWClient with autoReconnect disabled (we'll manage reconnection ourselves)
1539
+ const client = new DWClient({
1540
+ clientId: config.clientId,
1541
+ clientSecret: config.clientSecret,
1542
+ debug: config.debug || false,
1543
+ keepAlive: true,
1544
+ });
1545
+
1546
+ // Disable DWClient's built-in autoReconnect to use our robust ConnectionManager
1547
+ // Access private config to override autoReconnect
1548
+ (client as any).config.autoReconnect = false;
1549
+
1550
+ // Register message callback listener
1551
+ client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
1552
+ const messageId = res.headers?.messageId;
1553
+ try {
1554
+ if (messageId) {
1555
+ client.socketCallBackResponse(messageId, { success: true });
1556
+ }
1557
+ const data = JSON.parse(res.data) as DingTalkInboundMessage;
1558
+
1559
+ // Message deduplication: use bot-scoped key (robotKey:msgId) to prevent cross-bot conflicts
1560
+ // robotKey priority: robotCode (DingTalk's bot identifier) > clientId (app key) > accountId (local ID)
1561
+ // This ensures consistent keys per bot instance (config values remain stable during runtime)
1562
+ const robotKey = config.robotCode || config.clientId || account.accountId;
1563
+ const msgId = data.msgId || messageId;
1564
+
1565
+ // Skip dedup if we don't have a message ID (extremely rare edge case)
1566
+ if (!msgId) {
1567
+ ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
1568
+ } else {
1569
+ const dedupKey = `${robotKey}:${msgId}`;
1570
+ if (isMessageProcessed(dedupKey)) {
1571
+ ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
1572
+ return;
1573
+ }
1574
+ markMessageProcessed(dedupKey);
1575
+ }
1576
+
1577
+ await handleDingTalkMessage({
1578
+ cfg,
1579
+ accountId: account.accountId,
1580
+ data,
1581
+ sessionWebhook: data.sessionWebhook,
1582
+ log: ctx.log,
1583
+ dingtalkConfig: config,
1584
+ });
1585
+ } catch (error: any) {
1586
+ ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
1587
+ }
1588
+ });
1589
+
1590
+ // Track stopped state to prevent duplicate stop operations
1591
+ // The 'stopped' flag guards against multiple termination paths (abort signal, explicit stop)
1592
+ // from executing concurrently and ensures each lifecycle transition updates snapshot only once
1593
+ let stopped = false;
1594
+
1595
+ // Create connection manager configuration
1596
+ const connectionConfig: ConnectionManagerConfig = {
1597
+ maxAttempts: config.maxConnectionAttempts ?? 10,
1598
+ initialDelay: config.initialReconnectDelay ?? 1000,
1599
+ maxDelay: config.maxReconnectDelay ?? 60000,
1600
+ jitter: config.reconnectJitter ?? 0.3,
1601
+ onStateChange: (state: ConnectionState, error?: string) => {
1602
+ if (stopped) return;
1603
+ ctx.log?.debug?.(`[${account.accountId}] Connection state changed to: ${state}${error ? ` (${error})` : ''}`);
1604
+ if (state === ConnectionState.CONNECTED) {
1605
+ ctx.setStatus({
1606
+ ...ctx.getStatus(),
1607
+ running: true,
1608
+ lastStartAt: getCurrentTimestamp(),
1609
+ lastError: null,
1610
+ });
1611
+ } else if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
1612
+ ctx.setStatus({
1613
+ ...ctx.getStatus(),
1614
+ running: false,
1615
+ lastError: error || `Connection ${state.toLowerCase()}`,
1616
+ });
1617
+ }
1618
+ },
1619
+ };
1620
+
1621
+ ctx.log?.debug?.(
1622
+ `[${account.accountId}] Connection config: maxAttempts=${connectionConfig.maxAttempts}, ` +
1623
+ `initialDelay=${connectionConfig.initialDelay}ms, maxDelay=${connectionConfig.maxDelay}ms, ` +
1624
+ `jitter=${connectionConfig.jitter}`
1625
+ );
1626
+
1627
+ // Create connection manager
1628
+ const connectionManager = new ConnectionManager(client, account.accountId, connectionConfig, ctx.log);
1629
+
1630
+ // Setup abort signal handler BEFORE connecting
1631
+ // This allows the abort signal to cancel in-flight connection attempts
1632
+ if (abortSignal) {
1633
+ // Check if already aborted before we even start
1634
+ if (abortSignal.aborted) {
1635
+ ctx.log?.warn?.(`[${account.accountId}] Abort signal already active, skipping connection`);
1636
+
1637
+ // Update snapshot: channel aborted before start
1638
+ ctx.setStatus({
1639
+ ...ctx.getStatus(),
1640
+ running: false,
1641
+ lastStopAt: getCurrentTimestamp(),
1642
+ lastError: 'Connection aborted before start',
1643
+ });
1644
+
1645
+ throw new Error('Connection aborted before start');
1646
+ }
1647
+
1648
+ abortSignal.addEventListener('abort', () => {
1649
+ if (stopped) return;
1650
+ stopped = true;
1651
+ ctx.log?.info?.(`[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`);
1652
+ connectionManager.stop();
1653
+
1654
+ // Update snapshot: channel stopped by abort signal
1655
+ ctx.setStatus({
1656
+ ...ctx.getStatus(),
1657
+ running: false,
1658
+ lastStopAt: getCurrentTimestamp(),
1659
+ });
1660
+ });
1661
+ }
1662
+
1663
+ // Connect with robust retry logic
1664
+ try {
1665
+ await connectionManager.connect();
1666
+
1667
+ // Only mark as running if we weren't stopped and the connection is actually established
1668
+ if (!stopped && connectionManager.isConnected()) {
1669
+ // Update snapshot: connection successful, channel is now running
1670
+ ctx.setStatus({
1671
+ ...ctx.getStatus(),
1672
+ running: true,
1673
+ lastStartAt: getCurrentTimestamp(),
1674
+ lastError: null,
1675
+ });
1676
+ ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
1677
+ } else {
1678
+ // Startup was cancelled or connection is not established; do not overwrite stopped snapshot.
1679
+ ctx.log?.info?.(
1680
+ `[${account.accountId}] DingTalk Stream client connect() completed but channel is ` +
1681
+ `not running (stopped=${stopped}, connected=${connectionManager.isConnected()})`
1682
+ );
1683
+ }
1684
+ } catch (err: any) {
1685
+ ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
1686
+
1687
+ // Update snapshot: connection failed
1688
+ ctx.setStatus({
1689
+ ...ctx.getStatus(),
1690
+ running: false,
1691
+ lastError: err.message || 'Connection failed',
1692
+ });
1693
+ throw err;
1694
+ }
1695
+
1696
+ // Return stop handler
1697
+ return {
1698
+ stop: () => {
1699
+ if (stopped) return;
1700
+ stopped = true;
1701
+ ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
1702
+ connectionManager.stop();
1703
+
1704
+ // Update snapshot: channel stopped
1705
+ ctx.setStatus({
1706
+ ...ctx.getStatus(),
1707
+ running: false,
1708
+ lastStopAt: getCurrentTimestamp(),
1709
+ });
1710
+
1711
+ ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
1712
+ },
1713
+ };
1714
+ },
1715
+ },
1716
+ status: {
1717
+ defaultRuntime: { accountId: 'default', running: false, lastStartAt: null, lastStopAt: null, lastError: null },
1718
+ collectStatusIssues: (accounts: any[]) => {
1719
+ return accounts.flatMap((account) => {
1720
+ if (!account.configured) {
1721
+ return [
1722
+ {
1723
+ channel: 'dingtalk',
1724
+ accountId: account.accountId,
1725
+ kind: 'config' as const,
1726
+ message: 'Account not configured (missing clientId or clientSecret)',
1727
+ },
1728
+ ];
1729
+ }
1730
+ return [];
1731
+ });
1732
+ },
1733
+ buildChannelSummary: ({ snapshot }: any) => ({
1734
+ configured: snapshot?.configured ?? false,
1735
+ running: snapshot?.running ?? false,
1736
+ lastStartAt: snapshot?.lastStartAt ?? null,
1737
+ lastStopAt: snapshot?.lastStopAt ?? null,
1738
+ lastError: snapshot?.lastError ?? null,
1739
+ }),
1740
+ probeAccount: async ({ account, timeoutMs }: any) => {
1741
+ if (!account.configured || !account.config?.clientId || !account.config?.clientSecret) {
1742
+ return { ok: false, error: 'Not configured' };
1743
+ }
1744
+ try {
1745
+ const controller = new AbortController();
1746
+ const timeoutId = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
1747
+ try {
1748
+ await getAccessToken(account.config);
1749
+ return { ok: true, details: { clientId: account.config.clientId } };
1750
+ } finally {
1751
+ if (timeoutId) clearTimeout(timeoutId);
1752
+ }
1753
+ } catch (error: any) {
1754
+ return { ok: false, error: error.message };
1755
+ }
1756
+ },
1757
+ buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) => ({
1758
+ accountId: account.accountId,
1759
+ name: account.name,
1760
+ enabled: account.enabled,
1761
+ configured: account.configured,
1762
+ clientId: account.config?.clientId ?? null,
1763
+ running: runtime?.running ?? snapshot?.running ?? false,
1764
+ lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
1765
+ lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
1766
+ lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
1767
+ probe,
1768
+ }),
1769
+ },
1770
+ };
1771
+
1772
+ /**
1773
+ * Public low-level API exports for the DingTalk channel plugin.
1774
+ *
1775
+ * - {@link sendBySession} sends a message to DingTalk using a session/webhook
1776
+ * (e.g. replies within an existing conversation).
1777
+ * - {@link createAICard} creates and delivers an AI Card using the DingTalk API
1778
+ * (returns AICardInstance for streaming updates). Automatically registers the card
1779
+ * in activeCardsByTarget mapping (accountId:conversationId -> cardInstanceId).
1780
+ * - {@link streamAICard} streams content updates to an AI Card
1781
+ * (for real-time streaming message updates).
1782
+ * - {@link finishAICard} finalizes an AI Card and sets state to FINISHED
1783
+ * (closes streaming channel and updates card state).
1784
+ * - {@link sendMessage} sends a message with automatic mode selection
1785
+ * (text/markdown/card based on config).
1786
+ * - {@link uploadMedia} uploads a local media file to DingTalk media server
1787
+ * and returns the media_id for use in messages.
1788
+ * - {@link getAccessToken} retrieves (and caches) the DingTalk access token
1789
+ * for the configured application/runtime.
1790
+ * - {@link getLogger} retrieves the current global logger instance
1791
+ * (set by handleDingTalkMessage during inbound message processing).
1792
+ *
1793
+ * These exports are intended to be used by external integrations that need
1794
+ * direct programmatic access to DingTalk messaging and authentication.
1795
+ */
1796
+ export {
1797
+ sendBySession,
1798
+ createAICard,
1799
+ streamAICard,
1800
+ finishAICard,
1801
+ sendMessage,
1802
+ uploadMedia,
1803
+ sendProactiveMedia,
1804
+ getAccessToken,
1805
+ getLogger,
1806
+ };
1807
+ export { detectMediaTypeFromExtension } from './media-utils';