@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/README.md +482 -0
- package/clawbot.plugin.json +9 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +79 -0
- package/src/AGENTS.md +63 -0
- package/src/channel.ts +1807 -0
- package/src/config-schema.ts +92 -0
- package/src/connection-manager.ts +434 -0
- package/src/media-utils.ts +132 -0
- package/src/onboarding.ts +325 -0
- package/src/openclaw-channel-dingtalk.code-workspace +17 -0
- package/src/peer-id-registry.ts +35 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +543 -0
- package/src/utils.ts +106 -0
|
@@ -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
|
+
}
|