@soimy/dingtalk 2.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts ADDED
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Type definitions for DingTalk Channel Plugin
3
+ *
4
+ * Provides comprehensive type safety for:
5
+ * - Configuration objects
6
+ * - DingTalk API request/response models
7
+ * - Message content and formats
8
+ * - Media files and streams
9
+ * - Session and token management
10
+ */
11
+
12
+ import type {
13
+ OpenClawConfig,
14
+ ChannelLogSink as SDKChannelLogSink,
15
+ ChannelAccountSnapshot as SDKChannelAccountSnapshot,
16
+ ChannelGatewayContext as SDKChannelGatewayContext,
17
+ ChannelPlugin as SDKChannelPlugin,
18
+ } from "openclaw/plugin-sdk";
19
+
20
+ /**
21
+ * DingTalk channel configuration (extends base OpenClaw config)
22
+ */
23
+ export interface DingTalkConfig extends OpenClawConfig {
24
+ clientId: string;
25
+ clientSecret: string;
26
+ robotCode?: string;
27
+ corpId?: string;
28
+ agentId?: string;
29
+ name?: string;
30
+ enabled?: boolean;
31
+ dmPolicy?: "open" | "pairing" | "allowlist";
32
+ groupPolicy?: "open" | "allowlist";
33
+ allowFrom?: string[];
34
+ showThinking?: boolean;
35
+ debug?: boolean;
36
+ messageType?: "markdown" | "card";
37
+ cardTemplateId?: string;
38
+ cardTemplateKey?: string;
39
+ groups?: Record<string, { systemPrompt?: string }>;
40
+ accounts?: Record<string, DingTalkConfig>;
41
+ // Connection robustness configuration
42
+ maxConnectionAttempts?: number;
43
+ initialReconnectDelay?: number;
44
+ maxReconnectDelay?: number;
45
+ reconnectJitter?: number;
46
+ }
47
+
48
+ /**
49
+ * Multi-account DingTalk configuration wrapper
50
+ */
51
+ export interface DingTalkChannelConfig {
52
+ enabled?: boolean;
53
+ clientId: string;
54
+ clientSecret: string;
55
+ robotCode?: string;
56
+ corpId?: string;
57
+ agentId?: string;
58
+ name?: string;
59
+ dmPolicy?: "open" | "pairing" | "allowlist";
60
+ groupPolicy?: "open" | "allowlist";
61
+ allowFrom?: string[];
62
+ showThinking?: boolean;
63
+ debug?: boolean;
64
+ messageType?: "markdown" | "card";
65
+ cardTemplateId?: string;
66
+ cardTemplateKey?: string;
67
+ groups?: Record<string, { systemPrompt?: string }>;
68
+ accounts?: Record<string, DingTalkConfig>;
69
+ maxConnectionAttempts?: number;
70
+ initialReconnectDelay?: number;
71
+ maxReconnectDelay?: number;
72
+ reconnectJitter?: number;
73
+ }
74
+
75
+ /**
76
+ * DingTalk token info for caching
77
+ */
78
+ export interface TokenInfo {
79
+ accessToken: string;
80
+ expireIn: number;
81
+ }
82
+
83
+ /**
84
+ * DingTalk API token response
85
+ */
86
+ export interface TokenResponse {
87
+ accessToken: string;
88
+ expireIn: number;
89
+ }
90
+
91
+ /**
92
+ * DingTalk API generic response wrapper
93
+ */
94
+ export interface DingTalkApiResponse<T = unknown> {
95
+ data?: T;
96
+ code?: string;
97
+ message?: string;
98
+ success?: boolean;
99
+ }
100
+
101
+ /**
102
+ * Media download response from DingTalk API
103
+ */
104
+ export interface MediaDownloadResponse {
105
+ downloadUrl?: string;
106
+ downloadCode?: string;
107
+ }
108
+
109
+ /**
110
+ * Media file metadata
111
+ */
112
+ export interface MediaFile {
113
+ path: string;
114
+ mimeType: string;
115
+ }
116
+
117
+ /**
118
+ * DingTalk incoming message (Stream mode)
119
+ */
120
+ export interface DingTalkInboundMessage {
121
+ msgId: string;
122
+ msgtype: string;
123
+ createAt: number;
124
+ text?: {
125
+ content: string;
126
+ };
127
+ content?: {
128
+ downloadCode?: string;
129
+ fileName?: string;
130
+ recognition?: string;
131
+ richText?: Array<{
132
+ type: string;
133
+ text?: string;
134
+ atName?: string;
135
+ downloadCode?: string; // For picture type in richText
136
+ }>;
137
+ };
138
+ conversationType: string;
139
+ conversationId: string;
140
+ conversationTitle?: string;
141
+ senderId: string;
142
+ senderStaffId?: string;
143
+ senderNick?: string;
144
+ chatbotUserId: string;
145
+ sessionWebhook: string;
146
+ }
147
+
148
+ /**
149
+ * Extracted message content for unified processing
150
+ */
151
+ export interface MessageContent {
152
+ text: string;
153
+ mediaPath?: string;
154
+ mediaType?: string;
155
+ messageType: string;
156
+ }
157
+
158
+ /**
159
+ * Send message options
160
+ */
161
+ export interface SendMessageOptions {
162
+ title?: string;
163
+ useMarkdown?: boolean;
164
+ atUserId?: string | null;
165
+ log?: any;
166
+ mediaPath?: string;
167
+ filePath?: string;
168
+ mediaUrl?: string;
169
+ mediaType?: "image" | "voice" | "video" | "file";
170
+ }
171
+
172
+ /**
173
+ * Session webhook response
174
+ */
175
+ export interface SessionWebhookResponse {
176
+ msgtype: string;
177
+ markdown?: {
178
+ title: string;
179
+ text: string;
180
+ };
181
+ text?: {
182
+ content: string;
183
+ };
184
+ at?: {
185
+ atUserIds: string[];
186
+ isAtAll: boolean;
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Message handler parameters
192
+ */
193
+ export interface HandleDingTalkMessageParams {
194
+ cfg: OpenClawConfig;
195
+ accountId: string;
196
+ data: DingTalkInboundMessage;
197
+ sessionWebhook: string;
198
+ log?: any;
199
+ dingtalkConfig: DingTalkConfig;
200
+ }
201
+
202
+ /**
203
+ * Proactive message payload
204
+ */
205
+ export interface ProactiveMessagePayload {
206
+ robotCode: string;
207
+ msgKey: string;
208
+ msgParam: string;
209
+ openConversationId?: string;
210
+ userIds?: string[];
211
+ }
212
+
213
+ /**
214
+ * Account descriptor
215
+ */
216
+ export interface AccountDescriptor {
217
+ accountId: string;
218
+ config?: DingTalkConfig;
219
+ enabled?: boolean;
220
+ name?: string;
221
+ configured?: boolean;
222
+ }
223
+
224
+ /**
225
+ * Account resolver result
226
+ */
227
+ export interface ResolvedAccount {
228
+ accountId: string;
229
+ config: DingTalkConfig;
230
+ enabled: boolean;
231
+ }
232
+
233
+ /**
234
+ * HTTP request config for axios
235
+ */
236
+ export interface AxiosRequestConfig {
237
+ url?: string;
238
+ method?: string;
239
+ data?: any;
240
+ headers?: Record<string, string>;
241
+ responseType?: "arraybuffer" | "json" | "text";
242
+ }
243
+
244
+ /**
245
+ * HTTP response from axios
246
+ */
247
+ export interface AxiosResponse<T = any> {
248
+ data: T;
249
+ status: number;
250
+ statusText: string;
251
+ headers: Record<string, string>;
252
+ }
253
+
254
+ /**
255
+ * DingTalk Stream callback listener types
256
+ */
257
+ export interface StreamCallbackResponse {
258
+ headers?: {
259
+ messageId?: string;
260
+ };
261
+ data: string;
262
+ }
263
+
264
+ /**
265
+ * Reply dispatcher context
266
+ */
267
+ export interface ReplyDispatchContext {
268
+ responsePrefix?: string;
269
+ deliver: (payload: any) => Promise<{ ok: boolean; error?: string }>;
270
+ }
271
+
272
+ /**
273
+ * Reply dispatcher result
274
+ */
275
+ export interface ReplyDispatcherResult {
276
+ dispatcher: any;
277
+ replyOptions: any;
278
+ markDispatchIdle: () => void;
279
+ }
280
+
281
+ /**
282
+ * Retry options
283
+ */
284
+ export interface RetryOptions {
285
+ maxRetries?: number;
286
+ baseDelayMs?: number;
287
+ log?: any;
288
+ }
289
+
290
+ /**
291
+ * Channel log sink
292
+ */
293
+ export type ChannelLogSink = SDKChannelLogSink;
294
+
295
+ /**
296
+ * @deprecated Use ChannelLogSink instead
297
+ */
298
+ export type Logger = ChannelLogSink;
299
+
300
+ /**
301
+ * Channel account snapshot
302
+ */
303
+ export type ChannelAccountSnapshot = SDKChannelAccountSnapshot;
304
+
305
+ /**
306
+ * @deprecated Use ChannelAccountSnapshot instead
307
+ */
308
+ export type ChannelSnapshot = ChannelAccountSnapshot;
309
+
310
+ /**
311
+ * Plugin gateway start context
312
+ */
313
+ export type GatewayStartContext = SDKChannelGatewayContext<ResolvedAccount>;
314
+
315
+ /**
316
+ * Plugin gateway account stop result
317
+ */
318
+ export interface GatewayStopResult {
319
+ stop: () => void;
320
+ }
321
+
322
+ /**
323
+ * DingTalk channel plugin definition
324
+ */
325
+ export type DingTalkChannelPlugin = SDKChannelPlugin<ResolvedAccount & { configured: boolean }>;
326
+
327
+ /**
328
+ * Result of target resolution validation
329
+ */
330
+ export interface TargetResolutionResult {
331
+ ok: boolean;
332
+ to?: string;
333
+ error?: Error;
334
+ }
335
+
336
+ /**
337
+ * Parameters for resolveTarget validation
338
+ */
339
+ export interface ResolveTargetParams {
340
+ to?: string | null;
341
+ [key: string]: any;
342
+ }
343
+
344
+ /**
345
+ * Parameters for sendText delivery
346
+ */
347
+ export interface SendTextParams {
348
+ cfg: DingTalkConfig;
349
+ to: string;
350
+ text: string;
351
+ accountId?: string;
352
+ [key: string]: any;
353
+ }
354
+
355
+ /**
356
+ * Parameters for sendMedia delivery
357
+ */
358
+ export interface SendMediaParams {
359
+ cfg: DingTalkConfig;
360
+ to: string;
361
+ mediaPath: string;
362
+ accountId?: string;
363
+ [key: string]: any;
364
+ }
365
+
366
+ /**
367
+ * DingTalk outbound handler configuration
368
+ */
369
+ export interface DingTalkOutboundHandler {
370
+ deliveryMode: "direct" | "queued" | "batch";
371
+ resolveTarget: (params: ResolveTargetParams) => TargetResolutionResult;
372
+ sendText: (params: SendTextParams) => Promise<{ ok: boolean; data?: any; error?: any }>;
373
+ sendMedia?: (params: SendMediaParams) => Promise<{ ok: boolean; data?: any; error?: any }>;
374
+ }
375
+
376
+ /**
377
+ * AI Card status constants
378
+ */
379
+ export const AICardStatus = {
380
+ PROCESSING: "1",
381
+ INPUTING: "2",
382
+ FINISHED: "3",
383
+ FAILED: "5",
384
+ } as const;
385
+
386
+ /**
387
+ * AI Card state type
388
+ */
389
+ export type AICardState = (typeof AICardStatus)[keyof typeof AICardStatus];
390
+
391
+ /**
392
+ * AI Card instance
393
+ */
394
+ export interface AICardInstance {
395
+ cardInstanceId: string;
396
+ accessToken: string;
397
+ conversationId: string;
398
+ createdAt: number;
399
+ lastUpdated: number;
400
+ state: AICardState; // Current card state: PROCESSING, INPUTING, FINISHED, FAILED
401
+ config?: DingTalkConfig; // Store config reference for token refresh
402
+ }
403
+
404
+ /**
405
+ * AI Card streaming update request (new API)
406
+ */
407
+ export interface AICardStreamingRequest {
408
+ outTrackId: string;
409
+ guid: string;
410
+ key: string;
411
+ content: string;
412
+ isFull: boolean;
413
+ isFinalize: boolean;
414
+ isError: boolean;
415
+ }
416
+
417
+ /**
418
+ * Connection state enum for lifecycle management
419
+ */
420
+ export enum ConnectionState {
421
+ DISCONNECTED = "DISCONNECTED",
422
+ CONNECTING = "CONNECTING",
423
+ CONNECTED = "CONNECTED",
424
+ DISCONNECTING = "DISCONNECTING",
425
+ FAILED = "FAILED",
426
+ }
427
+
428
+ /**
429
+ * Connection manager configuration
430
+ */
431
+ export interface ConnectionManagerConfig {
432
+ maxAttempts: number;
433
+ initialDelay: number;
434
+ maxDelay: number;
435
+ jitter: number;
436
+ /** Callback invoked when connection state changes */
437
+ onStateChange?: (state: ConnectionState, error?: string) => void;
438
+ }
439
+
440
+ /**
441
+ * Connection attempt result
442
+ */
443
+ export interface ConnectionAttemptResult {
444
+ success: boolean;
445
+ attempt: number;
446
+ error?: Error;
447
+ nextDelay?: number;
448
+ }
449
+
450
+ // ============ Onboarding Helper Functions ============
451
+
452
+ const DEFAULT_ACCOUNT_ID = "default";
453
+
454
+ /**
455
+ * List all DingTalk account IDs from config
456
+ */
457
+ export function listDingTalkAccountIds(cfg: OpenClawConfig): string[] {
458
+ const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
459
+ if (!dingtalk) return [];
460
+
461
+ const accountIds: string[] = [];
462
+
463
+ // Check for direct configuration (default account)
464
+ if (dingtalk.clientId || dingtalk.clientSecret) {
465
+ accountIds.push(DEFAULT_ACCOUNT_ID);
466
+ }
467
+
468
+ // Check accounts object
469
+ if (dingtalk.accounts) {
470
+ accountIds.push(...Object.keys(dingtalk.accounts));
471
+ }
472
+
473
+ return accountIds;
474
+ }
475
+
476
+ /**
477
+ * Resolved DingTalk account with configuration status
478
+ */
479
+ export interface ResolvedDingTalkAccount extends DingTalkConfig {
480
+ accountId: string;
481
+ configured: boolean;
482
+ }
483
+
484
+ /**
485
+ * Resolve a specific DingTalk account configuration
486
+ */
487
+ export function resolveDingTalkAccount(
488
+ cfg: OpenClawConfig,
489
+ accountId?: string | null,
490
+ ): ResolvedDingTalkAccount {
491
+ const id = accountId || DEFAULT_ACCOUNT_ID;
492
+ const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
493
+
494
+ // If default account, return top-level config
495
+ if (id === DEFAULT_ACCOUNT_ID) {
496
+ const config: DingTalkConfig = {
497
+ clientId: dingtalk?.clientId ?? "",
498
+ clientSecret: dingtalk?.clientSecret ?? "",
499
+ robotCode: dingtalk?.robotCode,
500
+ corpId: dingtalk?.corpId,
501
+ agentId: dingtalk?.agentId,
502
+ name: dingtalk?.name,
503
+ enabled: dingtalk?.enabled,
504
+ dmPolicy: dingtalk?.dmPolicy,
505
+ groupPolicy: dingtalk?.groupPolicy,
506
+ allowFrom: dingtalk?.allowFrom,
507
+ showThinking: dingtalk?.showThinking,
508
+ debug: dingtalk?.debug,
509
+ messageType: dingtalk?.messageType,
510
+ cardTemplateId: dingtalk?.cardTemplateId,
511
+ cardTemplateKey: dingtalk?.cardTemplateKey,
512
+ groups: dingtalk?.groups,
513
+ accounts: dingtalk?.accounts,
514
+ maxConnectionAttempts: dingtalk?.maxConnectionAttempts,
515
+ initialReconnectDelay: dingtalk?.initialReconnectDelay,
516
+ maxReconnectDelay: dingtalk?.maxReconnectDelay,
517
+ reconnectJitter: dingtalk?.reconnectJitter,
518
+ };
519
+ return {
520
+ ...config,
521
+ accountId: id,
522
+ configured: Boolean(config.clientId && config.clientSecret),
523
+ };
524
+ }
525
+
526
+ // If named account, get from accounts object
527
+ const accountConfig = dingtalk?.accounts?.[id];
528
+ if (accountConfig) {
529
+ return {
530
+ ...accountConfig,
531
+ accountId: id,
532
+ configured: Boolean(accountConfig.clientId && accountConfig.clientSecret),
533
+ };
534
+ }
535
+
536
+ // Account doesn't exist, return empty config
537
+ return {
538
+ clientId: "",
539
+ clientSecret: "",
540
+ accountId: id,
541
+ configured: false,
542
+ };
543
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,106 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import type { Logger, RetryOptions } from './types';
5
+
6
+ /**
7
+ * Mask sensitive fields in data for safe logging
8
+ * Prevents PII leakage in debug logs
9
+ */
10
+ export function maskSensitiveData(data: unknown): any {
11
+ if (data === null || data === undefined) {
12
+ return data;
13
+ }
14
+
15
+ if (typeof data !== 'object') {
16
+ return data as string | number;
17
+ }
18
+
19
+ const masked = JSON.parse(JSON.stringify(data)) as Record<string, any>;
20
+ const sensitiveFields = ['token', 'accessToken'];
21
+
22
+ function maskObj(obj: any): void {
23
+ for (const key in obj) {
24
+ if (sensitiveFields.includes(key)) {
25
+ const val = obj[key];
26
+ if (typeof val === 'string' && val.length > 6) {
27
+ obj[key] = val.slice(0, 3) + '*'.repeat(val.length - 6) + val.slice(-3);
28
+ } else if (typeof val === 'string') {
29
+ obj[key] = '*'.repeat(val.length);
30
+ }
31
+ } else if (typeof obj[key] === 'object' && obj[key] !== null) {
32
+ maskObj(obj[key]);
33
+ }
34
+ }
35
+ }
36
+
37
+ maskObj(masked);
38
+ return masked;
39
+ }
40
+
41
+ /**
42
+ * Cleanup orphaned temp files from dingtalk media
43
+ * Run at startup to clean up files from crashed processes
44
+ */
45
+ export function cleanupOrphanedTempFiles(log?: Logger): number {
46
+ const tempDir = os.tmpdir();
47
+ const dingtalkPattern = /^dingtalk_\d+\..+$/;
48
+ let cleaned = 0;
49
+
50
+ try {
51
+ const files = fs.readdirSync(tempDir);
52
+ const now = Date.now();
53
+ const maxAge = 24 * 60 * 60 * 1000;
54
+
55
+ for (const file of files) {
56
+ if (!dingtalkPattern.test(file)) continue;
57
+
58
+ const filePath = path.join(tempDir, file);
59
+ try {
60
+ const stats = fs.statSync(filePath);
61
+ if (now - stats.mtime.getTime() > maxAge) {
62
+ fs.unlinkSync(filePath);
63
+ cleaned++;
64
+ log?.debug?.(`[DingTalk] Cleaned up orphaned temp file: ${file}`);
65
+ }
66
+ } catch (err: any) {
67
+ log?.debug?.(`[DingTalk] Failed to cleanup temp file ${file}: ${err.message}`);
68
+ }
69
+ }
70
+
71
+ if (cleaned > 0) {
72
+ log?.info?.(`[DingTalk] Cleaned up ${cleaned} orphaned temp files`);
73
+ }
74
+ } catch (err: any) {
75
+ log?.debug?.(`[DingTalk] Failed to cleanup temp directory: ${err.message}`);
76
+ }
77
+
78
+ return cleaned;
79
+ }
80
+
81
+ /**
82
+ * Retry logic for API calls with exponential backoff
83
+ * Handles transient failures like 401 token expiry
84
+ */
85
+ export async function retryWithBackoff<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
86
+ const { maxRetries = 3, baseDelayMs = 100, log } = options;
87
+
88
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
89
+ try {
90
+ return await fn();
91
+ } catch (err: any) {
92
+ const statusCode = err.response?.status;
93
+ const isRetryable = statusCode === 401 || statusCode === 429 || (statusCode && statusCode >= 500);
94
+
95
+ if (!isRetryable || attempt === maxRetries) {
96
+ throw err;
97
+ }
98
+
99
+ const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
100
+ log?.debug?.(`[DingTalk] Retry attempt ${attempt}/${maxRetries} after ${delayMs}ms`);
101
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
102
+ }
103
+ }
104
+
105
+ throw new Error('Retry exhausted without returning');
106
+ }