openclaw-channel-dmwork 0.5.6 → 0.5.7
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/package.json +1 -1
- package/src/actions.test.ts +19 -40
- package/src/api-fetch.test.ts +138 -0
- package/src/api-fetch.ts +31 -7
- package/src/channel.ts +144 -69
- package/src/inbound.ts +91 -49
package/package.json
CHANGED
package/src/actions.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { ChannelType } from "./types.js";
|
|
3
3
|
|
|
4
|
+
// Mock uploadAndSendMedia — the streaming COS upload uses its own SDK internals
|
|
5
|
+
// that can't be tested via fetch mocks alone. Upload logic is tested in inbound.test.ts.
|
|
6
|
+
vi.mock("./inbound.js", () => ({
|
|
7
|
+
uploadAndSendMedia: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
}));
|
|
9
|
+
|
|
4
10
|
/**
|
|
5
11
|
* Tests for message action handlers.
|
|
6
12
|
* All API calls are mocked via global.fetch.
|
|
@@ -171,29 +177,9 @@ describe("handleDmworkMessageAction", () => {
|
|
|
171
177
|
|
|
172
178
|
describe("send — media only (no text)", () => {
|
|
173
179
|
it("should upload and send media without text", async () => {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
globalThis.fetch = mockFetch({
|
|
178
|
-
"/v1/bot/upload/credentials": async () => {
|
|
179
|
-
// Return 404 to trigger fallback to legacy upload
|
|
180
|
-
return new Response("Not implemented in test", { status: 404 });
|
|
181
|
-
},
|
|
182
|
-
"/v1/bot/file/upload": async () => {
|
|
183
|
-
uploadCalled = true;
|
|
184
|
-
return jsonResponse({ url: "https://cdn.example.com/file/chat/img.png" });
|
|
185
|
-
},
|
|
186
|
-
"/v1/bot/sendMessage": async (_url, init) => {
|
|
187
|
-
mediaSentPayload = JSON.parse(init?.body as string);
|
|
188
|
-
return jsonResponse({ message_id: 1, message_seq: 1 });
|
|
189
|
-
},
|
|
190
|
-
"https://example.com/image.png": async () => {
|
|
191
|
-
return new Response(Buffer.from("fake-image"), {
|
|
192
|
-
status: 200,
|
|
193
|
-
headers: { "Content-Type": "image/png" },
|
|
194
|
-
});
|
|
195
|
-
},
|
|
196
|
-
});
|
|
180
|
+
const { uploadAndSendMedia } = await import("./inbound.js");
|
|
181
|
+
const uploadSpy = vi.mocked(uploadAndSendMedia);
|
|
182
|
+
uploadSpy.mockClear();
|
|
197
183
|
|
|
198
184
|
const { handleDmworkMessageAction } = await import("./actions.js");
|
|
199
185
|
const result = await handleDmworkMessageAction({
|
|
@@ -204,35 +190,28 @@ describe("handleDmworkMessageAction", () => {
|
|
|
204
190
|
});
|
|
205
191
|
|
|
206
192
|
expect(result.ok).toBe(true);
|
|
207
|
-
expect(
|
|
193
|
+
expect(uploadSpy).toHaveBeenCalledOnce();
|
|
194
|
+
expect(uploadSpy.mock.calls[0][0]).toMatchObject({
|
|
195
|
+
mediaUrl: "https://example.com/image.png",
|
|
196
|
+
channelId: "uid1",
|
|
197
|
+
});
|
|
208
198
|
});
|
|
209
199
|
});
|
|
210
200
|
|
|
211
201
|
describe("send — media + text", () => {
|
|
212
202
|
it("should send both text and media", async () => {
|
|
213
203
|
let textSent = false;
|
|
214
|
-
|
|
204
|
+
|
|
205
|
+
const { uploadAndSendMedia } = await import("./inbound.js");
|
|
206
|
+
const uploadSpy = vi.mocked(uploadAndSendMedia);
|
|
207
|
+
uploadSpy.mockClear();
|
|
215
208
|
|
|
216
209
|
globalThis.fetch = mockFetch({
|
|
217
|
-
"/v1/bot/upload/credentials": async () => {
|
|
218
|
-
// Return 404 to trigger fallback to legacy upload
|
|
219
|
-
return new Response("Not implemented in test", { status: 404 });
|
|
220
|
-
},
|
|
221
|
-
"/v1/bot/file/upload": async () => {
|
|
222
|
-
uploadCalled = true;
|
|
223
|
-
return jsonResponse({ url: "https://cdn.example.com/file/chat/doc.pdf" });
|
|
224
|
-
},
|
|
225
210
|
"/v1/bot/sendMessage": async (_url, init) => {
|
|
226
211
|
const body = JSON.parse(init?.body as string);
|
|
227
212
|
if (body.payload?.content) textSent = true;
|
|
228
213
|
return jsonResponse({ message_id: 1, message_seq: 1 });
|
|
229
214
|
},
|
|
230
|
-
"https://example.com/doc.pdf": async () => {
|
|
231
|
-
return new Response(Buffer.from("fake-pdf"), {
|
|
232
|
-
status: 200,
|
|
233
|
-
headers: { "Content-Type": "application/pdf" },
|
|
234
|
-
});
|
|
235
|
-
},
|
|
236
215
|
});
|
|
237
216
|
|
|
238
217
|
const { handleDmworkMessageAction } = await import("./actions.js");
|
|
@@ -249,7 +228,7 @@ describe("handleDmworkMessageAction", () => {
|
|
|
249
228
|
|
|
250
229
|
expect(result.ok).toBe(true);
|
|
251
230
|
expect(textSent).toBe(true);
|
|
252
|
-
expect(
|
|
231
|
+
expect(uploadSpy).toHaveBeenCalledOnce();
|
|
253
232
|
});
|
|
254
233
|
});
|
|
255
234
|
|
package/src/api-fetch.test.ts
CHANGED
|
@@ -494,6 +494,144 @@ describe("fetchBotGroups — null response", () => {
|
|
|
494
494
|
});
|
|
495
495
|
});
|
|
496
496
|
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// parseImageDimensionsFromFile — streaming dimension parser
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
describe("parseImageDimensionsFromFile", () => {
|
|
501
|
+
it("should parse dimensions from a valid PNG file", async () => {
|
|
502
|
+
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
503
|
+
const { join } = await import("node:path");
|
|
504
|
+
|
|
505
|
+
// Create a minimal valid PNG (1x1 pixel)
|
|
506
|
+
const pngHeader = Buffer.from([
|
|
507
|
+
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
|
508
|
+
0x00, 0x00, 0x00, 0x0D, // IHDR chunk length
|
|
509
|
+
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
|
510
|
+
0x00, 0x00, 0x00, 0x64, // width = 100
|
|
511
|
+
0x00, 0x00, 0x00, 0xC8, // height = 200
|
|
512
|
+
0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc.
|
|
513
|
+
]);
|
|
514
|
+
const tmpDir = join("/tmp", "test-dims");
|
|
515
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
516
|
+
const tmpFile = join(tmpDir, "test.png");
|
|
517
|
+
writeFileSync(tmpFile, pngHeader);
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const { parseImageDimensionsFromFile } = await import("./api-fetch.js");
|
|
521
|
+
const dims = await parseImageDimensionsFromFile(tmpFile, "image/png");
|
|
522
|
+
expect(dims).toEqual({ width: 100, height: 200 });
|
|
523
|
+
} finally {
|
|
524
|
+
unlinkSync(tmpFile);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("should return null for non-existent file", async () => {
|
|
529
|
+
const { parseImageDimensionsFromFile } = await import("./api-fetch.js");
|
|
530
|
+
const dims = await parseImageDimensionsFromFile("/tmp/nonexistent-test-file.png", "image/png");
|
|
531
|
+
expect(dims).toBeNull();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("should return null for unsupported mime type", async () => {
|
|
535
|
+
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
536
|
+
const { join } = await import("node:path");
|
|
537
|
+
const tmpFile = join("/tmp", "test-dims", "test.txt");
|
|
538
|
+
mkdirSync(join("/tmp", "test-dims"), { recursive: true });
|
|
539
|
+
writeFileSync(tmpFile, "not an image");
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
const { parseImageDimensionsFromFile } = await import("./api-fetch.js");
|
|
543
|
+
const dims = await parseImageDimensionsFromFile(tmpFile, "text/plain");
|
|
544
|
+
expect(dims).toBeNull();
|
|
545
|
+
} finally {
|
|
546
|
+
unlinkSync(tmpFile);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// getUploadCredentials — validates response shape
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
describe("getUploadCredentials", () => {
|
|
555
|
+
const originalFetch = global.fetch;
|
|
556
|
+
|
|
557
|
+
beforeEach(() => {
|
|
558
|
+
vi.restoreAllMocks();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
afterEach(() => {
|
|
562
|
+
global.fetch = originalFetch;
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("should return credentials on valid response", async () => {
|
|
566
|
+
const fakeCreds = {
|
|
567
|
+
bucket: "my-bucket",
|
|
568
|
+
region: "ap-guangzhou",
|
|
569
|
+
key: "uploads/test.png",
|
|
570
|
+
credentials: {
|
|
571
|
+
tmpSecretId: "id123",
|
|
572
|
+
tmpSecretKey: "key456",
|
|
573
|
+
sessionToken: "tok789",
|
|
574
|
+
},
|
|
575
|
+
startTime: 1000,
|
|
576
|
+
expiredTime: 2000,
|
|
577
|
+
cdnBaseUrl: "https://cdn.example.com",
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
581
|
+
ok: true,
|
|
582
|
+
json: vi.fn().mockResolvedValue(fakeCreds),
|
|
583
|
+
}) as unknown as typeof fetch;
|
|
584
|
+
|
|
585
|
+
const { getUploadCredentials } = await import("./api-fetch.js");
|
|
586
|
+
const result = await getUploadCredentials({
|
|
587
|
+
apiUrl: "http://localhost:8090",
|
|
588
|
+
botToken: "test-token",
|
|
589
|
+
filename: "test.png",
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
expect(result.bucket).toBe("my-bucket");
|
|
593
|
+
expect(result.credentials.tmpSecretId).toBe("id123");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("should throw on non-ok response", async () => {
|
|
597
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
598
|
+
ok: false,
|
|
599
|
+
status: 403,
|
|
600
|
+
text: vi.fn().mockResolvedValue("Forbidden"),
|
|
601
|
+
statusText: "Forbidden",
|
|
602
|
+
}) as unknown as typeof fetch;
|
|
603
|
+
|
|
604
|
+
const { getUploadCredentials } = await import("./api-fetch.js");
|
|
605
|
+
await expect(
|
|
606
|
+
getUploadCredentials({
|
|
607
|
+
apiUrl: "http://localhost:8090",
|
|
608
|
+
botToken: "test-token",
|
|
609
|
+
filename: "test.png",
|
|
610
|
+
}),
|
|
611
|
+
).rejects.toThrow("403");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("should throw on incomplete response (missing bucket)", async () => {
|
|
615
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
616
|
+
ok: true,
|
|
617
|
+
json: vi.fn().mockResolvedValue({
|
|
618
|
+
region: "ap-guangzhou",
|
|
619
|
+
key: "uploads/test.png",
|
|
620
|
+
credentials: { tmpSecretId: "id", tmpSecretKey: "key", sessionToken: "tok" },
|
|
621
|
+
}),
|
|
622
|
+
}) as unknown as typeof fetch;
|
|
623
|
+
|
|
624
|
+
const { getUploadCredentials } = await import("./api-fetch.js");
|
|
625
|
+
await expect(
|
|
626
|
+
getUploadCredentials({
|
|
627
|
+
apiUrl: "http://localhost:8090",
|
|
628
|
+
botToken: "test-token",
|
|
629
|
+
filename: "test.png",
|
|
630
|
+
}),
|
|
631
|
+
).rejects.toThrow("incomplete");
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
497
635
|
// ---------------------------------------------------------------------------
|
|
498
636
|
// sendMediaMessage — Image vs File payload shape
|
|
499
637
|
// ---------------------------------------------------------------------------
|
package/src/api-fetch.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { ChannelType, MessageType } from "./types.js";
|
|
7
7
|
import path from "path";
|
|
8
|
+
import { open } from "node:fs/promises";
|
|
8
9
|
// @ts-ignore — cos-nodejs-sdk-v5 has incomplete TypeScript definitions
|
|
9
10
|
import COS from "cos-nodejs-sdk-v5";
|
|
10
11
|
|
|
@@ -144,6 +145,23 @@ export function parseImageDimensions(buf: Buffer, mime: string): { width: number
|
|
|
144
145
|
return null;
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Parse image dimensions from a file path by reading only the first 64KB.
|
|
150
|
+
* Avoids loading the entire file into memory.
|
|
151
|
+
*/
|
|
152
|
+
export async function parseImageDimensionsFromFile(filePath: string, mime: string): Promise<{ width: number; height: number } | null> {
|
|
153
|
+
const HEADER_SIZE = 65536; // 64KB — enough for PNG/JPEG/GIF/WebP headers
|
|
154
|
+
let fh: Awaited<ReturnType<typeof open>> | undefined;
|
|
155
|
+
try {
|
|
156
|
+
fh = await open(filePath, "r");
|
|
157
|
+
const buf = Buffer.alloc(HEADER_SIZE);
|
|
158
|
+
const { bytesRead } = await fh.read(buf, 0, HEADER_SIZE, 0);
|
|
159
|
+
return parseImageDimensions(buf.subarray(0, bytesRead), mime);
|
|
160
|
+
} catch { /* ignore read/parse errors */ }
|
|
161
|
+
finally { await fh?.close(); }
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
147
165
|
export async function sendMessage(params: {
|
|
148
166
|
apiUrl: string;
|
|
149
167
|
botToken: string;
|
|
@@ -522,7 +540,8 @@ export async function uploadFileToCOS(params: {
|
|
|
522
540
|
bucket: string;
|
|
523
541
|
region: string;
|
|
524
542
|
key: string;
|
|
525
|
-
|
|
543
|
+
fileBody: Buffer | NodeJS.ReadableStream;
|
|
544
|
+
fileSize?: number;
|
|
526
545
|
contentType: string;
|
|
527
546
|
cdnBaseUrl?: string;
|
|
528
547
|
}): Promise<{ url: string }> {
|
|
@@ -534,13 +553,18 @@ export async function uploadFileToCOS(params: {
|
|
|
534
553
|
ExpiredTime: params.expiredTime,
|
|
535
554
|
} as any);
|
|
536
555
|
|
|
556
|
+
const putParams: Record<string, unknown> = {
|
|
557
|
+
Bucket: params.bucket,
|
|
558
|
+
Region: params.region,
|
|
559
|
+
Key: params.key,
|
|
560
|
+
Body: params.fileBody,
|
|
561
|
+
};
|
|
562
|
+
if (params.fileSize != null) {
|
|
563
|
+
putParams.ContentLength = params.fileSize;
|
|
564
|
+
}
|
|
565
|
+
|
|
537
566
|
return new Promise((resolve, reject) => {
|
|
538
|
-
cos.putObject({
|
|
539
|
-
Bucket: params.bucket,
|
|
540
|
-
Region: params.region,
|
|
541
|
-
Key: params.key,
|
|
542
|
-
Body: params.fileBuffer,
|
|
543
|
-
} as any, (err: any, data: any) => {
|
|
567
|
+
cos.putObject(putParams as any, (err: any, data: any) => {
|
|
544
568
|
if (err) {
|
|
545
569
|
reject(new Error(`COS upload failed: ${err.message || JSON.stringify(err)}`));
|
|
546
570
|
} else {
|
package/src/channel.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
resolveDmworkAccount,
|
|
12
12
|
type ResolvedDmworkAccount,
|
|
13
13
|
} from "./accounts.js";
|
|
14
|
-
import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContentType, fetchBotGroups, getGroupMd, parseImageDimensions, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
|
|
14
|
+
import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContentType, fetchBotGroups, getGroupMd, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
|
|
15
15
|
import { WKSocket } from "./socket.js";
|
|
16
16
|
import { handleInboundMessage, type DmworkStatusSink } from "./inbound.js";
|
|
17
17
|
import { ChannelType, MessageType, type BotMessage, type MessagePayload } from "./types.js";
|
|
@@ -19,13 +19,64 @@ import { parseMentions } from "./mention-utils.js";
|
|
|
19
19
|
import { handleDmworkMessageAction, parseTarget } from "./actions.js";
|
|
20
20
|
import { createDmworkManagementTools } from "./agent-tools.js";
|
|
21
21
|
import { getOrCreateGroupMdCache, registerBotGroupIds, getKnownGroupIds } from "./group-md.js";
|
|
22
|
-
import path from "path";
|
|
23
|
-
import os from "os";
|
|
24
|
-
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
|
|
25
|
+
import { createReadStream, createWriteStream, statSync } from "node:fs";
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import { pipeline } from "node:stream/promises";
|
|
28
|
+
import { Readable } from "node:stream";
|
|
25
29
|
// HistoryEntry type - compatible with any version
|
|
26
30
|
type HistoryEntry = { sender: string; body: string; timestamp: number };
|
|
27
31
|
const DEFAULT_GROUP_HISTORY_LIMIT = 20;
|
|
28
32
|
|
|
33
|
+
const MAX_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
|
|
34
|
+
const UPLOAD_TEMP_DIR = path.join("/tmp", "dmwork-upload");
|
|
35
|
+
|
|
36
|
+
/** Download a URL to a temp file with backpressure, return the temp path. */
|
|
37
|
+
async function downloadToTempFile(url: string, filename: string, signal?: AbortSignal): Promise<{ tempPath: string; contentType: string | undefined }> {
|
|
38
|
+
await mkdir(UPLOAD_TEMP_DIR, { recursive: true });
|
|
39
|
+
const tempPath = path.join(UPLOAD_TEMP_DIR, `${randomUUID()}-${filename}`);
|
|
40
|
+
|
|
41
|
+
// HEAD to check size first
|
|
42
|
+
const head = await fetch(url, { method: "HEAD", signal: signal ?? AbortSignal.timeout(30_000) });
|
|
43
|
+
const contentLength = Number(head.headers.get("content-length") || 0);
|
|
44
|
+
if (contentLength > MAX_UPLOAD_SIZE) {
|
|
45
|
+
throw new Error(`File too large (${contentLength} bytes, max ${MAX_UPLOAD_SIZE})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const resp = await fetch(url, { signal: signal ?? AbortSignal.timeout(300_000) });
|
|
49
|
+
if (!resp.ok) throw new Error(`Failed to download media from ${url}: ${resp.status}`);
|
|
50
|
+
const contentType = resp.headers.get("content-type") ?? undefined;
|
|
51
|
+
|
|
52
|
+
const body = resp.body;
|
|
53
|
+
if (!body) throw new Error(`No response body from ${url}`);
|
|
54
|
+
const nodeStream = Readable.fromWeb(body as any);
|
|
55
|
+
const ws = createWriteStream(tempPath);
|
|
56
|
+
try {
|
|
57
|
+
await pipeline(nodeStream, ws);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Cleanup partial temp file on download failure
|
|
60
|
+
await unlink(tempPath).catch(() => {});
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
return { tempPath, contentType };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Cleanup old temp upload files (>1h). Called opportunistically. */
|
|
67
|
+
async function cleanupOldUploadTempFiles(): Promise<void> {
|
|
68
|
+
try {
|
|
69
|
+
const { readdir, stat, unlink: rm } = await import("node:fs/promises");
|
|
70
|
+
const files = await readdir(UPLOAD_TEMP_DIR);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
for (const f of files) {
|
|
73
|
+
const fp = path.join(UPLOAD_TEMP_DIR, f);
|
|
74
|
+
const st = await stat(fp).catch(() => null);
|
|
75
|
+
if (st && now - st.mtimeMs > 3600_000) await rm(fp).catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
} catch { /* dir may not exist */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
29
80
|
// Module-level history storage — survives auto-restarts
|
|
30
81
|
const _historyMaps = new Map<string, Map<string, any[]>>();
|
|
31
82
|
function getOrCreateHistoryMap(accountId: string): Map<string, any[]> {
|
|
@@ -337,10 +388,16 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
337
388
|
throw new Error("sendMedia called without mediaUrl");
|
|
338
389
|
}
|
|
339
390
|
|
|
340
|
-
// 1.
|
|
341
|
-
let
|
|
391
|
+
// 1. Resolve file — stream-based for HTTP/file paths, Buffer for data URIs
|
|
392
|
+
let fileBody: Buffer | NodeJS.ReadableStream;
|
|
393
|
+
let fileSize: number;
|
|
342
394
|
let contentType: string | undefined;
|
|
343
395
|
let filename: string;
|
|
396
|
+
let tempPath: string | undefined; // temp file we created (will be cleaned up)
|
|
397
|
+
let localFilePath: string | undefined; // path for parseImageDimensionsFromFile
|
|
398
|
+
|
|
399
|
+
// Opportunistic cleanup of stale temp files
|
|
400
|
+
cleanupOldUploadTempFiles().catch(() => {});
|
|
344
401
|
|
|
345
402
|
if (mediaUrl.startsWith("data:")) {
|
|
346
403
|
// Parse data URI: data:[<mediatype>][;base64],<data>
|
|
@@ -349,7 +406,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
349
406
|
throw new Error("Invalid data URI format");
|
|
350
407
|
}
|
|
351
408
|
contentType = match[1] || "application/octet-stream";
|
|
352
|
-
|
|
409
|
+
const buf = Buffer.from(match[2], "base64");
|
|
410
|
+
fileBody = buf;
|
|
411
|
+
fileSize = buf.length;
|
|
353
412
|
// Generate a reasonable filename from MIME type
|
|
354
413
|
const extMap: Record<string, string> = {
|
|
355
414
|
"text/markdown": ".md", "text/plain": ".txt", "application/pdf": ".pdf",
|
|
@@ -365,81 +424,97 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
365
424
|
}
|
|
366
425
|
} else if (mediaUrl.startsWith("file://")) {
|
|
367
426
|
const filePath = decodeURIComponent(mediaUrl.slice(7));
|
|
368
|
-
|
|
427
|
+
const st = statSync(filePath);
|
|
428
|
+
if (st.size > MAX_UPLOAD_SIZE) {
|
|
429
|
+
throw new Error(`File too large (${st.size} bytes, max ${MAX_UPLOAD_SIZE})`);
|
|
430
|
+
}
|
|
431
|
+
localFilePath = filePath;
|
|
432
|
+
fileBody = createReadStream(filePath);
|
|
433
|
+
fileSize = st.size;
|
|
369
434
|
filename = path.basename(filePath);
|
|
370
435
|
contentType = inferContentType(filename);
|
|
371
436
|
} else {
|
|
372
|
-
|
|
373
|
-
if (!resp.ok) {
|
|
374
|
-
throw new Error(`Failed to download media from ${mediaUrl}: ${resp.status}`);
|
|
375
|
-
}
|
|
376
|
-
fileBuffer = Buffer.from(await resp.arrayBuffer());
|
|
377
|
-
contentType = resp.headers.get("content-type") ?? undefined;
|
|
378
|
-
// Extract filename from URL path
|
|
437
|
+
// HTTP(S) URL — stream download to temp file to avoid buffering in memory
|
|
379
438
|
const urlPath = new URL(mediaUrl).pathname;
|
|
380
439
|
filename = path.basename(urlPath) || "file";
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
440
|
+
const dl = await downloadToTempFile(mediaUrl, filename);
|
|
441
|
+
tempPath = dl.tempPath;
|
|
442
|
+
localFilePath = dl.tempPath;
|
|
443
|
+
contentType = dl.contentType;
|
|
444
|
+
if (!contentType) contentType = inferContentType(filename);
|
|
445
|
+
const st = statSync(tempPath);
|
|
446
|
+
fileBody = createReadStream(tempPath);
|
|
447
|
+
fileSize = st.size;
|
|
384
448
|
}
|
|
385
449
|
|
|
386
450
|
contentType = contentType || "application/octet-stream";
|
|
387
451
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
botToken: account.config.botToken,
|
|
392
|
-
filename,
|
|
393
|
-
});
|
|
394
|
-
const { url: cdnUrl } = await uploadFileToCOS({
|
|
395
|
-
credentials: creds.credentials,
|
|
396
|
-
startTime: creds.startTime,
|
|
397
|
-
expiredTime: creds.expiredTime,
|
|
398
|
-
bucket: creds.bucket,
|
|
399
|
-
region: creds.region,
|
|
400
|
-
key: creds.key,
|
|
401
|
-
fileBuffer,
|
|
402
|
-
contentType,
|
|
403
|
-
cdnBaseUrl: creds.cdnBaseUrl,
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// 4. Parse target using shared parseTarget + knownGroupIds
|
|
407
|
-
let targetForParse = ctx.to;
|
|
408
|
-
if (ctx.to.startsWith("group:")) {
|
|
409
|
-
const groupPart = ctx.to.slice(6);
|
|
410
|
-
const atIdx = groupPart.indexOf("@");
|
|
411
|
-
if (atIdx >= 0) targetForParse = "group:" + groupPart.slice(0, atIdx);
|
|
412
|
-
}
|
|
413
|
-
const { channelId, channelType } = parseTarget(targetForParse, undefined, getKnownGroupIds());
|
|
414
|
-
|
|
415
|
-
// 5. Determine message type and send
|
|
416
|
-
const msgType = contentType.startsWith("image/")
|
|
417
|
-
? MessageType.Image
|
|
418
|
-
: MessageType.File;
|
|
419
|
-
|
|
420
|
-
if (msgType === MessageType.Image) {
|
|
421
|
-
const dims = parseImageDimensions(fileBuffer, contentType);
|
|
422
|
-
await sendMediaMessage({
|
|
452
|
+
try {
|
|
453
|
+
// 2. Upload to COS via STS credentials (stream mode)
|
|
454
|
+
const creds = await getUploadCredentials({
|
|
423
455
|
apiUrl: account.config.apiUrl,
|
|
424
456
|
botToken: account.config.botToken,
|
|
425
|
-
|
|
426
|
-
channelType,
|
|
427
|
-
type: msgType,
|
|
428
|
-
url: cdnUrl,
|
|
429
|
-
width: dims?.width,
|
|
430
|
-
height: dims?.height,
|
|
457
|
+
filename,
|
|
431
458
|
});
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
459
|
+
const { url: cdnUrl } = await uploadFileToCOS({
|
|
460
|
+
credentials: creds.credentials,
|
|
461
|
+
startTime: creds.startTime,
|
|
462
|
+
expiredTime: creds.expiredTime,
|
|
463
|
+
bucket: creds.bucket,
|
|
464
|
+
region: creds.region,
|
|
465
|
+
key: creds.key,
|
|
466
|
+
fileBody,
|
|
467
|
+
fileSize,
|
|
468
|
+
contentType,
|
|
469
|
+
cdnBaseUrl: creds.cdnBaseUrl,
|
|
442
470
|
});
|
|
471
|
+
|
|
472
|
+
// 3. Parse target using shared parseTarget + knownGroupIds
|
|
473
|
+
let targetForParse = ctx.to;
|
|
474
|
+
if (ctx.to.startsWith("group:")) {
|
|
475
|
+
const groupPart = ctx.to.slice(6);
|
|
476
|
+
const atIdx = groupPart.indexOf("@");
|
|
477
|
+
if (atIdx >= 0) targetForParse = "group:" + groupPart.slice(0, atIdx);
|
|
478
|
+
}
|
|
479
|
+
const { channelId, channelType } = parseTarget(targetForParse, undefined, getKnownGroupIds());
|
|
480
|
+
|
|
481
|
+
// 4. Determine message type and send
|
|
482
|
+
const msgType = contentType.startsWith("image/")
|
|
483
|
+
? MessageType.Image
|
|
484
|
+
: MessageType.File;
|
|
485
|
+
|
|
486
|
+
if (msgType === MessageType.Image) {
|
|
487
|
+
// For images, parse dimensions from file or buffer
|
|
488
|
+
const dims = localFilePath
|
|
489
|
+
? await parseImageDimensionsFromFile(localFilePath, contentType)
|
|
490
|
+
: Buffer.isBuffer(fileBody)
|
|
491
|
+
? parseImageDimensions(fileBody, contentType)
|
|
492
|
+
: null;
|
|
493
|
+
await sendMediaMessage({
|
|
494
|
+
apiUrl: account.config.apiUrl,
|
|
495
|
+
botToken: account.config.botToken,
|
|
496
|
+
channelId,
|
|
497
|
+
channelType,
|
|
498
|
+
type: msgType,
|
|
499
|
+
url: cdnUrl,
|
|
500
|
+
width: dims?.width,
|
|
501
|
+
height: dims?.height,
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
await sendMediaMessage({
|
|
505
|
+
apiUrl: account.config.apiUrl,
|
|
506
|
+
botToken: account.config.botToken,
|
|
507
|
+
channelId,
|
|
508
|
+
channelType,
|
|
509
|
+
type: msgType,
|
|
510
|
+
url: cdnUrl,
|
|
511
|
+
name: filename,
|
|
512
|
+
size: fileSize,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
} finally {
|
|
516
|
+
// Cleanup temp file
|
|
517
|
+
if (tempPath) await unlink(tempPath).catch(() => {});
|
|
443
518
|
}
|
|
444
519
|
|
|
445
520
|
return { channel: "dmwork", to: ctx.to, messageId: "" };
|
package/src/inbound.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ChannelLogSink, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, postJson, sendMediaMessage, inferContentType, parseImageDimensions, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
|
|
2
|
+
import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, postJson, sendMediaMessage, inferContentType, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
|
|
3
3
|
import type { ResolvedDmworkAccount } from "./accounts.js";
|
|
4
4
|
import type { BotMessage } from "./types.js";
|
|
5
5
|
import { ChannelType, MessageType } from "./types.js";
|
|
@@ -77,68 +77,110 @@ export async function uploadAndSendMedia(params: {
|
|
|
77
77
|
}): Promise<void> {
|
|
78
78
|
const { mediaUrl, apiUrl, botToken, channelId, channelType, log } = params;
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const { createReadStream: fsCreateReadStream, statSync: fsStatSync, createWriteStream: fsCreateWriteStream } = await import("node:fs");
|
|
81
|
+
const { basename, join: pathJoin } = await import("node:path");
|
|
82
|
+
const { mkdir: fsMkdir, unlink: fsUnlink } = await import("node:fs/promises");
|
|
83
|
+
const { randomUUID } = await import("node:crypto");
|
|
84
|
+
const { pipeline } = await import("node:stream/promises");
|
|
85
|
+
const { Readable } = await import("node:stream");
|
|
86
|
+
|
|
87
|
+
const MAX_UPLOAD = 500 * 1024 * 1024;
|
|
88
|
+
const TEMP_DIR = pathJoin("/tmp", "dmwork-upload");
|
|
89
|
+
|
|
90
|
+
let fileBody: Buffer | NodeJS.ReadableStream;
|
|
91
|
+
let fileSize: number;
|
|
82
92
|
let contentType: string;
|
|
83
93
|
let filename: string;
|
|
94
|
+
let tempPath: string | undefined;
|
|
84
95
|
|
|
85
96
|
if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
|
|
97
|
+
filename = extractFilename(mediaUrl);
|
|
98
|
+
// Stream download to temp file
|
|
99
|
+
await fsMkdir(TEMP_DIR, { recursive: true });
|
|
100
|
+
tempPath = pathJoin(TEMP_DIR, `${randomUUID()}-${filename}`);
|
|
101
|
+
|
|
102
|
+
const head = await fetch(mediaUrl, { method: "HEAD" });
|
|
103
|
+
const cl = Number(head.headers.get("content-length") || 0);
|
|
104
|
+
if (cl > MAX_UPLOAD) throw new Error(`File too large (${cl} bytes, max ${MAX_UPLOAD})`);
|
|
105
|
+
|
|
86
106
|
const resp = await fetch(mediaUrl);
|
|
87
107
|
if (!resp.ok) throw new Error(`Failed to fetch media: ${resp.status}`);
|
|
88
|
-
buffer = Buffer.from(await resp.arrayBuffer());
|
|
89
108
|
contentType = resp.headers.get("content-type") || "application/octet-stream";
|
|
90
|
-
|
|
109
|
+
|
|
110
|
+
const body = resp.body;
|
|
111
|
+
if (!body) throw new Error(`No response body from ${mediaUrl}`);
|
|
112
|
+
const nodeStream = Readable.fromWeb(body as any);
|
|
113
|
+
const ws = fsCreateWriteStream(tempPath);
|
|
114
|
+
try {
|
|
115
|
+
await pipeline(nodeStream, ws);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Cleanup partial temp file on download failure
|
|
118
|
+
await fsUnlink(tempPath).catch(() => {});
|
|
119
|
+
tempPath = undefined;
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const st = fsStatSync(tempPath);
|
|
124
|
+
fileBody = fsCreateReadStream(tempPath);
|
|
125
|
+
fileSize = st.size;
|
|
91
126
|
} else {
|
|
92
|
-
// Local file path
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
127
|
+
// Local file path — stream, don't buffer
|
|
128
|
+
const st = fsStatSync(mediaUrl);
|
|
129
|
+
if (st.size > MAX_UPLOAD) throw new Error(`File too large (${st.size} bytes, max ${MAX_UPLOAD})`);
|
|
130
|
+
fileBody = fsCreateReadStream(mediaUrl);
|
|
131
|
+
fileSize = st.size;
|
|
96
132
|
filename = basename(mediaUrl);
|
|
97
133
|
contentType = inferContentType(filename);
|
|
98
134
|
}
|
|
99
135
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
try {
|
|
137
|
+
// Upload to COS via STS credentials (stream mode)
|
|
138
|
+
const creds = await getUploadCredentials({ apiUrl, botToken, filename });
|
|
139
|
+
const { url: uploadedUrl } = await uploadFileToCOS({
|
|
140
|
+
credentials: creds.credentials,
|
|
141
|
+
startTime: creds.startTime,
|
|
142
|
+
expiredTime: creds.expiredTime,
|
|
143
|
+
bucket: creds.bucket,
|
|
144
|
+
region: creds.region,
|
|
145
|
+
key: creds.key,
|
|
146
|
+
fileBody,
|
|
147
|
+
fileSize,
|
|
148
|
+
contentType,
|
|
149
|
+
cdnBaseUrl: creds.cdnBaseUrl,
|
|
150
|
+
});
|
|
113
151
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
152
|
+
// Determine message type from MIME
|
|
153
|
+
const isImage = contentType.startsWith("image/");
|
|
154
|
+
const msgType = isImage ? MessageType.Image : MessageType.File;
|
|
155
|
+
|
|
156
|
+
// For images, parse dimensions from file (not full buffer)
|
|
157
|
+
let width: number | undefined;
|
|
158
|
+
let height: number | undefined;
|
|
159
|
+
if (isImage) {
|
|
160
|
+
const fileToParse = tempPath ?? mediaUrl;
|
|
161
|
+
const dims = await parseImageDimensionsFromFile(fileToParse, contentType);
|
|
162
|
+
width = dims?.width;
|
|
163
|
+
height = dims?.height;
|
|
164
|
+
}
|
|
126
165
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
166
|
+
log?.info?.(`dmwork: uploaded media as ${isImage ? "image" : "file"}: ${filename}${width ? ` (${width}x${height})` : ""}`);
|
|
167
|
+
|
|
168
|
+
// Send via sendMessage
|
|
169
|
+
await sendMediaMessage({
|
|
170
|
+
apiUrl,
|
|
171
|
+
botToken,
|
|
172
|
+
channelId,
|
|
173
|
+
channelType,
|
|
174
|
+
type: msgType,
|
|
175
|
+
url: uploadedUrl,
|
|
176
|
+
name: isImage ? undefined : filename,
|
|
177
|
+
size: isImage ? undefined : fileSize,
|
|
178
|
+
width,
|
|
179
|
+
height,
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
if (tempPath) await fsUnlink(tempPath).catch(() => {});
|
|
183
|
+
}
|
|
142
184
|
}
|
|
143
185
|
|
|
144
186
|
/** Guess MIME type from file extension */
|