@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/LICENSE +21 -0
- package/README.md +247 -0
- package/clawdbot.plugin.json +9 -0
- package/index.ts +86 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +60 -0
- package/src/accounts.ts +49 -0
- package/src/ai-card.ts +341 -0
- package/src/bot.ts +403 -0
- package/src/channel.ts +220 -0
- package/src/client.ts +49 -0
- package/src/config-schema.ts +119 -0
- package/src/directory.ts +90 -0
- package/src/gateway-stream.ts +159 -0
- package/src/media.ts +608 -0
- package/src/monitor.ts +127 -0
- package/src/onboarding.ts +355 -0
- package/src/outbound.ts +46 -0
- package/src/policy.ts +92 -0
- package/src/probe.ts +41 -0
- package/src/reactions.ts +64 -0
- package/src/reply-dispatcher.ts +167 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +314 -0
- package/src/session.ts +144 -0
- package/src/streaming-handler.ts +298 -0
- package/src/targets.ts +56 -0
- package/src/types.ts +198 -0
- package/src/typing.ts +36 -0
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
|
+
* - 
|
|
12
|
+
* - 
|
|
13
|
+
* - 
|
|
14
|
+
* - 
|
|
15
|
+
* - 
|
|
16
|
+
* - 
|
|
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
|
+

|
|
47
|
+

|
|
48
|
+

|
|
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 
|
|
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, ``);
|
|
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 = ``;
|
|
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: ``,
|
|
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
|
+
}
|