@soimy/dingtalk 2.7.0 → 3.0.0

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