@soimy/dingtalk 2.6.5 → 3.0.0-beta.1

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,1394 +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
- // ============ 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
- }
28
+ ResolvedAccount,
29
+ } from "./types";
30
+ import { ConnectionState } from "./types";
31
+ import { cleanupOrphanedTempFiles, getCurrentTimestamp } from "./utils";
1357
32
 
1358
- // DingTalk Channel Definition
33
+ // DingTalk Channel Definition (assembly layer).
34
+ // Heavy logic is delegated to service modules for maintainability.
1359
35
  export const dingtalkPlugin: DingTalkChannelPlugin = {
1360
- id: 'dingtalk',
36
+ id: "dingtalk",
1361
37
  meta: {
1362
- id: 'dingtalk',
1363
- label: 'DingTalk',
1364
- selectionLabel: 'DingTalk (钉钉)',
1365
- docsPath: '/channels/dingtalk',
1366
- blurb: '钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。',
1367
- aliases: ['dd', 'ding'],
38
+ id: "dingtalk",
39
+ label: "DingTalk",
40
+ selectionLabel: "DingTalk (钉钉)",
41
+ docsPath: "/channels/dingtalk",
42
+ blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
43
+ aliases: ["dd", "ding"],
1368
44
  },
1369
45
  configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
1370
46
  onboarding: dingtalkOnboardingAdapter,
1371
47
  capabilities: {
1372
- chatTypes: ['direct', 'group'] as Array<'direct' | 'group'>,
48
+ chatTypes: ["direct", "group"] as Array<"direct" | "group">,
1373
49
  reactions: false,
1374
50
  threads: false,
1375
51
  media: true,
1376
52
  nativeCommands: false,
1377
53
  blockStreaming: false,
1378
54
  },
1379
- reload: { configPrefixes: ['channels.dingtalk'] },
55
+ reload: { configPrefixes: ["channels.dingtalk"] },
1380
56
  config: {
1381
57
  listAccountIds: (cfg: OpenClawConfig): string[] => {
1382
58
  const config = getConfig(cfg);
1383
59
  return config.accounts && Object.keys(config.accounts).length > 0
1384
60
  ? Object.keys(config.accounts)
1385
61
  : isConfigured(cfg)
1386
- ? ['default']
62
+ ? ["default"]
1387
63
  : [];
1388
64
  },
1389
65
  resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
1390
66
  const config = getConfig(cfg);
1391
- const id = accountId || 'default';
67
+ const id = accountId || "default";
1392
68
  const account = config.accounts?.[id];
1393
69
  const resolvedConfig = account || config;
1394
70
  const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
@@ -1400,48 +76,53 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1400
76
  name: resolvedConfig.name || null,
1401
77
  };
1402
78
  },
1403
- defaultAccountId: (): string => 'default',
1404
- isConfigured: (account: ResolvedAccount): boolean => Boolean(account.config?.clientId && account.config?.clientSecret),
79
+ defaultAccountId: (): string => "default",
80
+ isConfigured: (account: ResolvedAccount): boolean =>
81
+ Boolean(account.config?.clientId && account.config?.clientSecret),
1405
82
  describeAccount: (account: ResolvedAccount) => ({
1406
83
  accountId: account.accountId,
1407
- name: account.config?.name || 'DingTalk',
84
+ name: account.config?.name || "DingTalk",
1408
85
  enabled: account.enabled,
1409
86
  configured: Boolean(account.config?.clientId),
1410
87
  }),
1411
88
  },
1412
89
  security: {
1413
90
  resolveDmPolicy: ({ account }: any) => ({
1414
- policy: account.config?.dmPolicy || 'open',
91
+ policy: account.config?.dmPolicy || "open",
1415
92
  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, ''),
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, ""),
1420
97
  }),
1421
98
  },
1422
99
  groups: {
1423
- resolveRequireMention: ({ cfg }: any): boolean => getConfig(cfg).groupPolicy !== 'open',
100
+ resolveRequireMention: ({ cfg }: any): boolean => getConfig(cfg).groupPolicy !== "open",
1424
101
  resolveGroupIntroHint: ({ groupId, groupChannel }: any): string | undefined => {
1425
102
  const parts = [`conversationId=${groupId}`];
1426
- if (groupChannel) parts.push(`sessionKey=${groupChannel}`);
1427
- return `DingTalk IDs: ${parts.join(', ')}.`;
103
+ if (groupChannel) {
104
+ parts.push(`sessionKey=${groupChannel}`);
105
+ }
106
+ return `DingTalk IDs: ${parts.join(", ")}.`;
1428
107
  },
1429
108
  },
