@yaoyuanchao/dingtalk 1.5.0 → 1.5.2

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/api.ts CHANGED
@@ -1,955 +1,955 @@
1
- import https from "node:https";
2
- import http from "node:http";
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import os from "node:os";
6
-
7
- const DINGTALK_API_BASE = "https://api.dingtalk.com/v1.0";
8
- const DINGTALK_OAPI_BASE = "https://oapi.dingtalk.com";
9
-
10
- /** Temp directory for downloaded media files */
11
- const TEMP_DIR = path.join(os.tmpdir(), "dingtalk-media");
12
-
13
- /** Cache access tokens per clientId */
14
- const tokenCache = new Map<string, { token: string; expiresAt: number }>();
15
-
16
- function jsonPost(url: string, body: any, headers?: Record<string, string>): Promise<any> {
17
- return new Promise((resolve, reject) => {
18
- const data = JSON.stringify(body);
19
- const urlObj = new URL(url);
20
- const mod = urlObj.protocol === "https:" ? https : http;
21
- const req = mod.request(urlObj, {
22
- method: "POST",
23
- headers: {
24
- "Content-Type": "application/json",
25
- "Content-Length": Buffer.byteLength(data),
26
- ...headers,
27
- },
28
- timeout: 10000, // 10 second timeout
29
- family: 4, // Force IPv4 to avoid IPv6 connection issues
30
- }, (res) => {
31
- let buf = "";
32
- res.on("data", (chunk: any) => { buf += chunk; });
33
- res.on("end", () => {
34
- try { resolve(JSON.parse(buf)); }
35
- catch { resolve({ raw: buf }); }
36
- });
37
- });
38
- req.on("error", reject);
39
- req.on("timeout", () => {
40
- req.destroy();
41
- reject(new Error("Request timeout"));
42
- });
43
- req.write(data);
44
- req.end();
45
- });
46
- }
47
-
48
- function httpGetBuffer(url: string, headers?: Record<string, string>): Promise<Buffer> {
49
- return new Promise((resolve, reject) => {
50
- const urlObj = new URL(url);
51
- const mod = urlObj.protocol === "https:" ? https : http;
52
- const req = mod.request(urlObj, {
53
- method: "GET",
54
- headers: headers || {},
55
- timeout: 30000, // 30 second timeout for file downloads
56
- family: 4,
57
- }, (res) => {
58
- const chunks: Buffer[] = [];
59
- res.on("data", (chunk: Buffer) => { chunks.push(chunk); });
60
- res.on("end", () => {
61
- resolve(Buffer.concat(chunks));
62
- });
63
- });
64
- req.on("error", reject);
65
- req.on("timeout", () => {
66
- req.destroy();
67
- reject(new Error("Download timeout"));
68
- });
69
- req.end();
70
- });
71
- }
72
-
73
- /** Retry wrapper for async functions */
74
- async function withRetry<T>(
75
- fn: () => Promise<T>,
76
- maxRetries: number = 3,
77
- delayMs: number = 1000,
78
- backoffMultiplier: number = 2,
79
- ): Promise<T> {
80
- let lastError: Error | undefined;
81
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
82
- try {
83
- return await fn();
84
- } catch (err) {
85
- lastError = err instanceof Error ? err : new Error(String(err));
86
- if (attempt < maxRetries) {
87
- const delay = delayMs * Math.pow(backoffMultiplier, attempt - 1);
88
- console.log(`[dingtalk] Retry ${attempt}/${maxRetries} after ${delay}ms: ${lastError.message}`);
89
- await new Promise(resolve => setTimeout(resolve, delay));
90
- }
91
- }
92
- }
93
- throw lastError;
94
- }
95
-
96
- export async function getDingTalkAccessToken(clientId: string, clientSecret: string): Promise<string> {
97
- const cached = tokenCache.get(clientId);
98
- if (cached && cached.expiresAt > Date.now() + 60_000) {
99
- return cached.token;
100
- }
101
- const res = await jsonPost(`${DINGTALK_API_BASE}/oauth2/accessToken`, {
102
- appKey: clientId,
103
- appSecret: clientSecret,
104
- });
105
- if (!res.accessToken) {
106
- throw new Error(`DingTalk token error: ${JSON.stringify(res)}`);
107
- }
108
- tokenCache.set(clientId, {
109
- token: res.accessToken,
110
- expiresAt: Date.now() + (res.expireIn ?? 7200) * 1000,
111
- });
112
- return res.accessToken;
113
- }
114
-
115
- /** Send reply via sessionWebhook (preferred, no auth needed) */
116
- export async function sendViaSessionWebhook(
117
- sessionWebhook: string,
118
- text: string,
119
- ): Promise<{ ok: boolean; errcode?: number; errmsg?: string }> {
120
- const res = await jsonPost(sessionWebhook, {
121
- msgtype: "text",
122
- text: { content: text },
123
- });
124
- const ok = res?.errcode === 0 || !res?.errcode;
125
- if (!ok) {
126
- console.warn(`[dingtalk] SessionWebhook text error: errcode=${res?.errcode}, errmsg=${res?.errmsg}`);
127
- }
128
- return { ok, errcode: res?.errcode, errmsg: res?.errmsg };
129
- }
130
-
131
- /** Send markdown via sessionWebhook */
132
- export async function sendMarkdownViaSessionWebhook(
133
- sessionWebhook: string,
134
- title: string,
135
- text: string,
136
- ): Promise<{ ok: boolean; errcode?: number; errmsg?: string }> {
137
- const res = await jsonPost(sessionWebhook, {
138
- msgtype: "markdown",
139
- markdown: { title, text },
140
- });
141
- const ok = res?.errcode === 0 || !res?.errcode;
142
- if (!ok) {
143
- console.warn(`[dingtalk] SessionWebhook markdown error: errcode=${res?.errcode}, errmsg=${res?.errmsg}`);
144
- }
145
- return { ok, errcode: res?.errcode, errmsg: res?.errmsg };
146
- }
147
-
148
- /** Send image via sessionWebhook using markdown format */
149
- export async function sendImageViaSessionWebhook(
150
- sessionWebhook: string,
151
- imageUrl: string,
152
- caption?: string,
153
- ): Promise<{ ok: boolean }> {
154
- const title = caption || "图片";
155
- const text = caption
156
- ? `${caption}\n\n![image](${imageUrl})`
157
- : `![image](${imageUrl})`;
158
-
159
- return sendMarkdownViaSessionWebhook(sessionWebhook, title, text);
160
- }
161
-
162
- /** Send message via REST API (proactive/outbound, requires token) */
163
- export async function sendDingTalkRestMessage(params: {
164
- clientId: string;
165
- clientSecret: string;
166
- robotCode: string;
167
- userId?: string;
168
- conversationId?: string;
169
- text: string;
170
- format?: 'text' | 'markdown';
171
- }): Promise<{ ok: boolean }> {
172
- const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
173
- const headers = { "x-acs-dingtalk-access-token": token };
174
-
175
- // Use markdown format by default for better rendering
176
- const useMarkdown = params.format !== 'text';
177
- const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
178
- const msgParam = useMarkdown
179
- ? JSON.stringify({ title: 'AI', text: params.text })
180
- : JSON.stringify({ content: params.text });
181
-
182
- if (params.userId) {
183
- const res = await jsonPost(
184
- `${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
185
- {
186
- robotCode: params.robotCode,
187
- userIds: [params.userId],
188
- msgKey,
189
- msgParam,
190
- },
191
- headers,
192
- );
193
- if (res?.errcode && res.errcode !== 0) {
194
- throw new Error(`DingTalk DM send error: ${JSON.stringify(res)}`);
195
- }
196
- return { ok: !!res?.processQueryKey || !res?.code };
197
- }
198
-
199
- if (params.conversationId) {
200
- const res = await jsonPost(
201
- `${DINGTALK_API_BASE}/robot/groupMessages/send`,
202
- {
203
- robotCode: params.robotCode,
204
- openConversationId: params.conversationId,
205
- msgKey,
206
- msgParam,
207
- },
208
- headers,
209
- );
210
- if (res?.errcode && res.errcode !== 0) {
211
- throw new Error(`DingTalk group send error: ${JSON.stringify(res)}`);
212
- }
213
- return { ok: !!res?.processQueryKey || !res?.code };
214
- }
215
-
216
- throw new Error("Either userId or conversationId required");
217
- }
218
-
219
- /** Probe DingTalk connection by getting an access token */
220
- export async function probeDingTalk(
221
- clientId: string,
222
- clientSecret: string,
223
- ): Promise<{ ok: boolean; error?: string }> {
224
- try {
225
- await getDingTalkAccessToken(clientId, clientSecret);
226
- return { ok: true };
227
- } catch (err) {
228
- return { ok: false, error: String(err) };
229
- }
230
- }
231
-
232
- /** User info cache - persisted to local file */
233
- const USER_CACHE_FILE = path.join(os.homedir(), ".clawdbot", "extensions", "dingtalk", ".cache", "users.json");
234
- let userCache: Map<string, { name: string; avatar?: string }> | null = null;
235
-
236
- function loadUserCache(): Map<string, { name: string; avatar?: string }> {
237
- if (userCache) return userCache;
238
-
239
- try {
240
- if (fs.existsSync(USER_CACHE_FILE)) {
241
- const data = fs.readFileSync(USER_CACHE_FILE, "utf-8");
242
- const obj = JSON.parse(data);
243
- userCache = new Map(Object.entries(obj));
244
- return userCache;
245
- }
246
- } catch (err) {
247
- console.warn("[dingtalk] Failed to load user cache:", err);
248
- }
249
-
250
- userCache = new Map();
251
- return userCache;
252
- }
253
-
254
- function saveUserCache(cache: Map<string, { name: string; avatar?: string }>): void {
255
- try {
256
- const dir = path.dirname(USER_CACHE_FILE);
257
- if (!fs.existsSync(dir)) {
258
- fs.mkdirSync(dir, { recursive: true });
259
- }
260
- const obj = Object.fromEntries(cache.entries());
261
- fs.writeFileSync(USER_CACHE_FILE, JSON.stringify(obj, null, 2), "utf-8");
262
- } catch (err) {
263
- console.warn("[dingtalk] Failed to save user cache:", err);
264
- }
265
- }
266
-
267
- /** Get user info by userid (staffId), with persistent cache */
268
- export async function getUserInfo(
269
- clientId: string,
270
- clientSecret: string,
271
- userid: string,
272
- ): Promise<{ name: string; avatar?: string } | null> {
273
- const cache = loadUserCache();
274
-
275
- // Check cache first
276
- const cached = cache.get(userid);
277
- if (cached) return cached;
278
-
279
- // Call DingTalk API
280
- try {
281
- const token = await getDingTalkAccessToken(clientId, clientSecret);
282
- const res = await jsonPost(
283
- `${DINGTALK_OAPI_BASE}/topapi/v2/user/get?access_token=${token}`,
284
- {
285
- userid,
286
- language: "zh_CN",
287
- },
288
- );
289
-
290
- if (res.errcode !== 0) {
291
- console.warn(`[dingtalk] Failed to get user info for ${userid}: ${res.errmsg}`);
292
- return null;
293
- }
294
-
295
- const userInfo = {
296
- name: res.result?.name || userid,
297
- avatar: res.result?.avatar,
298
- };
299
-
300
- // Save to cache
301
- cache.set(userid, userInfo);
302
- saveUserCache(cache);
303
-
304
- return userInfo;
305
- } catch (err) {
306
- console.warn(`[dingtalk] Error getting user info for ${userid}:`, err);
307
- return null;
308
- }
309
- }
310
-
311
- /** Batch get user info with timeout */
312
- export async function batchGetUserInfo(
313
- clientId: string,
314
- clientSecret: string,
315
- userids: string[],
316
- timeoutMs: number = 500,
317
- ): Promise<Map<string, string>> {
318
- const result = new Map<string, string>();
319
-
320
- if (userids.length === 0) return result;
321
-
322
- const promises = userids.map(async (userid) => {
323
- const info = await getUserInfo(clientId, clientSecret, userid);
324
- if (info) {
325
- result.set(userid, info.name);
326
- }
327
- });
328
-
329
- try {
330
- await Promise.race([
331
- Promise.all(promises),
332
- new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutMs)),
333
- ]);
334
- } catch (err) {
335
- // Timeout or error - return partial results
336
- console.warn(`[dingtalk] Batch user info fetch timeout/error:`, err);
337
- }
338
-
339
- return result;
340
- }
341
-
342
- /** Download picture from DingTalk and return as base64 or file path */
343
- export async function downloadPicture(
344
- clientId: string,
345
- clientSecret: string,
346
- robotCode: string,
347
- downloadCode: string,
348
- ): Promise<{ base64?: string; filePath?: string; error?: string }> {
349
- try {
350
- const token = await getDingTalkAccessToken(clientId, clientSecret);
351
-
352
- // DingTalk picture download API (v1.0 API, NOT oapi)
353
- const url = `${DINGTALK_API_BASE}/robot/messageFiles/download`;
354
- const headers = {
355
- "x-acs-dingtalk-access-token": token,
356
- };
357
- const body = {
358
- downloadCode,
359
- robotCode,
360
- };
361
-
362
- // Download as buffer
363
- const response = await jsonPost(url, body, headers);
364
-
365
- // Check if response contains error
366
- if (response.errcode && response.errcode !== 0) {
367
- console.warn(`[dingtalk] Picture download failed: ${response.errmsg}`);
368
- return { error: response.errmsg || "Download failed" };
369
- }
370
-
371
- // If response has a file URL, download it with retry
372
- if (response.downloadUrl) {
373
- const imageBuffer = await withRetry(
374
- () => httpGetBuffer(response.downloadUrl),
375
- 3, // maxRetries
376
- 1000, // initial delay 1s
377
- 2, // backoff multiplier
378
- );
379
-
380
- // Convert to base64
381
- const base64 = imageBuffer.toString('base64');
382
-
383
- // Also save to temp file for reference
384
- if (!fs.existsSync(TEMP_DIR)) {
385
- fs.mkdirSync(TEMP_DIR, { recursive: true });
386
- }
387
-
388
- const timestamp = Date.now();
389
- const filename = `picture_${timestamp}.jpg`;
390
- const filePath = path.join(TEMP_DIR, filename);
391
-
392
- fs.writeFileSync(filePath, imageBuffer);
393
-
394
- console.log(`[dingtalk] Picture downloaded successfully: ${filePath} (${imageBuffer.length} bytes)`);
395
-
396
- return { base64, filePath };
397
- }
398
-
399
- return { error: "No download URL in response" };
400
- } catch (err) {
401
- console.warn(`[dingtalk] Error downloading picture:`, err);
402
- return { error: String(err) };
403
- }
404
- }
405
-
406
- /** Extension mapping for media types */
407
- const MEDIA_EXTENSIONS: Record<string, string> = {
408
- 'image/jpeg': '.jpg',
409
- 'image/png': '.png',
410
- 'image/gif': '.gif',
411
- 'image/webp': '.webp',
412
- 'audio/amr': '.amr',
413
- 'audio/mpeg': '.mp3',
414
- 'audio/mp4': '.m4a',
415
- 'video/mp4': '.mp4',
416
- 'application/pdf': '.pdf',
417
- 'application/octet-stream': '.bin',
418
- };
419
-
420
- /** Download media file (picture/audio/video/file) from DingTalk */
421
- export async function downloadMediaFile(
422
- clientId: string,
423
- clientSecret: string,
424
- robotCode: string,
425
- downloadCode: string,
426
- mediaType?: string,
427
- ): Promise<{ filePath?: string; mimeType?: string; error?: string }> {
428
- try {
429
- const token = await getDingTalkAccessToken(clientId, clientSecret);
430
-
431
- const url = `${DINGTALK_API_BASE}/robot/messageFiles/download`;
432
- const headers = { "x-acs-dingtalk-access-token": token };
433
- const body = { downloadCode, robotCode };
434
-
435
- const response = await jsonPost(url, body, headers);
436
-
437
- if (response.errcode && response.errcode !== 0) {
438
- console.warn(`[dingtalk] Media download failed: ${response.errmsg}`);
439
- return { error: response.errmsg || "Download failed" };
440
- }
441
-
442
- if (response.downloadUrl) {
443
- const mediaBuffer = await withRetry(
444
- () => httpGetBuffer(response.downloadUrl),
445
- 3, // maxRetries
446
- 1000, // initial delay 1s
447
- 2, // backoff multiplier
448
- );
449
-
450
- if (!fs.existsSync(TEMP_DIR)) {
451
- fs.mkdirSync(TEMP_DIR, { recursive: true });
452
- }
453
-
454
- // Determine file extension from content type or media type hint
455
- const contentType = response.contentType || '';
456
- const ext = MEDIA_EXTENSIONS[contentType]
457
- || (mediaType === 'audio' ? '.amr' : undefined)
458
- || (mediaType === 'video' ? '.mp4' : undefined)
459
- || (mediaType === 'image' ? '.jpg' : undefined)
460
- || '.bin';
461
-
462
- const timestamp = Date.now();
463
- const prefix = mediaType || 'media';
464
- const filename = `${prefix}_${timestamp}${ext}`;
465
- const filePath = path.join(TEMP_DIR, filename);
466
-
467
- fs.writeFileSync(filePath, mediaBuffer);
468
-
469
- console.log(`[dingtalk] Media downloaded: ${filePath} (${mediaBuffer.length} bytes, type=${contentType || mediaType || 'unknown'})`);
470
-
471
- return { filePath, mimeType: contentType || undefined };
472
- }
473
-
474
- return { error: "No download URL in response" };
475
- } catch (err) {
476
- console.warn(`[dingtalk] Error downloading media:`, err);
477
- return { error: String(err) };
478
- }
479
- }
480
-
481
- /** Clean up old media files (older than 1 hour) */
482
- export function cleanupOldMedia(): void {
483
- try {
484
- if (!fs.existsSync(TEMP_DIR)) return;
485
-
486
- const files = fs.readdirSync(TEMP_DIR);
487
- const oneHourAgo = Date.now() - 60 * 60 * 1000;
488
-
489
- for (const file of files) {
490
- const filePath = path.join(TEMP_DIR, file);
491
- const stats = fs.statSync(filePath);
492
-
493
- if (stats.mtimeMs < oneHourAgo) {
494
- fs.unlinkSync(filePath);
495
- console.log(`[dingtalk] Cleaned up old media: ${filePath}`);
496
- }
497
- }
498
- } catch (err) {
499
- console.warn(`[dingtalk] Error cleaning up media:`, err);
500
- }
501
- }
502
-
503
- /** @deprecated Use cleanupOldMedia() instead */
504
- export const cleanupOldPictures = cleanupOldMedia;
505
-
506
- /**
507
- * Upload a file to DingTalk and get media_id
508
- * Uses the legacy oapi media upload endpoint (the new API doesn't exist)
509
- */
510
- export async function uploadMediaFile(params: {
511
- clientId: string;
512
- clientSecret: string;
513
- robotCode: string;
514
- fileBuffer: Buffer;
515
- fileName: string;
516
- fileType?: 'image' | 'voice' | 'video' | 'file';
517
- }): Promise<{ mediaId?: string; error?: string }> {
518
- try {
519
- const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
520
-
521
- // Build multipart form data manually
522
- const boundary = `----DingTalkBoundary${Date.now()}`;
523
- const fileType = params.fileType || 'file';
524
-
525
- // Construct multipart body - oapi uses "media" as the file field name
526
- const parts: Buffer[] = [];
527
-
528
- // Add file field
529
- parts.push(Buffer.from(
530
- `--${boundary}\r\n` +
531
- `Content-Disposition: form-data; name="media"; filename="${params.fileName}"\r\n` +
532
- `Content-Type: application/octet-stream\r\n\r\n`
533
- ));
534
- parts.push(params.fileBuffer);
535
- parts.push(Buffer.from('\r\n'));
536
-
537
- // End boundary
538
- parts.push(Buffer.from(`--${boundary}--\r\n`));
539
-
540
- const body = Buffer.concat(parts);
541
-
542
- // Use legacy oapi endpoint - the new v1.0 API doesn't have this endpoint
543
- const url = `https://oapi.dingtalk.com/media/upload?access_token=${token}&type=${fileType}`;
544
-
545
- return new Promise((resolve) => {
546
- const urlObj = new URL(url);
547
- const req = https.request(urlObj, {
548
- method: 'POST',
549
- headers: {
550
- 'Content-Type': `multipart/form-data; boundary=${boundary}`,
551
- 'Content-Length': body.length,
552
- },
553
- timeout: 60000, // 60 second timeout for upload
554
- family: 4,
555
- }, (res) => {
556
- let buf = '';
557
- res.on('data', (chunk: any) => { buf += chunk; });
558
- res.on('end', () => {
559
- try {
560
- const json = JSON.parse(buf);
561
- // oapi returns media_id (with underscore), not mediaId
562
- if (json.media_id) {
563
- console.log(`[dingtalk] File uploaded successfully: media_id=${json.media_id}`);
564
- resolve({ mediaId: json.media_id });
565
- } else if (json.mediaId) {
566
- console.log(`[dingtalk] File uploaded successfully: mediaId=${json.mediaId}`);
567
- resolve({ mediaId: json.mediaId });
568
- } else {
569
- console.warn(`[dingtalk] File upload failed:`, json);
570
- resolve({ error: json.errmsg || json.message || 'Upload failed' });
571
- }
572
- } catch {
573
- resolve({ error: `Invalid response: ${buf}` });
574
- }
575
- });
576
- });
577
-
578
- req.on('error', (err) => {
579
- console.warn(`[dingtalk] File upload error:`, err);
580
- resolve({ error: String(err) });
581
- });
582
-
583
- req.on('timeout', () => {
584
- req.destroy();
585
- resolve({ error: 'Upload timeout' });
586
- });
587
-
588
- req.write(body);
589
- req.end();
590
- });
591
- } catch (err) {
592
- console.warn(`[dingtalk] Error uploading file:`, err);
593
- return { error: String(err) };
594
- }
595
- }
596
-
597
- /**
598
- * Send a file message via REST API
599
- * Requires mediaId from uploadMediaFile
600
- */
601
- export async function sendFileMessage(params: {
602
- clientId: string;
603
- clientSecret: string;
604
- robotCode: string;
605
- userId?: string;
606
- conversationId?: string;
607
- mediaId: string;
608
- fileName: string;
609
- }): Promise<{ ok: boolean; error?: string }> {
610
- try {
611
- const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
612
- const headers = { 'x-acs-dingtalk-access-token': token };
613
-
614
- const msgParam = JSON.stringify({
615
- mediaId: params.mediaId,
616
- fileName: params.fileName,
617
- fileType: getFileExtension(params.fileName),
618
- });
619
-
620
- if (params.userId) {
621
- // Send to DM
622
- const res = await jsonPost(
623
- `${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
624
- {
625
- robotCode: params.robotCode,
626
- userIds: [params.userId],
627
- msgKey: 'sampleFile',
628
- msgParam,
629
- },
630
- headers,
631
- );
632
-
633
- if (res?.code || res?.errcode) {
634
- console.warn(`[dingtalk] File send (DM) failed:`, res);
635
- return { ok: false, error: res.message || res.errmsg };
636
- }
637
-
638
- console.log(`[dingtalk] File sent to DM: ${params.fileName}`);
639
- return { ok: true };
640
- }
641
-
642
- if (params.conversationId) {
643
- // Send to group
644
- const res = await jsonPost(
645
- `${DINGTALK_API_BASE}/robot/groupMessages/send`,
646
- {
647
- robotCode: params.robotCode,
648
- openConversationId: params.conversationId,
649
- msgKey: 'sampleFile',
650
- msgParam,
651
- },
652
- headers,
653
- );
654
-
655
- if (res?.code || res?.errcode) {
656
- console.warn(`[dingtalk] File send (group) failed:`, res);
657
- return { ok: false, error: res.message || res.errmsg };
658
- }
659
-
660
- console.log(`[dingtalk] File sent to group: ${params.fileName}`);
661
- return { ok: true };
662
- }
663
-
664
- return { ok: false, error: 'Either userId or conversationId required' };
665
- } catch (err) {
666
- console.warn(`[dingtalk] Error sending file:`, err);
667
- return { ok: false, error: String(err) };
668
- }
669
- }
670
-
671
- /** Get file extension from filename */
672
- function getFileExtension(fileName: string): string {
673
- const ext = path.extname(fileName).toLowerCase();
674
- return ext ? ext.slice(1) : 'bin';
675
- }
676
-
677
- /**
678
- * Convert text to a markdown file buffer with UTF-8 BOM
679
- * BOM is needed for proper Chinese display in DingTalk/Windows
680
- */
681
- export function textToMarkdownFile(text: string, title?: string): { buffer: Buffer; fileName: string } {
682
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
683
- const fileName = title ? `${title}.md` : `reply_${timestamp}.md`;
684
-
685
- // Add UTF-8 BOM for proper Chinese display
686
- const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
687
- const content = Buffer.from(text, 'utf-8');
688
- const buffer = Buffer.concat([bom, content]);
689
-
690
- return { buffer, fileName };
691
- }
692
-
693
- // ============================================================================
694
- // Message Recall (撤回) APIs
695
- // ============================================================================
696
-
697
- /**
698
- * Send a DM message and return the processQueryKey for later recall
699
- * This is an enhanced version that returns the message ID
700
- */
701
- export async function sendDMMessageWithKey(params: {
702
- clientId: string;
703
- clientSecret: string;
704
- robotCode: string;
705
- userId: string;
706
- text: string;
707
- format?: 'text' | 'markdown';
708
- }): Promise<{ ok: boolean; processQueryKey?: string; error?: string }> {
709
- try {
710
- const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
711
- const headers = { "x-acs-dingtalk-access-token": token };
712
-
713
- const useMarkdown = params.format !== 'text';
714
- const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
715
- const msgParam = useMarkdown
716
- ? JSON.stringify({ title: 'AI', text: params.text })
717
- : JSON.stringify({ content: params.text });
718
-
719
- const res = await jsonPost(
720
- `${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
721
- {
722
- robotCode: params.robotCode,
723
- userIds: [params.userId],
724
- msgKey,
725
- msgParam,
726
- },
727
- headers,
728
- );
729
-
730
- if (res?.code || (res?.errcode && res.errcode !== 0)) {
731
- console.warn(`[dingtalk] DM send error:`, res);
732
- return { ok: false, error: res.message || res.errmsg };
733
- }
734
-
735
- return {
736
- ok: true,
737
- processQueryKey: res.processQueryKey
738
- };
739
- } catch (err) {
740
- console.warn(`[dingtalk] Error sending DM:`, err);
741
- return { ok: false, error: String(err) };
742
- }
743
- }
744
-
745
- /**
746
- * Send a group message and return the processQueryKey for later recall
747
- */
748
- export async function sendGroupMessageWithKey(params: {
749
- clientId: string;
750
- clientSecret: string;
751
- robotCode: string;
752
- conversationId: string;
753
- text: string;
754
- format?: 'text' | 'markdown';
755
- }): Promise<{ ok: boolean; processQueryKey?: string; error?: string }> {
756
- try {
757
- const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
758
- const headers = { "x-acs-dingtalk-access-token": token };
759
-
760
- const useMarkdown = params.format !== 'text';
761
- const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
762
- const msgParam = useMarkdown
763
- ? JSON.stringify({ title: 'AI', text: params.text })
764
- : JSON.stringify({ content: params.text });
765
-
766
- const res = await jsonPost(
767
- `${DINGTALK_API_BASE}/robot/groupMessages/send`,
768
- {
769
- robotCode: params.robotCode,
770
- openConversationId: params.conversationId,
771
- msgKey,
772
- msgParam,
773
- },
774
- headers,
775
- );
776
-
777
- if (res?.code || (res?.errcode && res.errcode !== 0)) {
778
- console.warn(`[dingtalk] Group send error:`, res);
779
- return { ok: false, error: res.message || res.errmsg };
780
- }
781
-
782
- return {
783
- ok: true,
784
- processQueryKey: res.processQueryKey
785
- };
786
- } catch (err) {
787
- console.warn(`[dingtalk] Error sending group message:`, err);
788
- return { ok: false, error: String(err) };
789
- }
790
- }
791
-
792
- /**
793
- * Recall (撤回) DM messages
794
- * Note: This is a "silent recall" - the message just disappears without notification
795
- */
796
- export async function recallDMMessages(params: {
797
- clientId: string;
798
- clientSecret: string;
799
- robotCode: string;
800
- userId: string;
801
- processQueryKeys: string[];
802
- }): Promise<{ ok: boolean; successKeys?: string[]; failedKeys?: Record<string, string>; error?: string }> {
803
- try {
804
- const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
805
- const headers = { "x-acs-dingtalk-access-token": token };
806
-
807
- const res = await jsonPost(
808
- `${DINGTALK_API_BASE}/robot/otoMessages/batchRecall`,
809
- {
810
- robotCode: params.robotCode,
811
- chatBotUserId: params.userId,
812
- processQueryKeys: params.processQueryKeys,
813
- },
814
- headers,
815
- );
816
-
817
- if (res?.code || (res?.errcode && res.errcode !== 0)) {
818
- console.warn(`[dingtalk] DM recall error:`, res);
819
- return { ok: false, error: res.message || res.errmsg };
820
- }
821
-
822
- return {
823
- ok: true,
824
- successKeys: res.successResult,
825
- failedKeys: res.failedResult,
826
- };
827
- } catch (err) {
828
- console.warn(`[dingtalk] Error recalling DM:`, err);
829
- return { ok: false, error: String(err) };
830
- }
831
- }
832
-
833
- /**
834
- * Recall (撤回) group messages
835
- */
836
- export async function recallGroupMessages(params: {
837
- clientId: string;
838
- clientSecret: string;
839
- robotCode: string;
840
- conversationId: string;
841
- processQueryKeys: string[];
842
- }): Promise<{ ok: boolean; successKeys?: string[]; failedKeys?: Record<string, string>; error?: string }> {
843
- try {
844
- const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
845
- const headers = { "x-acs-dingtalk-access-token": token };
846
-
847
- const res = await jsonPost(
848
- `${DINGTALK_API_BASE}/robot/groupMessages/recall`,
849
- {
850
- robotCode: params.robotCode,
851
- openConversationId: params.conversationId,
852
- processQueryKeys: params.processQueryKeys,
853
- },
854
- headers,
855
- );
856
-
857
- if (res?.code || (res?.errcode && res.errcode !== 0)) {
858
- console.warn(`[dingtalk] Group recall error:`, res);
859
- return { ok: false, error: res.message || res.errmsg };
860
- }
861
-
862
- return {
863
- ok: true,
864
- successKeys: res.successResult,
865
- failedKeys: res.failedResult,
866
- };
867
- } catch (err) {
868
- console.warn(`[dingtalk] Error recalling group message:`, err);
869
- return { ok: false, error: String(err) };
870
- }
871
- }
872
-
873
- /**
874
- * Typing indicator helper - sends a "thinking" message that will be recalled
875
- * Returns a cleanup function to recall the message
876
- */
877
- export async function sendTypingIndicator(params: {
878
- clientId: string;
879
- clientSecret: string;
880
- robotCode: string;
881
- userId?: string;
882
- conversationId?: string;
883
- message?: string;
884
- }): Promise<{ cleanup: () => Promise<void>; error?: string }> {
885
- const typingMessage = params.message || "⏳ 思考中...";
886
-
887
- try {
888
- if (params.userId) {
889
- const result = await sendDMMessageWithKey({
890
- clientId: params.clientId,
891
- clientSecret: params.clientSecret,
892
- robotCode: params.robotCode,
893
- userId: params.userId,
894
- text: typingMessage,
895
- format: 'text',
896
- });
897
-
898
- if (!result.ok || !result.processQueryKey) {
899
- return {
900
- cleanup: async () => {},
901
- error: result.error || "Failed to send typing indicator"
902
- };
903
- }
904
-
905
- const processQueryKey = result.processQueryKey;
906
- return {
907
- cleanup: async () => {
908
- await recallDMMessages({
909
- clientId: params.clientId,
910
- clientSecret: params.clientSecret,
911
- robotCode: params.robotCode,
912
- userId: params.userId!,
913
- processQueryKeys: [processQueryKey],
914
- });
915
- }
916
- };
917
- }
918
-
919
- if (params.conversationId) {
920
- const result = await sendGroupMessageWithKey({
921
- clientId: params.clientId,
922
- clientSecret: params.clientSecret,
923
- robotCode: params.robotCode,
924
- conversationId: params.conversationId,
925
- text: typingMessage,
926
- format: 'text',
927
- });
928
-
929
- if (!result.ok || !result.processQueryKey) {
930
- return {
931
- cleanup: async () => {},
932
- error: result.error || "Failed to send typing indicator"
933
- };
934
- }
935
-
936
- const processQueryKey = result.processQueryKey;
937
- return {
938
- cleanup: async () => {
939
- await recallGroupMessages({
940
- clientId: params.clientId,
941
- clientSecret: params.clientSecret,
942
- robotCode: params.robotCode,
943
- conversationId: params.conversationId!,
944
- processQueryKeys: [processQueryKey],
945
- });
946
- }
947
- };
948
- }
949
-
950
- return { cleanup: async () => {}, error: "Either userId or conversationId required" };
951
- } catch (err) {
952
- console.warn(`[dingtalk] Error sending typing indicator:`, err);
953
- return { cleanup: async () => {}, error: String(err) };
954
- }
955
- }
1
+ import https from "node:https";
2
+ import http from "node:http";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+
7
+ const DINGTALK_API_BASE = "https://api.dingtalk.com/v1.0";
8
+ const DINGTALK_OAPI_BASE = "https://oapi.dingtalk.com";
9
+
10
+ /** Temp directory for downloaded media files */
11
+ const TEMP_DIR = path.join(os.tmpdir(), "dingtalk-media");
12
+
13
+ /** Cache access tokens per clientId */
14
+ const tokenCache = new Map<string, { token: string; expiresAt: number }>();
15
+
16
+ function jsonPost(url: string, body: any, headers?: Record<string, string>): Promise<any> {
17
+ return new Promise((resolve, reject) => {
18
+ const data = JSON.stringify(body);
19
+ const urlObj = new URL(url);
20
+ const mod = urlObj.protocol === "https:" ? https : http;
21
+ const req = mod.request(urlObj, {
22
+ method: "POST",
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ "Content-Length": Buffer.byteLength(data),
26
+ ...headers,
27
+ },
28
+ timeout: 10000, // 10 second timeout
29
+ family: 4, // Force IPv4 to avoid IPv6 connection issues
30
+ }, (res) => {
31
+ let buf = "";
32
+ res.on("data", (chunk: any) => { buf += chunk; });
33
+ res.on("end", () => {
34
+ try { resolve(JSON.parse(buf)); }
35
+ catch { resolve({ raw: buf }); }
36
+ });
37
+ });
38
+ req.on("error", reject);
39
+ req.on("timeout", () => {
40
+ req.destroy();
41
+ reject(new Error("Request timeout"));
42
+ });
43
+ req.write(data);
44
+ req.end();
45
+ });
46
+ }
47
+
48
+ function httpGetBuffer(url: string, headers?: Record<string, string>): Promise<Buffer> {
49
+ return new Promise((resolve, reject) => {
50
+ const urlObj = new URL(url);
51
+ const mod = urlObj.protocol === "https:" ? https : http;
52
+ const req = mod.request(urlObj, {
53
+ method: "GET",
54
+ headers: headers || {},
55
+ timeout: 30000, // 30 second timeout for file downloads
56
+ family: 4,
57
+ }, (res) => {
58
+ const chunks: Buffer[] = [];
59
+ res.on("data", (chunk: Buffer) => { chunks.push(chunk); });
60
+ res.on("end", () => {
61
+ resolve(Buffer.concat(chunks));
62
+ });
63
+ });
64
+ req.on("error", reject);
65
+ req.on("timeout", () => {
66
+ req.destroy();
67
+ reject(new Error("Download timeout"));
68
+ });
69
+ req.end();
70
+ });
71
+ }
72
+
73
+ /** Retry wrapper for async functions */
74
+ async function withRetry<T>(
75
+ fn: () => Promise<T>,
76
+ maxRetries: number = 3,
77
+ delayMs: number = 1000,
78
+ backoffMultiplier: number = 2,
79
+ ): Promise<T> {
80
+ let lastError: Error | undefined;
81
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
82
+ try {
83
+ return await fn();
84
+ } catch (err) {
85
+ lastError = err instanceof Error ? err : new Error(String(err));
86
+ if (attempt < maxRetries) {
87
+ const delay = delayMs * Math.pow(backoffMultiplier, attempt - 1);
88
+ console.log(`[dingtalk] Retry ${attempt}/${maxRetries} after ${delay}ms: ${lastError.message}`);
89
+ await new Promise(resolve => setTimeout(resolve, delay));
90
+ }
91
+ }
92
+ }
93
+ throw lastError;
94
+ }
95
+
96
+ export async function getDingTalkAccessToken(clientId: string, clientSecret: string): Promise<string> {
97
+ const cached = tokenCache.get(clientId);
98
+ if (cached && cached.expiresAt > Date.now() + 60_000) {
99
+ return cached.token;
100
+ }
101
+ const res = await jsonPost(`${DINGTALK_API_BASE}/oauth2/accessToken`, {
102
+ appKey: clientId,
103
+ appSecret: clientSecret,
104
+ });
105
+ if (!res.accessToken) {
106
+ throw new Error(`DingTalk token error: ${JSON.stringify(res)}`);
107
+ }
108
+ tokenCache.set(clientId, {
109
+ token: res.accessToken,
110
+ expiresAt: Date.now() + (res.expireIn ?? 7200) * 1000,
111
+ });
112
+ return res.accessToken;
113
+ }
114
+
115
+ /** Send reply via sessionWebhook (preferred, no auth needed) */
116
+ export async function sendViaSessionWebhook(
117
+ sessionWebhook: string,
118
+ text: string,
119
+ ): Promise<{ ok: boolean; errcode?: number; errmsg?: string }> {
120
+ const res = await jsonPost(sessionWebhook, {
121
+ msgtype: "text",
122
+ text: { content: text },
123
+ });
124
+ const ok = res?.errcode === 0 || !res?.errcode;
125
+ if (!ok) {
126
+ console.warn(`[dingtalk] SessionWebhook text error: errcode=${res?.errcode}, errmsg=${res?.errmsg}`);
127
+ }
128
+ return { ok, errcode: res?.errcode, errmsg: res?.errmsg };
129
+ }
130
+
131
+ /** Send markdown via sessionWebhook */
132
+ export async function sendMarkdownViaSessionWebhook(
133
+ sessionWebhook: string,
134
+ title: string,
135
+ text: string,
136
+ ): Promise<{ ok: boolean; errcode?: number; errmsg?: string }> {
137
+ const res = await jsonPost(sessionWebhook, {
138
+ msgtype: "markdown",
139
+ markdown: { title, text },
140
+ });
141
+ const ok = res?.errcode === 0 || !res?.errcode;
142
+ if (!ok) {
143
+ console.warn(`[dingtalk] SessionWebhook markdown error: errcode=${res?.errcode}, errmsg=${res?.errmsg}`);
144
+ }
145
+ return { ok, errcode: res?.errcode, errmsg: res?.errmsg };
146
+ }
147
+
148
+ /** Send image via sessionWebhook using markdown format */
149
+ export async function sendImageViaSessionWebhook(
150
+ sessionWebhook: string,
151
+ imageUrl: string,
152
+ caption?: string,
153
+ ): Promise<{ ok: boolean }> {
154
+ const title = caption || "图片";
155
+ const text = caption
156
+ ? `${caption}\n\n![image](${imageUrl})`
157
+ : `![image](${imageUrl})`;
158
+
159
+ return sendMarkdownViaSessionWebhook(sessionWebhook, title, text);
160
+ }
161
+
162
+ /** Send message via REST API (proactive/outbound, requires token) */
163
+ export async function sendDingTalkRestMessage(params: {
164
+ clientId: string;
165
+ clientSecret: string;
166
+ robotCode: string;
167
+ userId?: string;
168
+ conversationId?: string;
169
+ text: string;
170
+ format?: 'text' | 'markdown';
171
+ }): Promise<{ ok: boolean }> {
172
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
173
+ const headers = { "x-acs-dingtalk-access-token": token };
174
+
175
+ // Use markdown format by default for better rendering
176
+ const useMarkdown = params.format !== 'text';
177
+ const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
178
+ const msgParam = useMarkdown
179
+ ? JSON.stringify({ title: 'AI', text: params.text })
180
+ : JSON.stringify({ content: params.text });
181
+
182
+ if (params.userId) {
183
+ const res = await jsonPost(
184
+ `${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
185
+ {
186
+ robotCode: params.robotCode,
187
+ userIds: [params.userId],
188
+ msgKey,
189
+ msgParam,
190
+ },
191
+ headers,
192
+ );
193
+ if (res?.errcode && res.errcode !== 0) {
194
+ throw new Error(`DingTalk DM send error: ${JSON.stringify(res)}`);
195
+ }
196
+ return { ok: !!res?.processQueryKey || !res?.code };
197
+ }
198
+
199
+ if (params.conversationId) {
200
+ const res = await jsonPost(
201
+ `${DINGTALK_API_BASE}/robot/groupMessages/send`,
202
+ {
203
+ robotCode: params.robotCode,
204
+ openConversationId: params.conversationId,
205
+ msgKey,
206
+ msgParam,
207
+ },
208
+ headers,
209
+ );
210
+ if (res?.errcode && res.errcode !== 0) {
211
+ throw new Error(`DingTalk group send error: ${JSON.stringify(res)}`);
212
+ }
213
+ return { ok: !!res?.processQueryKey || !res?.code };
214
+ }
215
+
216
+ throw new Error("Either userId or conversationId required");
217
+ }
218
+
219
+ /** Probe DingTalk connection by getting an access token */
220
+ export async function probeDingTalk(
221
+ clientId: string,
222
+ clientSecret: string,
223
+ ): Promise<{ ok: boolean; error?: string }> {
224
+ try {
225
+ await getDingTalkAccessToken(clientId, clientSecret);
226
+ return { ok: true };
227
+ } catch (err) {
228
+ return { ok: false, error: String(err) };
229
+ }
230
+ }
231
+
232
+ /** User info cache - persisted to local file */
233
+ const USER_CACHE_FILE = path.join(os.homedir(), ".clawdbot", "extensions", "dingtalk", ".cache", "users.json");
234
+ let userCache: Map<string, { name: string; avatar?: string }> | null = null;
235
+
236
+ function loadUserCache(): Map<string, { name: string; avatar?: string }> {
237
+ if (userCache) return userCache;
238
+
239
+ try {
240
+ if (fs.existsSync(USER_CACHE_FILE)) {
241
+ const data = fs.readFileSync(USER_CACHE_FILE, "utf-8");
242
+ const obj = JSON.parse(data);
243
+ userCache = new Map(Object.entries(obj));
244
+ return userCache;
245
+ }
246
+ } catch (err) {
247
+ console.warn("[dingtalk] Failed to load user cache:", err);
248
+ }
249
+
250
+ userCache = new Map();
251
+ return userCache;
252
+ }
253
+
254
+ function saveUserCache(cache: Map<string, { name: string; avatar?: string }>): void {
255
+ try {
256
+ const dir = path.dirname(USER_CACHE_FILE);
257
+ if (!fs.existsSync(dir)) {
258
+ fs.mkdirSync(dir, { recursive: true });
259
+ }
260
+ const obj = Object.fromEntries(cache.entries());
261
+ fs.writeFileSync(USER_CACHE_FILE, JSON.stringify(obj, null, 2), "utf-8");
262
+ } catch (err) {
263
+ console.warn("[dingtalk] Failed to save user cache:", err);
264
+ }
265
+ }
266
+
267
+ /** Get user info by userid (staffId), with persistent cache */
268
+ export async function getUserInfo(
269
+ clientId: string,
270
+ clientSecret: string,
271
+ userid: string,
272
+ ): Promise<{ name: string; avatar?: string } | null> {
273
+ const cache = loadUserCache();
274
+
275
+ // Check cache first
276
+ const cached = cache.get(userid);
277
+ if (cached) return cached;
278
+
279
+ // Call DingTalk API
280
+ try {
281
+ const token = await getDingTalkAccessToken(clientId, clientSecret);
282
+ const res = await jsonPost(
283
+ `${DINGTALK_OAPI_BASE}/topapi/v2/user/get?access_token=${token}`,
284
+ {
285
+ userid,
286
+ language: "zh_CN",
287
+ },
288
+ );
289
+
290
+ if (res.errcode !== 0) {
291
+ console.warn(`[dingtalk] Failed to get user info for ${userid}: ${res.errmsg}`);
292
+ return null;
293
+ }
294
+
295
+ const userInfo = {
296
+ name: res.result?.name || userid,
297
+ avatar: res.result?.avatar,
298
+ };
299
+
300
+ // Save to cache
301
+ cache.set(userid, userInfo);
302
+ saveUserCache(cache);
303
+
304
+ return userInfo;
305
+ } catch (err) {
306
+ console.warn(`[dingtalk] Error getting user info for ${userid}:`, err);
307
+ return null;
308
+ }
309
+ }
310
+
311
+ /** Batch get user info with timeout */
312
+ export async function batchGetUserInfo(
313
+ clientId: string,
314
+ clientSecret: string,
315
+ userids: string[],
316
+ timeoutMs: number = 500,
317
+ ): Promise<Map<string, string>> {
318
+ const result = new Map<string, string>();
319
+
320
+ if (userids.length === 0) return result;
321
+
322
+ const promises = userids.map(async (userid) => {
323
+ const info = await getUserInfo(clientId, clientSecret, userid);
324
+ if (info) {
325
+ result.set(userid, info.name);
326
+ }
327
+ });
328
+
329
+ try {
330
+ await Promise.race([
331
+ Promise.all(promises),
332
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutMs)),
333
+ ]);
334
+ } catch (err) {
335
+ // Timeout or error - return partial results
336
+ console.warn(`[dingtalk] Batch user info fetch timeout/error:`, err);
337
+ }
338
+
339
+ return result;
340
+ }
341
+
342
+ /** Download picture from DingTalk and return as base64 or file path */
343
+ export async function downloadPicture(
344
+ clientId: string,
345
+ clientSecret: string,
346
+ robotCode: string,
347
+ downloadCode: string,
348
+ ): Promise<{ base64?: string; filePath?: string; error?: string }> {
349
+ try {
350
+ const token = await getDingTalkAccessToken(clientId, clientSecret);
351
+
352
+ // DingTalk picture download API (v1.0 API, NOT oapi)
353
+ const url = `${DINGTALK_API_BASE}/robot/messageFiles/download`;
354
+ const headers = {
355
+ "x-acs-dingtalk-access-token": token,
356
+ };
357
+ const body = {
358
+ downloadCode,
359
+ robotCode,
360
+ };
361
+
362
+ // Download as buffer
363
+ const response = await jsonPost(url, body, headers);
364
+
365
+ // Check if response contains error
366
+ if (response.errcode && response.errcode !== 0) {
367
+ console.warn(`[dingtalk] Picture download failed: ${response.errmsg}`);
368
+ return { error: response.errmsg || "Download failed" };
369
+ }
370
+
371
+ // If response has a file URL, download it with retry
372
+ if (response.downloadUrl) {
373
+ const imageBuffer = await withRetry(
374
+ () => httpGetBuffer(response.downloadUrl),
375
+ 3, // maxRetries
376
+ 1000, // initial delay 1s
377
+ 2, // backoff multiplier
378
+ );
379
+
380
+ // Convert to base64
381
+ const base64 = imageBuffer.toString('base64');
382
+
383
+ // Also save to temp file for reference
384
+ if (!fs.existsSync(TEMP_DIR)) {
385
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
386
+ }
387
+
388
+ const timestamp = Date.now();
389
+ const filename = `picture_${timestamp}.jpg`;
390
+ const filePath = path.join(TEMP_DIR, filename);
391
+
392
+ fs.writeFileSync(filePath, imageBuffer);
393
+
394
+ console.log(`[dingtalk] Picture downloaded successfully: ${filePath} (${imageBuffer.length} bytes)`);
395
+
396
+ return { base64, filePath };
397
+ }
398
+
399
+ return { error: "No download URL in response" };
400
+ } catch (err) {
401
+ console.warn(`[dingtalk] Error downloading picture:`, err);
402
+ return { error: String(err) };
403
+ }
404
+ }
405
+
406
+ /** Extension mapping for media types */
407
+ const MEDIA_EXTENSIONS: Record<string, string> = {
408
+ 'image/jpeg': '.jpg',
409
+ 'image/png': '.png',
410
+ 'image/gif': '.gif',
411
+ 'image/webp': '.webp',
412
+ 'audio/amr': '.amr',
413
+ 'audio/mpeg': '.mp3',
414
+ 'audio/mp4': '.m4a',
415
+ 'video/mp4': '.mp4',
416
+ 'application/pdf': '.pdf',
417
+ 'application/octet-stream': '.bin',
418
+ };
419
+
420
+ /** Download media file (picture/audio/video/file) from DingTalk */
421
+ export async function downloadMediaFile(
422
+ clientId: string,
423
+ clientSecret: string,
424
+ robotCode: string,
425
+ downloadCode: string,
426
+ mediaType?: string,
427
+ ): Promise<{ filePath?: string; mimeType?: string; error?: string }> {
428
+ try {
429
+ const token = await getDingTalkAccessToken(clientId, clientSecret);
430
+
431
+ const url = `${DINGTALK_API_BASE}/robot/messageFiles/download`;
432
+ const headers = { "x-acs-dingtalk-access-token": token };
433
+ const body = { downloadCode, robotCode };
434
+
435
+ const response = await jsonPost(url, body, headers);
436
+
437
+ if (response.errcode && response.errcode !== 0) {
438
+ console.warn(`[dingtalk] Media download failed: ${response.errmsg}`);
439
+ return { error: response.errmsg || "Download failed" };
440
+ }
441
+
442
+ if (response.downloadUrl) {
443
+ const mediaBuffer = await withRetry(
444
+ () => httpGetBuffer(response.downloadUrl),
445
+ 3, // maxRetries
446
+ 1000, // initial delay 1s
447
+ 2, // backoff multiplier
448
+ );
449
+
450
+ if (!fs.existsSync(TEMP_DIR)) {
451
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
452
+ }
453
+
454
+ // Determine file extension from content type or media type hint
455
+ const contentType = response.contentType || '';
456
+ const ext = MEDIA_EXTENSIONS[contentType]
457
+ || (mediaType === 'audio' ? '.amr' : undefined)
458
+ || (mediaType === 'video' ? '.mp4' : undefined)
459
+ || (mediaType === 'image' ? '.jpg' : undefined)
460
+ || '.bin';
461
+
462
+ const timestamp = Date.now();
463
+ const prefix = mediaType || 'media';
464
+ const filename = `${prefix}_${timestamp}${ext}`;
465
+ const filePath = path.join(TEMP_DIR, filename);
466
+
467
+ fs.writeFileSync(filePath, mediaBuffer);
468
+
469
+ console.log(`[dingtalk] Media downloaded: ${filePath} (${mediaBuffer.length} bytes, type=${contentType || mediaType || 'unknown'})`);
470
+
471
+ return { filePath, mimeType: contentType || undefined };
472
+ }
473
+
474
+ return { error: "No download URL in response" };
475
+ } catch (err) {
476
+ console.warn(`[dingtalk] Error downloading media:`, err);
477
+ return { error: String(err) };
478
+ }
479
+ }
480
+
481
+ /** Clean up old media files (older than 1 hour) */
482
+ export function cleanupOldMedia(): void {
483
+ try {
484
+ if (!fs.existsSync(TEMP_DIR)) return;
485
+
486
+ const files = fs.readdirSync(TEMP_DIR);
487
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
488
+
489
+ for (const file of files) {
490
+ const filePath = path.join(TEMP_DIR, file);
491
+ const stats = fs.statSync(filePath);
492
+
493
+ if (stats.mtimeMs < oneHourAgo) {
494
+ fs.unlinkSync(filePath);
495
+ console.log(`[dingtalk] Cleaned up old media: ${filePath}`);
496
+ }
497
+ }
498
+ } catch (err) {
499
+ console.warn(`[dingtalk] Error cleaning up media:`, err);
500
+ }
501
+ }
502
+
503
+ /** @deprecated Use cleanupOldMedia() instead */
504
+ export const cleanupOldPictures = cleanupOldMedia;
505
+
506
+ /**
507
+ * Upload a file to DingTalk and get media_id
508
+ * Uses the legacy oapi media upload endpoint (the new API doesn't exist)
509
+ */
510
+ export async function uploadMediaFile(params: {
511
+ clientId: string;
512
+ clientSecret: string;
513
+ robotCode: string;
514
+ fileBuffer: Buffer;
515
+ fileName: string;
516
+ fileType?: 'image' | 'voice' | 'video' | 'file';
517
+ }): Promise<{ mediaId?: string; error?: string }> {
518
+ try {
519
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
520
+
521
+ // Build multipart form data manually
522
+ const boundary = `----DingTalkBoundary${Date.now()}`;
523
+ const fileType = params.fileType || 'file';
524
+
525
+ // Construct multipart body - oapi uses "media" as the file field name
526
+ const parts: Buffer[] = [];
527
+
528
+ // Add file field
529
+ parts.push(Buffer.from(
530
+ `--${boundary}\r\n` +
531
+ `Content-Disposition: form-data; name="media"; filename="${params.fileName}"\r\n` +
532
+ `Content-Type: application/octet-stream\r\n\r\n`
533
+ ));
534
+ parts.push(params.fileBuffer);
535
+ parts.push(Buffer.from('\r\n'));
536
+
537
+ // End boundary
538
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
539
+
540
+ const body = Buffer.concat(parts);
541
+
542
+ // Use legacy oapi endpoint - the new v1.0 API doesn't have this endpoint
543
+ const url = `https://oapi.dingtalk.com/media/upload?access_token=${token}&type=${fileType}`;
544
+
545
+ return new Promise((resolve) => {
546
+ const urlObj = new URL(url);
547
+ const req = https.request(urlObj, {
548
+ method: 'POST',
549
+ headers: {
550
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
551
+ 'Content-Length': body.length,
552
+ },
553
+ timeout: 60000, // 60 second timeout for upload
554
+ family: 4,
555
+ }, (res) => {
556
+ let buf = '';
557
+ res.on('data', (chunk: any) => { buf += chunk; });
558
+ res.on('end', () => {
559
+ try {
560
+ const json = JSON.parse(buf);
561
+ // oapi returns media_id (with underscore), not mediaId
562
+ if (json.media_id) {
563
+ console.log(`[dingtalk] File uploaded successfully: media_id=${json.media_id}`);
564
+ resolve({ mediaId: json.media_id });
565
+ } else if (json.mediaId) {
566
+ console.log(`[dingtalk] File uploaded successfully: mediaId=${json.mediaId}`);
567
+ resolve({ mediaId: json.mediaId });
568
+ } else {
569
+ console.warn(`[dingtalk] File upload failed:`, json);
570
+ resolve({ error: json.errmsg || json.message || 'Upload failed' });
571
+ }
572
+ } catch {
573
+ resolve({ error: `Invalid response: ${buf}` });
574
+ }
575
+ });
576
+ });
577
+
578
+ req.on('error', (err) => {
579
+ console.warn(`[dingtalk] File upload error:`, err);
580
+ resolve({ error: String(err) });
581
+ });
582
+
583
+ req.on('timeout', () => {
584
+ req.destroy();
585
+ resolve({ error: 'Upload timeout' });
586
+ });
587
+
588
+ req.write(body);
589
+ req.end();
590
+ });
591
+ } catch (err) {
592
+ console.warn(`[dingtalk] Error uploading file:`, err);
593
+ return { error: String(err) };
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Send a file message via REST API
599
+ * Requires mediaId from uploadMediaFile
600
+ */
601
+ export async function sendFileMessage(params: {
602
+ clientId: string;
603
+ clientSecret: string;
604
+ robotCode: string;
605
+ userId?: string;
606
+ conversationId?: string;
607
+ mediaId: string;
608
+ fileName: string;
609
+ }): Promise<{ ok: boolean; error?: string }> {
610
+ try {
611
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
612
+ const headers = { 'x-acs-dingtalk-access-token': token };
613
+
614
+ const msgParam = JSON.stringify({
615
+ mediaId: params.mediaId,
616
+ fileName: params.fileName,
617
+ fileType: getFileExtension(params.fileName),
618
+ });
619
+
620
+ if (params.userId) {
621
+ // Send to DM
622
+ const res = await jsonPost(
623
+ `${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
624
+ {
625
+ robotCode: params.robotCode,
626
+ userIds: [params.userId],
627
+ msgKey: 'sampleFile',
628
+ msgParam,
629
+ },
630
+ headers,
631
+ );
632
+
633
+ if (res?.code || res?.errcode) {
634
+ console.warn(`[dingtalk] File send (DM) failed:`, res);
635
+ return { ok: false, error: res.message || res.errmsg };
636
+ }
637
+
638
+ console.log(`[dingtalk] File sent to DM: ${params.fileName}`);
639
+ return { ok: true };
640
+ }
641
+
642
+ if (params.conversationId) {
643
+ // Send to group
644
+ const res = await jsonPost(
645
+ `${DINGTALK_API_BASE}/robot/groupMessages/send`,
646
+ {
647
+ robotCode: params.robotCode,
648
+ openConversationId: params.conversationId,
649
+ msgKey: 'sampleFile',
650
+ msgParam,
651
+ },
652
+ headers,
653
+ );
654
+
655
+ if (res?.code || res?.errcode) {
656
+ console.warn(`[dingtalk] File send (group) failed:`, res);
657
+ return { ok: false, error: res.message || res.errmsg };
658
+ }
659
+
660
+ console.log(`[dingtalk] File sent to group: ${params.fileName}`);
661
+ return { ok: true };
662
+ }
663
+
664
+ return { ok: false, error: 'Either userId or conversationId required' };
665
+ } catch (err) {
666
+ console.warn(`[dingtalk] Error sending file:`, err);
667
+ return { ok: false, error: String(err) };
668
+ }
669
+ }
670
+
671
+ /** Get file extension from filename */
672
+ function getFileExtension(fileName: string): string {
673
+ const ext = path.extname(fileName).toLowerCase();
674
+ return ext ? ext.slice(1) : 'bin';
675
+ }
676
+
677
+ /**
678
+ * Convert text to a markdown file buffer with UTF-8 BOM
679
+ * BOM is needed for proper Chinese display in DingTalk/Windows
680
+ */
681
+ export function textToMarkdownFile(text: string, title?: string): { buffer: Buffer; fileName: string } {
682
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
683
+ const fileName = title ? `${title}.md` : `reply_${timestamp}.md`;
684
+
685
+ // Add UTF-8 BOM for proper Chinese display
686
+ const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
687
+ const content = Buffer.from(text, 'utf-8');
688
+ const buffer = Buffer.concat([bom, content]);
689
+
690
+ return { buffer, fileName };
691
+ }
692
+
693
+ // ============================================================================
694
+ // Message Recall (撤回) APIs
695
+ // ============================================================================
696
+
697
+ /**
698
+ * Send a DM message and return the processQueryKey for later recall
699
+ * This is an enhanced version that returns the message ID
700
+ */
701
+ export async function sendDMMessageWithKey(params: {
702
+ clientId: string;
703
+ clientSecret: string;
704
+ robotCode: string;
705
+ userId: string;
706
+ text: string;
707
+ format?: 'text' | 'markdown';
708
+ }): Promise<{ ok: boolean; processQueryKey?: string; error?: string }> {
709
+ try {
710
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
711
+ const headers = { "x-acs-dingtalk-access-token": token };
712
+
713
+ const useMarkdown = params.format !== 'text';
714
+ const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
715
+ const msgParam = useMarkdown
716
+ ? JSON.stringify({ title: 'AI', text: params.text })
717
+ : JSON.stringify({ content: params.text });
718
+
719
+ const res = await jsonPost(
720
+ `${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
721
+ {
722
+ robotCode: params.robotCode,
723
+ userIds: [params.userId],
724
+ msgKey,
725
+ msgParam,
726
+ },
727
+ headers,
728
+ );
729
+
730
+ if (res?.code || (res?.errcode && res.errcode !== 0)) {
731
+ console.warn(`[dingtalk] DM send error:`, res);
732
+ return { ok: false, error: res.message || res.errmsg };
733
+ }
734
+
735
+ return {
736
+ ok: true,
737
+ processQueryKey: res.processQueryKey
738
+ };
739
+ } catch (err) {
740
+ console.warn(`[dingtalk] Error sending DM:`, err);
741
+ return { ok: false, error: String(err) };
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Send a group message and return the processQueryKey for later recall
747
+ */
748
+ export async function sendGroupMessageWithKey(params: {
749
+ clientId: string;
750
+ clientSecret: string;
751
+ robotCode: string;
752
+ conversationId: string;
753
+ text: string;
754
+ format?: 'text' | 'markdown';
755
+ }): Promise<{ ok: boolean; processQueryKey?: string; error?: string }> {
756
+ try {
757
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
758
+ const headers = { "x-acs-dingtalk-access-token": token };
759
+
760
+ const useMarkdown = params.format !== 'text';
761
+ const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
762
+ const msgParam = useMarkdown
763
+ ? JSON.stringify({ title: 'AI', text: params.text })
764
+ : JSON.stringify({ content: params.text });
765
+
766
+ const res = await jsonPost(
767
+ `${DINGTALK_API_BASE}/robot/groupMessages/send`,
768
+ {
769
+ robotCode: params.robotCode,
770
+ openConversationId: params.conversationId,
771
+ msgKey,
772
+ msgParam,
773
+ },
774
+ headers,
775
+ );
776
+
777
+ if (res?.code || (res?.errcode && res.errcode !== 0)) {
778
+ console.warn(`[dingtalk] Group send error:`, res);
779
+ return { ok: false, error: res.message || res.errmsg };
780
+ }
781
+
782
+ return {
783
+ ok: true,
784
+ processQueryKey: res.processQueryKey
785
+ };
786
+ } catch (err) {
787
+ console.warn(`[dingtalk] Error sending group message:`, err);
788
+ return { ok: false, error: String(err) };
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Recall (撤回) DM messages
794
+ * Note: This is a "silent recall" - the message just disappears without notification
795
+ */
796
+ export async function recallDMMessages(params: {
797
+ clientId: string;
798
+ clientSecret: string;
799
+ robotCode: string;
800
+ userId: string;
801
+ processQueryKeys: string[];
802
+ }): Promise<{ ok: boolean; successKeys?: string[]; failedKeys?: Record<string, string>; error?: string }> {
803
+ try {
804
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
805
+ const headers = { "x-acs-dingtalk-access-token": token };
806
+
807
+ const res = await jsonPost(
808
+ `${DINGTALK_API_BASE}/robot/otoMessages/batchRecall`,
809
+ {
810
+ robotCode: params.robotCode,
811
+ chatBotUserId: params.userId,
812
+ processQueryKeys: params.processQueryKeys,
813
+ },
814
+ headers,
815
+ );
816
+
817
+ if (res?.code || (res?.errcode && res.errcode !== 0)) {
818
+ console.warn(`[dingtalk] DM recall error:`, res);
819
+ return { ok: false, error: res.message || res.errmsg };
820
+ }
821
+
822
+ return {
823
+ ok: true,
824
+ successKeys: res.successResult,
825
+ failedKeys: res.failedResult,
826
+ };
827
+ } catch (err) {
828
+ console.warn(`[dingtalk] Error recalling DM:`, err);
829
+ return { ok: false, error: String(err) };
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Recall (撤回) group messages
835
+ */
836
+ export async function recallGroupMessages(params: {
837
+ clientId: string;
838
+ clientSecret: string;
839
+ robotCode: string;
840
+ conversationId: string;
841
+ processQueryKeys: string[];
842
+ }): Promise<{ ok: boolean; successKeys?: string[]; failedKeys?: Record<string, string>; error?: string }> {
843
+ try {
844
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
845
+ const headers = { "x-acs-dingtalk-access-token": token };
846
+
847
+ const res = await jsonPost(
848
+ `${DINGTALK_API_BASE}/robot/groupMessages/recall`,
849
+ {
850
+ robotCode: params.robotCode,
851
+ openConversationId: params.conversationId,
852
+ processQueryKeys: params.processQueryKeys,
853
+ },
854
+ headers,
855
+ );
856
+
857
+ if (res?.code || (res?.errcode && res.errcode !== 0)) {
858
+ console.warn(`[dingtalk] Group recall error:`, res);
859
+ return { ok: false, error: res.message || res.errmsg };
860
+ }
861
+
862
+ return {
863
+ ok: true,
864
+ successKeys: res.successResult,
865
+ failedKeys: res.failedResult,
866
+ };
867
+ } catch (err) {
868
+ console.warn(`[dingtalk] Error recalling group message:`, err);
869
+ return { ok: false, error: String(err) };
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Typing indicator helper - sends a "thinking" message that will be recalled
875
+ * Returns a cleanup function to recall the message
876
+ */
877
+ export async function sendTypingIndicator(params: {
878
+ clientId: string;
879
+ clientSecret: string;
880
+ robotCode: string;
881
+ userId?: string;
882
+ conversationId?: string;
883
+ message?: string;
884
+ }): Promise<{ cleanup: () => Promise<void>; error?: string }> {
885
+ const typingMessage = params.message || "⏳ 思考中...";
886
+
887
+ try {
888
+ if (params.userId) {
889
+ const result = await sendDMMessageWithKey({
890
+ clientId: params.clientId,
891
+ clientSecret: params.clientSecret,
892
+ robotCode: params.robotCode,
893
+ userId: params.userId,
894
+ text: typingMessage,
895
+ format: 'text',
896
+ });
897
+
898
+ if (!result.ok || !result.processQueryKey) {
899
+ return {
900
+ cleanup: async () => {},
901
+ error: result.error || "Failed to send typing indicator"
902
+ };
903
+ }
904
+
905
+ const processQueryKey = result.processQueryKey;
906
+ return {
907
+ cleanup: async () => {
908
+ await recallDMMessages({
909
+ clientId: params.clientId,
910
+ clientSecret: params.clientSecret,
911
+ robotCode: params.robotCode,
912
+ userId: params.userId!,
913
+ processQueryKeys: [processQueryKey],
914
+ });
915
+ }
916
+ };
917
+ }
918
+
919
+ if (params.conversationId) {
920
+ const result = await sendGroupMessageWithKey({
921
+ clientId: params.clientId,
922
+ clientSecret: params.clientSecret,
923
+ robotCode: params.robotCode,
924
+ conversationId: params.conversationId,
925
+ text: typingMessage,
926
+ format: 'text',
927
+ });
928
+
929
+ if (!result.ok || !result.processQueryKey) {
930
+ return {
931
+ cleanup: async () => {},
932
+ error: result.error || "Failed to send typing indicator"
933
+ };
934
+ }
935
+
936
+ const processQueryKey = result.processQueryKey;
937
+ return {
938
+ cleanup: async () => {
939
+ await recallGroupMessages({
940
+ clientId: params.clientId,
941
+ clientSecret: params.clientSecret,
942
+ robotCode: params.robotCode,
943
+ conversationId: params.conversationId!,
944
+ processQueryKeys: [processQueryKey],
945
+ });
946
+ }
947
+ };
948
+ }
949
+
950
+ return { cleanup: async () => {}, error: "Either userId or conversationId required" };
951
+ } catch (err) {
952
+ console.warn(`[dingtalk] Error sending typing indicator:`, err);
953
+ return { cleanup: async () => {}, error: String(err) };
954
+ }
955
+ }