@adongguo/dingtalk 0.1.3

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/media.ts ADDED
@@ -0,0 +1,608 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import type { DWClient } from "dingtalk-stream";
3
+ import type { DingTalkConfig } from "./types.js";
4
+ import fs from "fs";
5
+ import path from "path";
6
+
7
+ // ============ Image Post-Processing (Local Path Upload) ============
8
+
9
+ /**
10
+ * Regex to match markdown images with local file paths:
11
+ * - ![alt](file:///path/to/image.jpg)
12
+ * - ![alt](MEDIA:/var/folders/xxx.jpg)
13
+ * - ![alt](attachment:///path.jpg)
14
+ * - ![alt](/tmp/xxx.jpg)
15
+ * - ![alt](/var/folders/xxx.jpg)
16
+ * - ![alt](/Users/xxx/photo.jpg)
17
+ */
18
+ const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\(((?:file:\/\/\/|MEDIA:|attachment:\/\/\/)[^\s)]+|\/(?:tmp|var|private|Users)[^\s)]+)\)/g;
19
+
20
+ /**
21
+ * Regex to match bare local image paths (not in markdown syntax):
22
+ * - `/var/folders/.../screenshot.png`
23
+ * - `/tmp/image.jpg`
24
+ * - `/Users/xxx/photo.png`
25
+ * Supports backtick wrapping: `path`
26
+ */
27
+ const BARE_IMAGE_PATH_RE = /`?(\/(?:tmp|var|private|Users)\/[^\s`'",)]+\.(?:png|jpg|jpeg|gif|bmp|webp))`?/gi;
28
+
29
+ interface Logger {
30
+ info?: (msg: string) => void;
31
+ warn?: (msg: string) => void;
32
+ error?: (msg: string) => void;
33
+ }
34
+
35
+ /**
36
+ * Build system prompt instructing LLM to use local file paths for images.
37
+ * This guides the LLM to output markdown with local paths instead of URLs.
38
+ */
39
+ export function buildMediaSystemPrompt(): string {
40
+ return `## 钉钉图片显示规则
41
+
42
+ 你正在钉钉中与用户对话。显示图片时,直接使用本地文件路径,系统会自动上传处理。
43
+
44
+ ### 正确方式
45
+ \`\`\`markdown
46
+ ![描述](file:///path/to/image.jpg)
47
+ ![描述](/tmp/screenshot.png)
48
+ ![描述](/Users/xxx/photo.jpg)
49
+ \`\`\`
50
+
51
+ ### 禁止
52
+ - 不要自己执行 curl 上传
53
+ - 不要猜测或构造 URL
54
+ - 不要使用 https://oapi.dingtalk.com/... 这类地址
55
+
56
+ 直接输出本地路径即可,系统会自动上传到钉钉。`;
57
+ }
58
+
59
+ /**
60
+ * Convert file:// MEDIA: attachment:// prefixes to absolute path.
61
+ */
62
+ function toLocalPath(raw: string): string {
63
+ let filePath = raw;
64
+ if (filePath.startsWith("file://")) {
65
+ filePath = filePath.replace("file://", "");
66
+ } else if (filePath.startsWith("MEDIA:")) {
67
+ filePath = filePath.replace("MEDIA:", "");
68
+ } else if (filePath.startsWith("attachment://")) {
69
+ filePath = filePath.replace("attachment://", "");
70
+ }
71
+
72
+ // Decode URL-encoded paths (e.g. %E5%9B%BE -> 图)
73
+ try {
74
+ filePath = decodeURIComponent(filePath);
75
+ } catch {
76
+ // Keep original if decode fails
77
+ }
78
+ return filePath;
79
+ }
80
+
81
+ /**
82
+ * Get oapi access token for media upload.
83
+ * Uses dingtalk-stream's internal getAccessToken if available.
84
+ */
85
+ export async function getOapiAccessToken(
86
+ config: DingTalkConfig,
87
+ client?: DWClient,
88
+ ): Promise<string | null> {
89
+ try {
90
+ // Try to get token from client first (dingtalk-stream SDK)
91
+ if (client) {
92
+ try {
93
+ const token = await (client as unknown as { getAccessToken: () => Promise<string> }).getAccessToken();
94
+ return token;
95
+ } catch {
96
+ // Fall through to manual token request
97
+ }
98
+ }
99
+
100
+ // Manual token request
101
+ const response = await fetch("https://oapi.dingtalk.com/gettoken", {
102
+ method: "GET",
103
+ headers: { "Content-Type": "application/json" },
104
+ });
105
+
106
+ if (!response.ok) {
107
+ return null;
108
+ }
109
+
110
+ const data = (await response.json()) as { errcode?: number; access_token?: string };
111
+ if (data.errcode === 0 && data.access_token) {
112
+ return data.access_token;
113
+ }
114
+ return null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Upload local file to DingTalk via oapi media upload endpoint.
122
+ * Returns media_id if successful.
123
+ */
124
+ async function uploadToDingTalk(filePath: string, oapiToken: string, log?: Logger): Promise<string | null> {
125
+ try {
126
+ const absPath = toLocalPath(filePath);
127
+
128
+ if (!fs.existsSync(absPath)) {
129
+ log?.warn?.(`[DingTalk][Media] File not found: ${absPath}`);
130
+ return null;
131
+ }
132
+
133
+ const fileStream = fs.createReadStream(absPath);
134
+ const fileName = path.basename(absPath);
135
+
136
+ // Use FormData for multipart upload
137
+ const formData = new FormData();
138
+ const blob = new Blob([await fs.promises.readFile(absPath)]);
139
+ formData.append("media", blob, fileName);
140
+
141
+ log?.info?.(`[DingTalk][Media] Uploading image: ${absPath}`);
142
+
143
+ const response = await fetch(
144
+ `https://oapi.dingtalk.com/media/upload?access_token=${oapiToken}&type=image`,
145
+ {
146
+ method: "POST",
147
+ body: formData,
148
+ },
149
+ );
150
+
151
+ if (!response.ok) {
152
+ const text = await response.text();
153
+ log?.warn?.(`[DingTalk][Media] Upload failed: ${response.status} ${text}`);
154
+ return null;
155
+ }
156
+
157
+ const data = (await response.json()) as { media_id?: string };
158
+ const mediaId = data.media_id;
159
+
160
+ if (mediaId) {
161
+ log?.info?.(`[DingTalk][Media] Upload success: media_id=${mediaId}`);
162
+ return mediaId;
163
+ }
164
+
165
+ log?.warn?.(`[DingTalk][Media] Upload returned no media_id: ${JSON.stringify(data)}`);
166
+ return null;
167
+ } catch (err: unknown) {
168
+ const errMsg = err instanceof Error ? err.message : String(err);
169
+ log?.error?.(`[DingTalk][Media] Upload failed: ${errMsg}`);
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Process content by scanning for local image paths and uploading them to DingTalk.
176
+ * Replaces local paths with media_id in the output.
177
+ *
178
+ * @param content - Content containing potential local image paths
179
+ * @param oapiToken - DingTalk oapi access token
180
+ * @param log - Optional logger
181
+ * @returns Content with local paths replaced by media_ids
182
+ */
183
+ export async function processLocalImages(content: string, oapiToken: string | null, log?: Logger): Promise<string> {
184
+ if (!oapiToken) {
185
+ log?.warn?.(`[DingTalk][Media] No oapiToken, skipping image post-processing`);
186
+ return content;
187
+ }
188
+
189
+ let result = content;
190
+
191
+ // Step 1: Match markdown images ![alt](path)
192
+ const mdMatches = [...content.matchAll(LOCAL_IMAGE_RE)];
193
+ if (mdMatches.length > 0) {
194
+ log?.info?.(`[DingTalk][Media] Found ${mdMatches.length} markdown images, uploading...`);
195
+ for (const match of mdMatches) {
196
+ const [fullMatch, alt, rawPath] = match;
197
+ const mediaId = await uploadToDingTalk(rawPath, oapiToken, log);
198
+ if (mediaId) {
199
+ result = result.replace(fullMatch, `![${alt}](${mediaId})`);
200
+ }
201
+ }
202
+ }
203
+
204
+ // Step 2: Match bare local paths (e.g. /var/folders/.../xxx.png)
205
+ // Filter out paths already wrapped in markdown
206
+ const bareMatches = [...result.matchAll(BARE_IMAGE_PATH_RE)];
207
+ const newBareMatches = bareMatches.filter((m) => {
208
+ const idx = m.index!;
209
+ const before = result.slice(Math.max(0, idx - 10), idx);
210
+ return !before.includes("](");
211
+ });
212
+
213
+ if (newBareMatches.length > 0) {
214
+ log?.info?.(`[DingTalk][Media] Found ${newBareMatches.length} bare image paths, uploading...`);
215
+ // Replace from end to avoid index shifting
216
+ for (const match of newBareMatches.reverse()) {
217
+ const [fullMatch, rawPath] = match;
218
+ log?.info?.(`[DingTalk][Media] Bare image: "${fullMatch}" -> path="${rawPath}"`);
219
+ const mediaId = await uploadToDingTalk(rawPath, oapiToken, log);
220
+ if (mediaId) {
221
+ const replacement = `![](${mediaId})`;
222
+ result = result.slice(0, match.index!) + result.slice(match.index!).replace(fullMatch, replacement);
223
+ log?.info?.(`[DingTalk][Media] Replaced: ${replacement}`);
224
+ }
225
+ }
226
+ }
227
+
228
+ if (mdMatches.length === 0 && newBareMatches.length === 0) {
229
+ log?.info?.(`[DingTalk][Media] No local image paths detected`);
230
+ }
231
+
232
+ return result;
233
+ }
234
+
235
+ export type DownloadMediaResult = {
236
+ buffer: Buffer;
237
+ contentType?: string;
238
+ fileName?: string;
239
+ };
240
+
241
+ export type UploadMediaResult = {
242
+ mediaId: string;
243
+ };
244
+
245
+ export type SendMediaResult = {
246
+ conversationId: string;
247
+ processQueryKey?: string;
248
+ };
249
+
250
+ /**
251
+ * Download media from DingTalk message using downloadCode.
252
+ * Note: This requires OpenAPI access token.
253
+ */
254
+ export async function downloadMediaDingTalk(params: {
255
+ cfg: ClawdbotConfig;
256
+ downloadCode: string;
257
+ robotCode?: string;
258
+ client?: DWClient;
259
+ }): Promise<DownloadMediaResult | null> {
260
+ const { cfg, downloadCode, robotCode, client } = params;
261
+ const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
262
+ if (!dingtalkCfg) {
263
+ throw new Error("DingTalk channel not configured");
264
+ }
265
+
266
+ if (!client) {
267
+ // Cannot download without client for access token
268
+ return null;
269
+ }
270
+
271
+ try {
272
+ const accessToken = await client.getAccessToken();
273
+
274
+ // DingTalk media download API
275
+ // https://api.dingtalk.com/v1.0/robot/messageFiles/download
276
+ const response = await fetch("https://api.dingtalk.com/v1.0/robot/messageFiles/download", {
277
+ method: "POST",
278
+ headers: {
279
+ "Content-Type": "application/json",
280
+ "x-acs-dingtalk-access-token": accessToken,
281
+ },
282
+ body: JSON.stringify({
283
+ downloadCode,
284
+ robotCode: robotCode || dingtalkCfg.robotCode,
285
+ }),
286
+ });
287
+
288
+ if (!response.ok) {
289
+ const text = await response.text();
290
+ throw new Error(`DingTalk media download failed: ${response.status} ${text}`);
291
+ }
292
+
293
+ const result = await response.json() as { downloadUrl?: string };
294
+
295
+ if (!result.downloadUrl) {
296
+ throw new Error("DingTalk media download failed: no downloadUrl returned");
297
+ }
298
+
299
+ // Download the actual file from the URL
300
+ const fileResponse = await fetch(result.downloadUrl);
301
+ if (!fileResponse.ok) {
302
+ throw new Error(`Failed to download file from URL: ${fileResponse.status}`);
303
+ }
304
+
305
+ const buffer = Buffer.from(await fileResponse.arrayBuffer());
306
+ const contentType = fileResponse.headers.get("content-type") || undefined;
307
+
308
+ return { buffer, contentType };
309
+ } catch (err) {
310
+ console.error(`DingTalk media download error: ${String(err)}`);
311
+ return null;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Upload media to DingTalk via OpenAPI.
317
+ * This can be used for sending images/files in messages.
318
+ */
319
+ export async function uploadMediaDingTalk(params: {
320
+ cfg: ClawdbotConfig;
321
+ buffer: Buffer;
322
+ fileName: string;
323
+ mediaType: "image" | "file" | "voice";
324
+ client?: DWClient;
325
+ }): Promise<UploadMediaResult | null> {
326
+ const { cfg, buffer, fileName, mediaType, client } = params;
327
+ const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
328
+ if (!dingtalkCfg) {
329
+ throw new Error("DingTalk channel not configured");
330
+ }
331
+
332
+ if (!client) {
333
+ return null;
334
+ }
335
+
336
+ try {
337
+ const accessToken = await client.getAccessToken();
338
+
339
+ // DingTalk media upload uses multipart form data
340
+ // https://api.dingtalk.com/v1.0/robot/messageFiles/upload
341
+ const formData = new FormData();
342
+ // Convert Buffer to ArrayBuffer for Blob compatibility
343
+ const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
344
+ const blob = new Blob([arrayBuffer], { type: "application/octet-stream" });
345
+ formData.append("file", blob, fileName);
346
+ formData.append("type", mediaType);
347
+ formData.append("robotCode", dingtalkCfg.robotCode || "");
348
+
349
+ const response = await fetch("https://api.dingtalk.com/v1.0/robot/messageFiles/upload", {
350
+ method: "POST",
351
+ headers: {
352
+ "x-acs-dingtalk-access-token": accessToken,
353
+ },
354
+ body: formData,
355
+ });
356
+
357
+ if (!response.ok) {
358
+ const text = await response.text();
359
+ throw new Error(`DingTalk media upload failed: ${response.status} ${text}`);
360
+ }
361
+
362
+ const result = await response.json() as { mediaId?: string };
363
+
364
+ if (!result.mediaId) {
365
+ throw new Error("DingTalk media upload failed: no mediaId returned");
366
+ }
367
+
368
+ return { mediaId: result.mediaId };
369
+ } catch (err) {
370
+ console.error(`DingTalk media upload error: ${String(err)}`);
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Send an image via sessionWebhook using markdown with image URL.
377
+ * Note: DingTalk sessionWebhook has limited support for images.
378
+ * For better image support, use OpenAPI.
379
+ */
380
+ export async function sendImageDingTalk(params: {
381
+ cfg: ClawdbotConfig;
382
+ sessionWebhook: string;
383
+ imageUrl: string;
384
+ title?: string;
385
+ client?: DWClient;
386
+ }): Promise<SendMediaResult> {
387
+ const { sessionWebhook, imageUrl, title, client } = params;
388
+
389
+ let accessToken: string | undefined;
390
+ if (client) {
391
+ try {
392
+ accessToken = await client.getAccessToken();
393
+ } catch {
394
+ // Proceed without access token
395
+ }
396
+ }
397
+
398
+ const headers: Record<string, string> = {
399
+ "Content-Type": "application/json",
400
+ };
401
+
402
+ if (accessToken) {
403
+ headers["x-acs-dingtalk-access-token"] = accessToken;
404
+ }
405
+
406
+ // Use markdown format to embed image
407
+ const message = {
408
+ msgtype: "markdown",
409
+ markdown: {
410
+ title: title || "Image",
411
+ text: `![image](${imageUrl})`,
412
+ },
413
+ };
414
+
415
+ const response = await fetch(sessionWebhook, {
416
+ method: "POST",
417
+ headers,
418
+ body: JSON.stringify(message),
419
+ });
420
+
421
+ if (!response.ok) {
422
+ const text = await response.text();
423
+ throw new Error(`DingTalk image send failed: ${response.status} ${text}`);
424
+ }
425
+
426
+ const result = await response.json() as { errcode?: number; errmsg?: string; processQueryKey?: string };
427
+
428
+ if (result.errcode && result.errcode !== 0) {
429
+ throw new Error(`DingTalk image send failed: ${result.errmsg || `code ${result.errcode}`}`);
430
+ }
431
+
432
+ return {
433
+ conversationId: "",
434
+ processQueryKey: result.processQueryKey,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Send a file link via sessionWebhook.
440
+ * Note: DingTalk sessionWebhook doesn't support file attachments directly.
441
+ * This sends a link message pointing to the file URL.
442
+ */
443
+ export async function sendFileDingTalk(params: {
444
+ cfg: ClawdbotConfig;
445
+ sessionWebhook: string;
446
+ fileUrl: string;
447
+ fileName: string;
448
+ client?: DWClient;
449
+ }): Promise<SendMediaResult> {
450
+ const { sessionWebhook, fileUrl, fileName, client } = params;
451
+
452
+ let accessToken: string | undefined;
453
+ if (client) {
454
+ try {
455
+ accessToken = await client.getAccessToken();
456
+ } catch {
457
+ // Proceed without access token
458
+ }
459
+ }
460
+
461
+ const headers: Record<string, string> = {
462
+ "Content-Type": "application/json",
463
+ };
464
+
465
+ if (accessToken) {
466
+ headers["x-acs-dingtalk-access-token"] = accessToken;
467
+ }
468
+
469
+ // Use link format for files
470
+ const message = {
471
+ msgtype: "link",
472
+ link: {
473
+ title: fileName,
474
+ text: `File: ${fileName}`,
475
+ messageUrl: fileUrl,
476
+ picUrl: "",
477
+ },
478
+ };
479
+
480
+ const response = await fetch(sessionWebhook, {
481
+ method: "POST",
482
+ headers,
483
+ body: JSON.stringify(message),
484
+ });
485
+
486
+ if (!response.ok) {
487
+ const text = await response.text();
488
+ throw new Error(`DingTalk file send failed: ${response.status} ${text}`);
489
+ }
490
+
491
+ const result = await response.json() as { errcode?: number; errmsg?: string; processQueryKey?: string };
492
+
493
+ if (result.errcode && result.errcode !== 0) {
494
+ throw new Error(`DingTalk file send failed: ${result.errmsg || `code ${result.errcode}`}`);
495
+ }
496
+
497
+ return {
498
+ conversationId: "",
499
+ processQueryKey: result.processQueryKey,
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Helper to detect file type from extension
505
+ */
506
+ export function detectFileType(
507
+ fileName: string,
508
+ ): "image" | "file" | "voice" {
509
+ const ext = path.extname(fileName).toLowerCase();
510
+ const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
511
+ const voiceExts = [".opus", ".ogg", ".mp3", ".wav", ".m4a"];
512
+
513
+ if (imageExts.includes(ext)) {
514
+ return "image";
515
+ } else if (voiceExts.includes(ext)) {
516
+ return "voice";
517
+ }
518
+ return "file";
519
+ }
520
+
521
+ /**
522
+ * Check if a string is a local file path (not a URL)
523
+ */
524
+ function isLocalPath(urlOrPath: string): boolean {
525
+ if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
526
+ return true;
527
+ }
528
+ try {
529
+ const url = new URL(urlOrPath);
530
+ return url.protocol === "file:";
531
+ } catch {
532
+ return true;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Send media via sessionWebhook (limited support)
538
+ */
539
+ export async function sendMediaDingTalk(params: {
540
+ cfg: ClawdbotConfig;
541
+ sessionWebhook: string;
542
+ mediaUrl?: string;
543
+ mediaBuffer?: Buffer;
544
+ fileName?: string;
545
+ client?: DWClient;
546
+ }): Promise<SendMediaResult> {
547
+ const { cfg, sessionWebhook, mediaUrl, mediaBuffer, fileName, client } = params;
548
+
549
+ let buffer: Buffer | undefined;
550
+ let name: string;
551
+ let url: string | undefined;
552
+
553
+ if (mediaBuffer) {
554
+ buffer = mediaBuffer;
555
+ name = fileName ?? "file";
556
+ } else if (mediaUrl) {
557
+ if (isLocalPath(mediaUrl)) {
558
+ const filePath = mediaUrl.startsWith("~")
559
+ ? mediaUrl.replace("~", process.env.HOME ?? "")
560
+ : mediaUrl.replace("file://", "");
561
+
562
+ if (!fs.existsSync(filePath)) {
563
+ throw new Error(`Local file not found: ${filePath}`);
564
+ }
565
+ buffer = fs.readFileSync(filePath);
566
+ name = fileName ?? path.basename(filePath);
567
+ } else {
568
+ // Remote URL - can send directly as link
569
+ url = mediaUrl;
570
+ name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
571
+ }
572
+ } else {
573
+ throw new Error("Either mediaUrl or mediaBuffer must be provided");
574
+ }
575
+
576
+ const fileType = detectFileType(name);
577
+
578
+ if (url) {
579
+ // Send as link/image depending on type
580
+ if (fileType === "image") {
581
+ return sendImageDingTalk({ cfg, sessionWebhook, imageUrl: url, title: name, client });
582
+ } else {
583
+ return sendFileDingTalk({ cfg, sessionWebhook, fileUrl: url, fileName: name, client });
584
+ }
585
+ }
586
+
587
+ // For local files, we need to upload first
588
+ if (buffer && client) {
589
+ const uploadResult = await uploadMediaDingTalk({
590
+ cfg,
591
+ buffer,
592
+ fileName: name,
593
+ mediaType: fileType,
594
+ client,
595
+ });
596
+
597
+ if (uploadResult) {
598
+ // Note: mediaId usage depends on specific DingTalk API
599
+ // For now, return a result indicating upload was successful
600
+ return {
601
+ conversationId: "",
602
+ processQueryKey: uploadResult.mediaId,
603
+ };
604
+ }
605
+ }
606
+
607
+ throw new Error("Unable to send media: upload failed or no client available");
608
+ }