1430
109
  messaging: {
1431
- normalizeTarget: (raw: string) => (raw ? raw.replace(/^(dingtalk|dd|ding):/i, '') : undefined),
1432
- 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
+ },
1433
115
  },
1434
116
  outbound: {
1435
- deliveryMode: 'direct' as const,
117
+ deliveryMode: "direct" as const,
1436
118
  resolveTarget: ({ to }: any) => {
1437
119
  const trimmed = to?.trim();
1438
120
  if (!trimmed) {
1439
121
  return {
1440
122
  ok: false as const,
1441
- error: new Error('DingTalk message requires --to <conversationId>'),
123
+ error: new Error("DingTalk message requires --to <conversationId>"),
1442
124
  };
1443
125
  }
1444
- // Strip group: or user: prefix and resolve original case-sensitive conversationId
1445
126
  const { targetId } = stripTargetPrefix(trimmed);
1446
127
  const resolved = resolveOriginalPeerId(targetId);
1447
128
  return { ok: true as const, to: resolved };
@@ -1455,14 +136,23 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1455
136
  const data = result.data as any;
1456
137
  const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
1457
138
  return {
1458
- channel: 'dingtalk',
139
+ channel: "dingtalk",
1459
140
  messageId,
1460
- 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,
1461
144
  };
1462
145
  }
1463
- 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
+ );
1464
149
  } catch (err: any) {
1465
- throw new Error(typeof err?.response?.data === 'string' ? err.response.data : err?.message || 'sendText failed');
150
+ throw new Error(
151
+ typeof err?.response?.data === "string"
152
+ ? err.response.data
153
+ : err?.message || "sendText failed",
154
+ { cause: err },
155
+ );
1466
156
  }
1467
157
  },
1468
158
  sendMedia: async ({
@@ -1476,49 +166,67 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1476
166
  log,
1477
167
  }: any) => {
1478
168
  const config = getConfig(cfg, accountId);
1479
- if (!config.clientId) throw new Error('DingTalk not configured');
169
+ if (!config.clientId) {
170
+ throw new Error("DingTalk not configured");
171
+ }
1480
172
 
1481
- // Support mediaPath, filePath, and mediaUrl parameter names
1482
- const actualMediaPath = mediaPath || filePath || mediaUrl;
173
+ // Support mediaPath/filePath/mediaUrl aliases for better CLI compatibility.
174
+ const rawMediaPath = mediaPath || filePath || mediaUrl;
1483
175
 
1484
176
  getLogger()?.debug?.(
1485
- `[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, actualMediaPath=${actualMediaPath}`
177
+ `[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, rawMediaPath=${rawMediaPath}`,
1486
178
  );
1487
179
 
1488
- if (!actualMediaPath) {
180
+ if (!rawMediaPath) {
1489
181
  throw new Error(
1490
182
  `mediaPath, filePath, or mediaUrl is required. Received: ${JSON.stringify({
1491
183
  to,
1492
184
  mediaPath,
1493
185
  filePath,
1494
186
  mediaUrl,
1495
- })}`
187
+ })}`,
1496
188
  );
1497
189
  }
1498
190
 
191
+ const actualMediaPath = resolveRelativePath(rawMediaPath);
192
+
193
+ getLogger()?.debug?.(
194
+ `[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
195
+ );
196
+
1499
197
  try {
1500
- // Detect media type from file extension if not provided
1501
198
  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 });
199
+ const result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
200
+ log,
201
+ accountId,
202
+ });
1505
203
  getLogger()?.debug?.(
1506
- `[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`
204
+ `[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`,
1507
205
  );
1508
206
 
1509
207
  if (result.ok) {
1510
- // Extract messageId from DingTalk response for CLI display
1511
208
  const data = result.data;
1512
- const messageId = String(result.messageId || data?.processQueryKey || data?.messageId || randomUUID());
209
+ const messageId = String(
210
+ result.messageId || data?.processQueryKey || data?.messageId || randomUUID(),
211
+ );
1513
212
  return {
1514
- channel: 'dingtalk',
213
+ channel: "dingtalk",
1515
214
  messageId,
1516
- 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,
1517
218
  };
1518
219
  }
1519
- 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
+ );
1520
223
  } catch (err: any) {
1521
- throw new Error(typeof err?.response?.data === 'string' ? err.response.data : err?.message || 'sendMedia failed');
224
+ throw new Error(
225
+ typeof err?.response?.data === "string"
226
+ ? err.response.data
227
+ : err?.message || "sendMedia failed",
228
+ { cause: err },
229
+ );
1522
230
  }
