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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -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
- let uploadCalled = false;
175
- let mediaSentPayload: any = null;
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(uploadCalled).toBe(true);
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
- let uploadCalled = false;
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(uploadCalled).toBe(true);
231
+ expect(uploadSpy).toHaveBeenCalledOnce();
253
232
  });
254
233
  });
255
234
 
@@ -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
- fileBuffer: Buffer;
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. Download the file
341
- let fileBuffer: Buffer;
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
- fileBuffer = Buffer.from(match[2], "base64");
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
- fileBuffer = await readFile(filePath);
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
- const resp = await fetch(mediaUrl, { signal: AbortSignal.timeout(300_000) });
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
- if (!contentType) {
382
- contentType = inferContentType(filename);
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
- // 2. Upload to COS via STS credentials (putObject supports Buffer directly)
389
- const creds = await getUploadCredentials({
390
- apiUrl: account.config.apiUrl,
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
- channelId,
426
- channelType,
427
- type: msgType,
428
- url: cdnUrl,
429
- width: dims?.width,
430
- height: dims?.height,
457
+ filename,
431
458
  });
432
- } else {
433
- await sendMediaMessage({
434
- apiUrl: account.config.apiUrl,
435
- botToken: account.config.botToken,
436
- channelId,
437
- channelType,
438
- type: msgType,
439
- url: cdnUrl,
440
- name: filename,
441
- size: fileBuffer.length,
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
- // Handle local file path or remote URL
81
- let buffer: Buffer;
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
- filename = extractFilename(mediaUrl);
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 { readFileSync } = await import("node:fs");
94
- const { basename } = await import("node:path");
95
- buffer = readFileSync(mediaUrl);
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
- // Upload directly to COS via STS credentials (putObject supports Buffer directly)
101
- const creds = await getUploadCredentials({ apiUrl, botToken, filename });
102
- const { url: uploadedUrl } = await uploadFileToCOS({
103
- credentials: creds.credentials,
104
- startTime: creds.startTime,
105
- expiredTime: creds.expiredTime,
106
- bucket: creds.bucket,
107
- region: creds.region,
108
- key: creds.key,
109
- fileBuffer: buffer,
110
- contentType,
111
- cdnBaseUrl: creds.cdnBaseUrl,
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
- // Determine message type from MIME
115
- const isImage = contentType.startsWith("image/");
116
- const msgType = isImage ? MessageType.Image : MessageType.File;
117
-
118
- // For images, try to read dimensions from the buffer
119
- let width: number | undefined;
120
- let height: number | undefined;
121
- if (isImage) {
122
- const dims = parseImageDimensions(buffer, contentType);
123
- width = dims?.width;
124
- height = dims?.height;
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
- log?.info?.(`dmwork: uploaded media as ${isImage ? "image" : "file"}: ${filename}${width ? ` (${width}x${height})` : ""}`);
128
-
129
- // Send via sendMessage
130
- await sendMediaMessage({
131
- apiUrl,
132
- botToken,
133
- channelId,
134
- channelType,
135
- type: msgType,
136
- url: uploadedUrl,
137
- name: isImage ? undefined : filename,
138
- size: isImage ? undefined : buffer.length,
139
- width,
140
- height,
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 */