@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.
@@ -0,0 +1,92 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * DingTalk configuration schema using Zod
5
+ * Mirrors the structure needed for proper control-ui rendering
6
+ */
7
+ export const DingTalkConfigSchema: z.ZodTypeAny = z.object({
8
+ /** Account name (optional display name) */
9
+ name: z.string().optional(),
10
+
11
+ /** Whether this channel is enabled */
12
+ enabled: z.boolean().optional().default(true),
13
+
14
+ /** DingTalk App Key (Client ID) - required for authentication */
15
+ clientId: z.string().optional(),
16
+
17
+ /** DingTalk App Secret (Client Secret) - required for authentication */
18
+ clientSecret: z.string().optional(),
19
+
20
+ /** DingTalk Robot Code for media download */
21
+ robotCode: z.string().optional(),
22
+
23
+ /** DingTalk Corporation ID */
24
+ corpId: z.string().optional(),
25
+
26
+ /** DingTalk Application ID (Agent ID) */
27
+ agentId: z.union([z.string(), z.number()]).optional(),
28
+
29
+ /** Direct message policy: open, pairing, or allowlist */
30
+ dmPolicy: z.enum(['open', 'pairing', 'allowlist']).optional().default('open'),
31
+
32
+ /** Group message policy: open or allowlist */
33
+ groupPolicy: z.enum(['open', 'allowlist']).optional().default('open'),
34
+
35
+ /** List of allowed user IDs for allowlist policy */
36
+ allowFrom: z.array(z.string()).optional(),
37
+
38
+ /** Show thinking indicator while processing */
39
+ showThinking: z.boolean().optional().default(true),
40
+
41
+ /** Enable debug logging */
42
+ debug: z.boolean().optional().default(false),
43
+
44
+ /** Message type for replies: markdown or card */
45
+ messageType: z.enum(['markdown', 'card']).optional().default('markdown'),
46
+
47
+ /** Card template ID for AI interactive cards
48
+ * obtain the template ID from DingTalk Developer Console.
49
+ * ref: 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
50
+ */
51
+ cardTemplateId: z.string().optional(),
52
+
53
+ /** Card template key for streaming updates
54
+ * Default: 'msgContent' - maps to the content field in the card template
55
+ * This key is used in the streaming API to update specific fields in the card.
56
+ */
57
+ cardTemplateKey: z.string().optional().default('content'),
58
+
59
+ /** Per-group configuration, keyed by conversationId (supports "*" wildcard) */
60
+ groups: z
61
+ .record(
62
+ z.string(),
63
+ z.object({
64
+ systemPrompt: z.string().optional(),
65
+ })
66
+ )
67
+ .optional(),
68
+
69
+ /** Multi-account configuration */
70
+ accounts: z
71
+ .record(
72
+ z.string(),
73
+ z.lazy(() => DingTalkConfigSchema)
74
+ )
75
+ .optional(),
76
+
77
+ /** Connection robustness configuration */
78
+
79
+ /** Maximum number of connection attempts before giving up (default: 10) */
80
+ maxConnectionAttempts: z.number().int().min(1).optional().default(10),
81
+
82
+ /** Initial reconnection delay in milliseconds (default: 1000ms) */
83
+ initialReconnectDelay: z.number().int().min(100).optional().default(1000),
84
+
85
+ /** Maximum reconnection delay in milliseconds for exponential backoff (default: 60000ms = 1 minute) */
86
+ maxReconnectDelay: z.number().int().min(1000).optional().default(60000),
87
+
88
+ /** Jitter factor for reconnection delay randomization (0-1, default: 0.3) */
89
+ reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
90
+ });
91
+
92
+ export type DingTalkConfig = z.infer<typeof DingTalkConfigSchema>;
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Connection Manager for DingTalk Stream Client
3
+ *
4
+ * Provides robust connection lifecycle management with:
5
+ * - Exponential backoff with jitter for reconnection attempts
6
+ * - Configurable max attempts and delay parameters
7
+ * - Connection state tracking and event handling
8
+ * - Proper cleanup of timers and resources
9
+ * - Structured logging for all connection events
10
+ */
11
+
12
+ import type { DWClient } from 'dingtalk-stream';
13
+ import type { ConnectionState, ConnectionManagerConfig, ConnectionAttemptResult, Logger } from './types';
14
+ import { ConnectionState as ConnectionStateEnum } from './types';
15
+
16
+ /**
17
+ * ConnectionManager handles the robust connection lifecycle for DWClient
18
+ */
19
+ export class ConnectionManager {
20
+ private config: ConnectionManagerConfig;
21
+ private log?: Logger;
22
+ private accountId: string;
23
+
24
+ // Connection state tracking
25
+ private state: ConnectionState = ConnectionStateEnum.DISCONNECTED;
26
+ private attemptCount: number = 0;
27
+ private reconnectTimer?: NodeJS.Timeout;
28
+ private stopped: boolean = false;
29
+
30
+ // Runtime monitoring resources
31
+ private healthCheckInterval?: NodeJS.Timeout;
32
+ private socketCloseHandler?: (code: number, reason: string) => void;
33
+ private socketErrorHandler?: (error: Error) => void;
34
+ private monitoredSocket?: any; // Store the socket instance we attached listeners to
35
+
36
+ // Sleep abort control
37
+ private sleepTimeout?: NodeJS.Timeout;
38
+ private sleepResolve?: () => void;
39
+
40
+ // Client reference
41
+ private client: DWClient;
42
+
43
+ constructor(client: DWClient, accountId: string, config: ConnectionManagerConfig, log?: Logger) {
44
+ this.client = client;
45
+ this.accountId = accountId;
46
+ this.config = config;
47
+ this.log = log;
48
+ }
49
+
50
+ private notifyStateChange(error?: string): void {
51
+ if (this.config.onStateChange) {
52
+ this.config.onStateChange(this.state, error);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Calculate next reconnection delay with exponential backoff and jitter
58
+ * Formula: delay = min(initialDelay * 2^attempt, maxDelay) * (1 ± jitter)
59
+ * @param attempt Zero-based attempt number (0 for first retry, 1 for second, etc.)
60
+ */
61
+ private calculateNextDelay(attempt: number): number {
62
+ const { initialDelay, maxDelay, jitter } = this.config;
63
+
64
+ // Exponential backoff: initialDelay * 2^attempt
65
+ // For attempt=0 (first retry), this gives initialDelay * 1 = initialDelay
66
+ const exponentialDelay = initialDelay * Math.pow(2, attempt);
67
+
68
+ // Cap at maxDelay
69
+ const cappedDelay = Math.min(exponentialDelay, maxDelay);
70
+
71
+ // Apply jitter: randomize ± jitter%
72
+ const jitterAmount = cappedDelay * jitter;
73
+ const randomJitter = (Math.random() * 2 - 1) * jitterAmount;
74
+ const finalDelay = Math.max(100, cappedDelay + randomJitter); // Minimum 100ms
75
+
76
+ return Math.floor(finalDelay);
77
+ }
78
+
79
+ /**
80
+ * Attempt to connect with retry logic
81
+ */
82
+ private async attemptConnection(): Promise<ConnectionAttemptResult> {
83
+ if (this.stopped) {
84
+ return { success: false, attempt: this.attemptCount, error: new Error('Connection manager stopped') };
85
+ }
86
+
87
+ this.attemptCount++;
88
+ this.state = ConnectionStateEnum.CONNECTING;
89
+ this.notifyStateChange();
90
+
91
+ this.log?.info?.(`[${this.accountId}] Connection attempt ${this.attemptCount}/${this.config.maxAttempts}...`);
92
+
93
+ try {
94
+ // Call DWClient connect method
95
+ await this.client.connect();
96
+
97
+ // Re-check stopped flag after async connect() completes
98
+ // This prevents race condition where stop() is called during connection
99
+ if (this.stopped) {
100
+ this.log?.warn?.(
101
+ `[${this.accountId}] Connection succeeded but manager was stopped during connect - disconnecting`
102
+ );
103
+ try {
104
+ this.client.disconnect();
105
+ } catch (disconnectErr: any) {
106
+ this.log?.debug?.(`[${this.accountId}] Error during post-connect disconnect: ${disconnectErr.message}`);
107
+ }
108
+ return {
109
+ success: false,
110
+ attempt: this.attemptCount,
111
+ error: new Error('Connection manager stopped during connect'),
112
+ };
113
+ }
114
+
115
+ // Connection successful
116
+ this.state = ConnectionStateEnum.CONNECTED;
117
+ this.notifyStateChange();
118
+ const successfulAttempt = this.attemptCount;
119
+ this.attemptCount = 0; // Reset counter on success
120
+
121
+ this.log?.info?.(`[${this.accountId}] DingTalk Stream client connected successfully`);
122
+
123
+ return { success: true, attempt: successfulAttempt };
124
+ } catch (err: any) {
125
+ this.log?.error?.(`[${this.accountId}] Connection attempt ${this.attemptCount} failed: ${err.message}`);
126
+
127
+ // Check if we've exceeded max attempts
128
+ if (this.attemptCount >= this.config.maxAttempts) {
129
+ this.state = ConnectionStateEnum.FAILED;
130
+ this.notifyStateChange('Max connection attempts reached');
131
+ this.log?.error?.(
132
+ `[${this.accountId}] Max connection attempts (${this.config.maxAttempts}) reached. Giving up.`
133
+ );
134
+ return { success: false, attempt: this.attemptCount, error: err };
135
+ }
136
+
137
+ // Calculate next retry delay (use attemptCount-1 for zero-based exponent)
138
+ // This ensures first retry uses 2^0 = 1x initialDelay
139
+ const nextDelay = this.calculateNextDelay(this.attemptCount - 1);
140
+
141
+ this.log?.warn?.(
142
+ `[${this.accountId}] Will retry connection in ${(nextDelay / 1000).toFixed(2)}s (attempt ${this.attemptCount + 1}/${this.config.maxAttempts})`
143
+ );
144
+
145
+ return { success: false, attempt: this.attemptCount, error: err, nextDelay };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Connect with robust retry logic
151
+ */
152
+ public async connect(): Promise<void> {
153
+ if (this.stopped) {
154
+ throw new Error('Cannot connect: connection manager is stopped');
155
+ }
156
+
157
+ // Clear any existing reconnect timer
158
+ this.clearReconnectTimer();
159
+
160
+ this.log?.info?.(`[${this.accountId}] Starting DingTalk Stream client with robust connection...`);
161
+
162
+ // Keep trying until success or max attempts reached
163
+ while (!this.stopped && this.state !== ConnectionStateEnum.CONNECTED) {
164
+ const result = await this.attemptConnection();
165
+
166
+ if (result.success) {
167
+ // Connection successful
168
+ this.setupRuntimeReconnection();
169
+ return;
170
+ }
171
+
172
+ // Check if connection was stopped during connect
173
+ if (result.error?.message === 'Connection manager stopped during connect') {
174
+ this.log?.info?.(`[${this.accountId}] Connection cancelled: manager stopped during connect`);
175
+ throw new Error('Connection cancelled: connection manager stopped');
176
+ }
177
+
178
+ if (!result.nextDelay || this.attemptCount >= this.config.maxAttempts) {
179
+ // No more retries
180
+ throw new Error(`Failed to connect after ${this.attemptCount} attempts`);
181
+ }
182
+
183
+ // Wait before next attempt
184
+ await this.sleep(result.nextDelay);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Setup runtime reconnection handlers
190
+ * Monitors DWClient connection state for automatic reconnection
191
+ */
192
+ private setupRuntimeReconnection(): void {
193
+ this.log?.debug?.(`[${this.accountId}] Setting up runtime reconnection monitoring`);
194
+
195
+ // Clean up any existing monitoring resources before setting up new ones
196
+ this.cleanupRuntimeMonitoring();
197
+
198
+ // Access DWClient internals to monitor connection state
199
+ const client = this.client as any;
200
+
201
+ // Monitor client's 'connected' property changes
202
+ // We'll set up an interval to periodically check connection health
203
+ this.healthCheckInterval = setInterval(() => {
204
+ if (this.stopped) {
205
+ if (this.healthCheckInterval) {
206
+ clearInterval(this.healthCheckInterval);
207
+ }
208
+ return;
209
+ }
210
+
211
+ // If we believe we're connected but DWClient disagrees, trigger reconnection
212
+ if (this.state === ConnectionStateEnum.CONNECTED && !client.connected) {
213
+ this.log?.warn?.(`[${this.accountId}] Connection health check failed - detected disconnection`);
214
+ if (this.healthCheckInterval) {
215
+ clearInterval(this.healthCheckInterval);
216
+ }
217
+ this.handleRuntimeDisconnection();
218
+ }
219
+ }, 5000); // Check every 5 seconds
220
+
221
+ // Additionally, if we have access to the socket, monitor its events
222
+ // The DWClient uses 'ws' WebSocket library which extends EventEmitter
223
+ if (client.socket) {
224
+ const socket = client.socket;
225
+ // Store the socket instance we're attaching listeners to
226
+ this.monitoredSocket = socket;
227
+
228
+ // Handler for socket close event
229
+ this.socketCloseHandler = (code: number, reason: string) => {
230
+ this.log?.warn?.(`[${this.accountId}] WebSocket closed event (code: ${code}, reason: ${reason || 'none'})`);
231
+
232
+ // Only trigger reconnection if we were previously connected and not stopping
233
+ if (!this.stopped && this.state === ConnectionStateEnum.CONNECTED) {
234
+ if (this.healthCheckInterval) {
235
+ clearInterval(this.healthCheckInterval);
236
+ }
237
+ this.handleRuntimeDisconnection();
238
+ }
239
+ };
240
+
241
+ // Handler for socket error event
242
+ this.socketErrorHandler = (error: Error) => {
243
+ this.log?.error?.(`[${this.accountId}] WebSocket error event: ${error?.message || 'Unknown error'}`);
244
+ };
245
+
246
+ // Listen to socket events
247
+ // Use 'once' for close to avoid duplicate reconnection triggers
248
+ socket.once('close', this.socketCloseHandler);
249
+ // Use 'once' for error as well to prevent accumulation across reconnects
250
+ socket.once('error', this.socketErrorHandler);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Clean up runtime monitoring resources (intervals and event listeners)
256
+ */
257
+ private cleanupRuntimeMonitoring(): void {
258
+ // Clear health check interval
259
+ if (this.healthCheckInterval) {
260
+ clearInterval(this.healthCheckInterval);
261
+ this.healthCheckInterval = undefined;
262
+ this.log?.debug?.(`[${this.accountId}] Health check interval cleared`);
263
+ }
264
+
265
+ // Remove socket event listeners from the stored socket instance
266
+ if (this.monitoredSocket) {
267
+ const socket = this.monitoredSocket;
268
+
269
+ if (this.socketCloseHandler) {
270
+ socket.removeListener('close', this.socketCloseHandler);
271
+ this.socketCloseHandler = undefined;
272
+ }
273
+ if (this.socketErrorHandler) {
274
+ socket.removeListener('error', this.socketErrorHandler);
275
+ this.socketErrorHandler = undefined;
276
+ }
277
+
278
+ this.log?.debug?.(`[${this.accountId}] Socket event listeners removed from monitored socket`);
279
+ this.monitoredSocket = undefined;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Handle runtime disconnection and trigger reconnection
285
+ */
286
+ private handleRuntimeDisconnection(): void {
287
+ if (this.stopped) return;
288
+
289
+ this.log?.warn?.(`[${this.accountId}] Runtime disconnection detected, initiating reconnection...`);
290
+
291
+ this.state = ConnectionStateEnum.DISCONNECTED;
292
+ this.notifyStateChange('Runtime disconnection detected');
293
+ this.attemptCount = 0; // Reset attempt counter for runtime reconnection
294
+
295
+ // Clear any existing timer
296
+ this.clearReconnectTimer();
297
+
298
+ // Start reconnection with initial delay
299
+ const delay = this.calculateNextDelay(0);
300
+ this.log?.info?.(`[${this.accountId}] Scheduling reconnection in ${(delay / 1000).toFixed(2)}s`);
301
+
302
+ this.reconnectTimer = setTimeout(() => {
303
+ this.reconnect().catch((err) => {
304
+ this.log?.error?.(`[${this.accountId}] Reconnection failed: ${err.message}`);
305
+ });
306
+ }, delay);
307
+ }
308
+
309
+ /**
310
+ * Reconnect after runtime disconnection
311
+ */
312
+ private async reconnect(): Promise<void> {
313
+ if (this.stopped) return;
314
+
315
+ this.log?.info?.(`[${this.accountId}] Attempting to reconnect...`);
316
+
317
+ try {
318
+ await this.connect();
319
+ this.log?.info?.(`[${this.accountId}] Reconnection successful`);
320
+ } catch (err: any) {
321
+ if (this.stopped) return;
322
+
323
+ this.log?.error?.(`[${this.accountId}] Reconnection failed: ${err.message}`);
324
+ this.state = ConnectionStateEnum.FAILED;
325
+ this.notifyStateChange(err.message);
326
+
327
+ // Continue runtime recovery instead of getting stuck in FAILED.
328
+ const delay = this.calculateNextDelay(0);
329
+ this.attemptCount = 0;
330
+ this.clearReconnectTimer();
331
+ this.log?.warn?.(
332
+ `[${this.accountId}] Reconnection cycle failed; scheduling next reconnect in ${(delay / 1000).toFixed(2)}s`
333
+ );
334
+ this.reconnectTimer = setTimeout(() => {
335
+ void this.reconnect();
336
+ }, delay);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Stop the connection manager and cleanup resources
342
+ */
343
+ public stop(): void {
344
+ if (this.stopped) return;
345
+
346
+ this.log?.info?.(`[${this.accountId}] Stopping connection manager...`);
347
+
348
+ this.stopped = true;
349
+ this.state = ConnectionStateEnum.DISCONNECTING;
350
+
351
+ // Clear reconnect timer
352
+ this.clearReconnectTimer();
353
+
354
+ // Cancel any in-flight sleep (retry delay)
355
+ this.cancelSleep();
356
+
357
+ // Clean up runtime monitoring resources
358
+ this.cleanupRuntimeMonitoring();
359
+
360
+ // Disconnect client
361
+ try {
362
+ this.client.disconnect();
363
+ } catch (err: any) {
364
+ this.log?.warn?.(`[${this.accountId}] Error during disconnect: ${err.message}`);
365
+ }
366
+
367
+ this.state = ConnectionStateEnum.DISCONNECTED;
368
+ this.log?.info?.(`[${this.accountId}] Connection manager stopped`);
369
+ }
370
+
371
+ /**
372
+ * Clear reconnection timer
373
+ */
374
+ private clearReconnectTimer(): void {
375
+ if (this.reconnectTimer) {
376
+ clearTimeout(this.reconnectTimer);
377
+ this.reconnectTimer = undefined;
378
+ this.log?.debug?.(`[${this.accountId}] Reconnect timer cleared`);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Sleep utility for retry delays
384
+ * Returns a promise that resolves after ms or can be cancelled via cancelSleep()
385
+ */
386
+ private sleep(ms: number): Promise<void> {
387
+ return new Promise((resolve) => {
388
+ this.sleepResolve = resolve;
389
+ this.sleepTimeout = setTimeout(() => {
390
+ this.sleepTimeout = undefined;
391
+ this.sleepResolve = undefined;
392
+ resolve();
393
+ }, ms);
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Cancel any in-flight sleep operation
399
+ * Resolves the pending promise immediately so await unblocks
400
+ */
401
+ private cancelSleep(): void {
402
+ if (this.sleepTimeout) {
403
+ clearTimeout(this.sleepTimeout);
404
+ this.sleepTimeout = undefined;
405
+ this.log?.debug?.(`[${this.accountId}] Sleep timeout cancelled`);
406
+ }
407
+ // Resolve the pending promise so await unblocks immediately
408
+ if (this.sleepResolve) {
409
+ this.sleepResolve();
410
+ this.sleepResolve = undefined;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Get current connection state
416
+ */
417
+ public getState(): ConnectionState {
418
+ return this.state;
419
+ }
420
+
421
+ /**
422
+ * Check if connection is active
423
+ */
424
+ public isConnected(): boolean {
425
+ return this.state === ConnectionStateEnum.CONNECTED;
426
+ }
427
+
428
+ /**
429
+ * Check if connection manager is stopped
430
+ */
431
+ public isStopped(): boolean {
432
+ return this.stopped;
433
+ }
434
+ }
@@ -0,0 +1,132 @@
1
+ // src/media-utils.ts
2
+
3
+ /**
4
+ * Media handling utilities for DingTalk channel plugin.
5
+ * Provides functions for media type detection and file upload to DingTalk media servers.
6
+ */
7
+
8
+ import * as path from 'path';
9
+ import * as fs from 'fs';
10
+ import { promises as fsPromises } from 'fs';
11
+ import axios from 'axios';
12
+ import FormData from 'form-data';
13
+ import type { DingTalkConfig, Logger } from './types';
14
+
15
+ export type DingTalkMediaType = 'image' | 'voice' | 'video' | 'file';
16
+
17
+ /**
18
+ * Detect media type from file extension
19
+ * Matches DingTalk's supported media types:
20
+ * - image: jpg, gif, png, bmp (max 20MB)
21
+ * - voice: amr, mp3, wav (max 2MB)
22
+ * - video: mp4 (max 20MB)
23
+ * - file: doc, docx, xls, xlsx, ppt, pptx, zip, pdf, rar (max 20MB)
24
+ *
25
+ * @param filePath Path to the media file
26
+ * @returns Detected media type
27
+ */
28
+ export function detectMediaTypeFromExtension(filePath: string): DingTalkMediaType {
29
+ const ext = path.extname(filePath).toLowerCase();
30
+
31
+ if (['.jpg', '.jpeg', '.png', '.gif', '.bmp'].includes(ext)) {
32
+ return 'image';
33
+ } else if (['.mp3', '.amr', '.wav'].includes(ext)) {
34
+ return 'voice';
35
+ } else if (['.mp4', '.avi', '.mov'].includes(ext)) {
36
+ return 'video';
37
+ }
38
+
39
+ return 'file';
40
+ }
41
+
42
+ /**
43
+ * File size limits for DingTalk media types (in bytes)
44
+ */
45
+ const FILE_SIZE_LIMITS: Record<DingTalkMediaType, number> = {
46
+ image: 20 * 1024 * 1024, // 20MB
47
+ voice: 2 * 1024 * 1024, // 2MB
48
+ video: 20 * 1024 * 1024, // 20MB
49
+ file: 20 * 1024 * 1024, // 20MB
50
+ };
51
+
52
+ /**
53
+ * Upload media file to DingTalk and get media_id
54
+ * Uses DingTalk's media upload API: https://oapi.dingtalk.com/media/upload
55
+ *
56
+ * Note: Media files are stored temporarily by DingTalk (not in permanent storage).
57
+ * The media_id can be used in subsequent message sends.
58
+ *
59
+ * @param config DingTalk configuration
60
+ * @param mediaPath Local path to the media file
61
+ * @param mediaType Type of media: 'image' | 'voice' | 'video' | 'file'
62
+ * @param getAccessToken Function to get DingTalk access token
63
+ * @param log Optional logger
64
+ * @returns media_id on success, null on failure
65
+ */
66
+ export async function uploadMedia(
67
+ config: DingTalkConfig,
68
+ mediaPath: string,
69
+ mediaType: DingTalkMediaType,
70
+ getAccessToken: (config: DingTalkConfig, log?: Logger) => Promise<string>,
71
+ log?: Logger
72
+ ): Promise<string | null> {
73
+ let fileStream: fs.ReadStream | null = null;
74
+
75
+ try {
76
+ const token = await getAccessToken(config, log);
77
+
78
+ // Check file size (stat will throw if file doesn't exist)
79
+ const stats = await fsPromises.stat(mediaPath);
80
+ const sizeLimit = FILE_SIZE_LIMITS[mediaType];
81
+ if (stats.size > sizeLimit) {
82
+ const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
83
+ const limitMB = (sizeLimit / (1024 * 1024)).toFixed(2);
84
+ log?.error?.(`[DingTalk] Media file too large: ${sizeMB}MB exceeds ${limitMB}MB limit for ${mediaType}`);
85
+ return null;
86
+ }
87
+
88
+ // Read file as a stream for better memory efficiency
89
+ fileStream = fs.createReadStream(mediaPath);
90
+ const filename = path.basename(mediaPath);
91
+
92
+ // Upload to DingTalk's media server using form-data
93
+ const form = new FormData();
94
+ form.append('media', fileStream, { filename });
95
+
96
+ const uploadUrl = `https://oapi.dingtalk.com/media/upload?access_token=${token}&type=${mediaType}`;
97
+
98
+ log?.debug?.(`[DingTalk] Uploading media: ${filename} (${stats.size} bytes) as ${mediaType}`);
99
+
100
+ const response = await axios.post(uploadUrl, form, {
101
+ headers: form.getHeaders(),
102
+ maxBodyLength: Infinity,
103
+ maxContentLength: Infinity,
104
+ });
105
+
106
+ if (response.data?.errcode === 0 && response.data?.media_id) {
107
+ log?.debug?.(`[DingTalk] Media uploaded successfully: ${response.data.media_id} (${stats.size} bytes)`);
108
+ return response.data.media_id;
109
+ } else {
110
+ log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
111
+ return null;
112
+ }
113
+ } catch (err: any) {
114
+ // Handle file system errors (e.g., file not found, permission denied)
115
+ if (err.code === 'ENOENT') {
116
+ log?.error?.(`[DingTalk] Media file not found: ${mediaPath}`);
117
+ } else if (err.code === 'EACCES') {
118
+ log?.error?.(`[DingTalk] Permission denied accessing media file: ${mediaPath}`);
119
+ } else {
120
+ log?.error?.(`[DingTalk] Failed to upload media: ${err.message}`);
121
+ if (axios.isAxiosError(err) && err.response) {
122
+ log?.error?.(`[DingTalk] Upload response: ${JSON.stringify(err.response.data)}`);
123
+ }
124
+ }
125
+ return null;
126
+ } finally {
127
+ // Ensure file stream is closed even on error
128
+ if (fileStream) {
129
+ fileStream.destroy();
130
+ }
131
+ }
132
+ }