1523
231
  },
1524
232
  },
@@ -1527,15 +235,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1527
235
  const { account, cfg, abortSignal } = ctx;
1528
236
  const config = account.config;
1529
237
  if (!config.clientId || !config.clientSecret) {
1530
- throw new Error('DingTalk clientId and clientSecret are required');
238
+ throw new Error("DingTalk clientId and clientSecret are required");
1531
239
  }
1532
240
 
1533
241
  ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
1534
242
 
1535
- // Cleanup orphaned temp files from previous sessions
1536
243
  cleanupOrphanedTempFiles(ctx.log);
1537
244
 
1538
- // Create DWClient with autoReconnect disabled (we'll manage reconnection ourselves)
1539
245
  const client = new DWClient({
1540
246
  clientId: config.clientId,
1541
247
  clientSecret: config.clientSecret,
@@ -1543,11 +249,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1543
249
  keepAlive: true,
1544
250
  });
1545
251
 
1546
- // Disable DWClient's built-in autoReconnect to use our robust ConnectionManager
1547
- // Access private config to override autoReconnect
252
+ // Disable built-in reconnect so ConnectionManager owns all retry/backoff behavior.
1548
253
  (client as any).config.autoReconnect = false;
1549
254
 
1550
- // Register message callback listener
1551
255
  client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
1552
256
  const messageId = res.headers?.messageId;
1553
257
  try {
@@ -1556,13 +260,10 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1556
260
  }
1557
261
  const data = JSON.parse(res.data) as DingTalkInboundMessage;
1558
262
 
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)
263
+ // Message deduplication key is bot-scoped to avoid cross-account conflicts.
1562
264
  const robotKey = config.robotCode || config.clientId || account.accountId;
1563
265
  const msgId = data.msgId || messageId;
1564
266
 
1565
- // Skip dedup if we don't have a message ID (extremely rare edge case)
1566
267
  if (!msgId) {
1567
268
  ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
1568
269
  } else {
@@ -1587,20 +288,21 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1587
288
  }
1588
289
  });
1589
290
 
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
291
+ // Guard against duplicate stop paths (abort signal + explicit stop).
1593
292
  let stopped = false;
1594
293
 
1595
- // Create connection manager configuration
1596
294
  const connectionConfig: ConnectionManagerConfig = {
1597
295
  maxAttempts: config.maxConnectionAttempts ?? 10,
1598
296
  initialDelay: config.initialReconnectDelay ?? 1000,
1599
297
  maxDelay: config.maxReconnectDelay ?? 60000,
1600
298
  jitter: config.reconnectJitter ?? 0.3,
1601
299
  onStateChange: (state: ConnectionState, error?: string) => {
1602
- if (stopped) return;
1603
- 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
+ );
1604
306
  if (state === ConnectionState.CONNECTED) {
1605
307
  ctx.setStatus({
1606
308
  ...ctx.getStatus(),
@@ -1621,37 +323,43 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1621
323
  ctx.log?.debug?.(
1622
324
  `[${account.accountId}] Connection config: maxAttempts=${connectionConfig.maxAttempts}, ` +
1623
325
  `initialDelay=${connectionConfig.initialDelay}ms, maxDelay=${connectionConfig.maxDelay}ms, ` +
1624
- `jitter=${connectionConfig.jitter}`
326
+ `jitter=${connectionConfig.jitter}`,
1625
327
  );
1626
328
 
1627
- // Create connection manager
1628
- 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
+ );
1629
335
 
1630
- // Setup abort signal handler BEFORE connecting
1631
- // This allows the abort signal to cancel in-flight connection attempts
336
+ // Register abort listener before connect() so startup can be cancelled safely.
1632
337
  if (abortSignal) {
1633
- // Check if already aborted before we even start
1634
338
  if (abortSignal.aborted) {
1635
- 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
+ );
1636
342
 
1637
- // Update snapshot: channel aborted before start
1638
343
  ctx.setStatus({
1639
344
  ...ctx.getStatus(),
1640
345
  running: false,
1641
346
  lastStopAt: getCurrentTimestamp(),
1642
- lastError: 'Connection aborted before start',
347
+ lastError: "Connection aborted before start",
1643
348
  });
1644
349
 
1645
- throw new Error('Connection aborted before start');
350
+ throw new Error("Connection aborted before start");
1646
351
  }
1647
352
 
1648
- abortSignal.addEventListener('abort', () => {
1649
- if (stopped) return;
353
+ abortSignal.addEventListener("abort", () => {
354
+ if (stopped) {
355
+ return;
356
+ }
1650
357
  stopped = true;
1651
- 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
+ );
1652
361
  connectionManager.stop();
1653
362
 
1654
- // Update snapshot: channel stopped by abort signal
1655
363
  ctx.setStatus({
1656
364
  ...ctx.getStatus(),
1657
365
  running: false,
@@ -1660,13 +368,10 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1660
368
  });
1661
369
  }
1662
370
 
1663
- // Connect with robust retry logic
1664
371
  try {
1665
372
  await connectionManager.connect();
1666
373
 
1667
- // Only mark as running if we weren't stopped and the connection is actually established
1668
374
  if (!stopped && connectionManager.isConnected()) {
1669
- // Update snapshot: connection successful, channel is now running
1670
375
  ctx.setStatus({
1671
376
  ...ctx.getStatus(),
1672
377
  running: true,
@@ -1674,34 +379,34 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1674
379
  lastError: null,
1675
380
  });
1676
381
  ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
382
+
383
+ await connectionManager.waitForStop();
1677
384
  } else {
1678
- // Startup was cancelled or connection is not established; do not overwrite stopped snapshot.
1679
385
  ctx.log?.info?.(
1680
386
  `[${account.accountId}] DingTalk Stream client connect() completed but channel is ` +
1681
- `not running (stopped=${stopped}, connected=${connectionManager.isConnected()})`
387
+ `not running (stopped=${stopped}, connected=${connectionManager.isConnected()})`,
1682
388
  );
1683
389
  }
1684
390
  } catch (err: any) {
1685
391
  ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
1686
392
 
1687
- // Update snapshot: connection failed
1688
393
  ctx.setStatus({
1689
394
  ...ctx.getStatus(),
1690
395
  running: false,
1691
- lastError: err.message || 'Connection failed',
396
+ lastError: err.message || "Connection failed",
1692
397
  });
1693
398
  throw err;
1694
399
  }
1695
400
 
1696
- // Return stop handler
1697
401
  return {
1698
402
  stop: () => {
1699
- if (stopped) return;
403
+ if (stopped) {
404
+ return;
405
+ }
1700
406
  stopped = true;
1701
407
  ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
1702
408
  connectionManager.stop();
1703
409
 
1704
- // Update snapshot: channel stopped
1705
410
  ctx.setStatus({
1706
411
  ...ctx.getStatus(),
1707
412
  running: false,
@@ -1714,16 +419,22 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1714
419
  },
1715
420
  },
1716
421
  status: {
1717
- 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
+ },
1718
429
  collectStatusIssues: (accounts: any[]) => {
1719
430
  return accounts.flatMap((account) => {
1720
431
  if (!account.configured) {
1721
432
  return [
1722
433
  {
1723
- channel: 'dingtalk',
434
+ channel: "dingtalk",
1724
435
  accountId: account.accountId,
1725
- kind: 'config' as const,
1726
- message: 'Account not configured (missing clientId or clientSecret)',
436
+ kind: "config" as const,
437
+ message: "Account not configured (missing clientId or clientSecret)",
1727
438
  },
1728
439
  ];
1729
440
  }
@@ -1739,7 +450,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1739
450
  }),
1740
451
  probeAccount: async ({ account, timeoutMs }: any) => {
1741
452
  if (!account.configured || !account.config?.clientId || !account.config?.clientSecret) {
1742
- return { ok: false, error: 'Not configured' };
453
+ return { ok: false, error: "Not configured" };
1743
454
  }
1744
455
  try {
1745
456
  const controller = new AbortController();
@@ -1748,7 +459,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1748
459
  await getAccessToken(account.config);
1749
460
  return { ok: true, details: { clientId: account.config.clientId } };
1750
461
  } finally {
1751
- if (timeoutId) clearTimeout(timeoutId);
462
+ if (timeoutId) {
463
+ clearTimeout(timeoutId);
464
+ }
1752
465
  }
1753
466
  } catch (error: any) {
1754
467
  return { ok: false, error: error.message };
@@ -1769,30 +482,6 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1769
482
  },
1770
483
  };
1771
484
 
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
485
  export {
1797
486
  sendBySession,
1798
487
  createAICard,
@@ -1804,4 +493,4 @@ export {
1804
493
  getAccessToken,
1805
494
  getLogger,
1806
495
  };
1807
- export { detectMediaTypeFromExtension } from './media-utils';
496
+ export { detectMediaTypeFromExtension } from "./media-utils";