@syengup/friday-channel-next 0.1.23 → 0.1.24

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.
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import { sseEmitter } from "./sse/emitter.js";
4
4
  import { guessMimeType } from "./http/handlers/files.js";
5
- import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
5
+ import { decodeBase64Media, downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
6
6
  import { getRunRoute } from "./run-metadata.js";
7
7
  import { resolveHistorySessionKeyForFridayDevice } from "./friday-session.js";
8
8
  const DISCOVERY = {
@@ -49,6 +49,9 @@ async function handleSend(ctx) {
49
49
  const to = pickString(ctx.params, ["to", "target"]).toUpperCase();
50
50
  const text = pickString(ctx.params, ["message", "text", "content"]);
51
51
  const mediaPath = pickString(ctx.params, ["media", "url", "path", "filePath", "fileUrl"]);
52
+ const inlineBase64 = pickString(ctx.params, ["buffer", "base64", "data"]);
53
+ const mediaMimeHint = pickString(ctx.params, ["mimeType", "contentType"]);
54
+ const filename = pickString(ctx.params, ["filename", "name"]);
52
55
  const caption = pickString(ctx.params, ["caption"]);
53
56
  if (!to) {
54
57
  return { ok: false, error: "Missing required param: to" };
@@ -75,29 +78,38 @@ async function handleSend(ctx) {
75
78
  },
76
79
  }, to, true);
77
80
  }
81
+ // Resolve media from an inline base64 buffer or a path/url reference. (`attachments[]` arrays are
82
+ // normalized by the OpenClaw core and arrive via outbound.sendMedia, so they're not handled here.)
83
+ let media = null;
84
+ let originalMediaUrl = "";
85
+ if (inlineBase64) {
86
+ media = decodeBase64Media(inlineBase64, mediaMimeHint || (filename ? guessMimeType(filename) : ""));
87
+ originalMediaUrl = filename || "inline-buffer";
88
+ }
89
+ else if (mediaPath) {
90
+ media = await readMediaFile(mediaPath, ctx);
91
+ originalMediaUrl = mediaPath;
92
+ }
78
93
  // Send media via SSE outbound
79
- if (mediaPath) {
80
- const result = await readMediaFile(mediaPath, ctx);
81
- if (result) {
82
- const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
83
- const saved = await saveMediaBuffer(result.buffer, result.mimeType, "inbound");
84
- if (saved.id) {
85
- const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
86
- sseEmitter.broadcast({
87
- type: "outbound",
88
- data: {
89
- op: "media",
90
- ts: Date.now(),
91
- runId,
92
- deviceId: to,
93
- sessionKey,
94
- audioAsVoice: false,
95
- caption: caption || text,
96
- mediaUrl: publicUrl,
97
- ctx: { to, text: caption || text, originalMediaUrl: mediaPath },
98
- },
99
- }, to, true);
100
- }
94
+ if (media) {
95
+ const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
96
+ const saved = await saveMediaBuffer(media.buffer, media.mimeType, "inbound");
97
+ if (saved.id) {
98
+ const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
99
+ sseEmitter.broadcast({
100
+ type: "outbound",
101
+ data: {
102
+ op: "media",
103
+ ts: Date.now(),
104
+ runId,
105
+ deviceId: to,
106
+ sessionKey,
107
+ audioAsVoice: false,
108
+ caption: caption || text,
109
+ mediaUrl: publicUrl,
110
+ ctx: { to, text: caption || text, originalMediaUrl },
111
+ },
112
+ }, to, true);
101
113
  }
102
114
  }
103
115
  return { ok: true, runId, to };
@@ -1,4 +1,17 @@
1
1
  export declare function isHttpUrl(value: string): boolean;
2
+ /**
3
+ * Decode an inline base64 attachment (the `message` tool's `buffer` param). Accepts both a bare
4
+ * base64 string and a `data:<mime>;base64,...` data URL.
5
+ *
6
+ * The charset guard rejects local paths / http URLs (which contain `:`/`.`), so callers can pass a
7
+ * possibly-misused value safely — those fall through to the path/url resolver instead of being
8
+ * decoded into garbage bytes. Like remote downloads, the mime is only a hint; `saveMediaBuffer`
9
+ * re-detects the real type from magic bytes.
10
+ */
11
+ export declare function decodeBase64Media(raw: string, mimeHint?: string): {
12
+ buffer: Buffer;
13
+ mimeType: string;
14
+ } | null;
2
15
  /**
3
16
  * Download an http/https URL into a buffer. Returns null on any failure (non-2xx, oversize, timeout,
4
17
  * network error) so callers degrade to text-only rather than throwing.
@@ -13,6 +13,39 @@ const REMOTE_MEDIA_TIMEOUT_MS = 20_000;
13
13
  export function isHttpUrl(value) {
14
14
  return /^https?:\/\//i.test(value.trim());
15
15
  }
16
+ const DATA_URL_PREFIX_RE = /^data:([^;,]+)?(;base64)?,/i;
17
+ /**
18
+ * Decode an inline base64 attachment (the `message` tool's `buffer` param). Accepts both a bare
19
+ * base64 string and a `data:<mime>;base64,...` data URL.
20
+ *
21
+ * The charset guard rejects local paths / http URLs (which contain `:`/`.`), so callers can pass a
22
+ * possibly-misused value safely — those fall through to the path/url resolver instead of being
23
+ * decoded into garbage bytes. Like remote downloads, the mime is only a hint; `saveMediaBuffer`
24
+ * re-detects the real type from magic bytes.
25
+ */
26
+ export function decodeBase64Media(raw, mimeHint) {
27
+ let body = raw.trim();
28
+ let dataUrlMime = "";
29
+ const match = body.match(DATA_URL_PREFIX_RE);
30
+ if (match) {
31
+ dataUrlMime = (match[1] ?? "").trim().toLowerCase();
32
+ body = body.slice(match[0].length);
33
+ }
34
+ body = body.replace(/\s+/g, "");
35
+ if (!body || !/^[A-Za-z0-9+/]+={0,2}$/.test(body))
36
+ return null;
37
+ let buffer;
38
+ try {
39
+ buffer = Buffer.from(body, "base64");
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ if (!buffer.length)
45
+ return null;
46
+ const mimeType = mimeHint?.trim().toLowerCase() || dataUrlMime || "application/octet-stream";
47
+ return { buffer, mimeType };
48
+ }
16
49
  /**
17
50
  * Download an http/https URL into a buffer. Returns null on any failure (non-2xx, oversize, timeout,
18
51
  * network error) so callers degrade to text-only rather than throwing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,15 @@
12
12
  "tsconfig.json",
13
13
  "openclaw.plugin.json"
14
14
  ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "prepublishOnly": "pnpm build && rm -rf dist/attachments",
18
+ "test": "npm run test:unit && npm run test:e2e",
19
+ "test:unit": "vitest run",
20
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
21
+ "test:smoke": "node scripts/e2e-smoke.mjs",
22
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
23
+ },
15
24
  "bin": {
16
25
  "friday-channel-next": "install.js"
17
26
  },
@@ -57,13 +66,5 @@
57
66
  "typescript": "^6.0.3",
58
67
  "vitest": "^4.1.5",
59
68
  "zod": "^4.3.6"
60
- },
61
- "scripts": {
62
- "build": "tsc -p tsconfig.json",
63
- "test": "npm run test:unit && npm run test:e2e",
64
- "test:unit": "vitest run",
65
- "test:e2e": "vitest run --config vitest.e2e.config.ts",
66
- "test:smoke": "node scripts/e2e-smoke.mjs",
67
- "test:msg-live": "node scripts/message-roundtrip-live.mjs"
68
69
  }
69
- }
70
+ }
@@ -130,6 +130,39 @@ describe("channel-actions handleSend sessionKey routing", () => {
130
130
  expect(media?.data.sessionKey).toBe(appSession);
131
131
  });
132
132
 
133
+ it("send media via an inline base64 `buffer` param decodes it and emits op:media", async () => {
134
+ const deviceId = "DEV-ACT-BUF";
135
+ const runId = "run-act-buf";
136
+ const appSession = "agent:operator:friday:direct:dev-act-buf:1780561609";
137
+ registerRunRoute({ runId, deviceId, sessionKey: appSession });
138
+ sseEmitter.trackDeviceForRun(deviceId, runId);
139
+ const res = connect(deviceId);
140
+
141
+ const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
142
+
143
+ const result = await handleMessageAction({
144
+ action: "send",
145
+ params: {
146
+ to: deviceId,
147
+ message: "base64 图来了",
148
+ buffer: jpegBytes.toString("base64"),
149
+ mimeType: "image/jpeg",
150
+ filename: "test-small.jpg",
151
+ },
152
+ sessionKey: "agent:operator:main",
153
+ });
154
+
155
+ expect((result as { ok?: boolean }).ok).toBe(true);
156
+ const frames = parseOutboundFrames(res);
157
+ const media = frames.find((f) => f.type === "outbound" && f.data.op === "media");
158
+ expect(media).toBeTruthy();
159
+ expect(String(media?.data.mediaUrl)).toMatch(/^\/friday-next\/files\//);
160
+ expect((media?.data.ctx as { originalMediaUrl?: string })?.originalMediaUrl).toBe(
161
+ "test-small.jpg",
162
+ );
163
+ expect(media?.data.sessionKey).toBe(appSession);
164
+ });
165
+
133
166
  it("falls back to ctx.sessionKey when the device has no active run-route", async () => {
134
167
  const deviceId = "DEV-ACT-2";
135
168
  const res = connect(deviceId);
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import { sseEmitter } from "./sse/emitter.js";
4
4
  import { guessMimeType } from "./http/handlers/files.js";
5
- import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
5
+ import { decodeBase64Media, downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
6
6
  import { getRunRoute } from "./run-metadata.js";
7
7
  import { resolveHistorySessionKeyForFridayDevice } from "./friday-session.js";
8
8
 
@@ -63,6 +63,9 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
63
63
  const to = pickString(ctx.params, ["to", "target"]).toUpperCase();
64
64
  const text = pickString(ctx.params, ["message", "text", "content"]);
65
65
  const mediaPath = pickString(ctx.params, ["media", "url", "path", "filePath", "fileUrl"]);
66
+ const inlineBase64 = pickString(ctx.params, ["buffer", "base64", "data"]);
67
+ const mediaMimeHint = pickString(ctx.params, ["mimeType", "contentType"]);
68
+ const filename = pickString(ctx.params, ["filename", "name"]);
66
69
  const caption = pickString(ctx.params, ["caption"]);
67
70
 
68
71
  if (!to) {
@@ -98,33 +101,45 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
98
101
  );
99
102
  }
100
103
 
104
+ // Resolve media from an inline base64 buffer or a path/url reference. (`attachments[]` arrays are
105
+ // normalized by the OpenClaw core and arrive via outbound.sendMedia, so they're not handled here.)
106
+ let media: { buffer: Buffer; mimeType: string } | null = null;
107
+ let originalMediaUrl = "";
108
+ if (inlineBase64) {
109
+ media = decodeBase64Media(
110
+ inlineBase64,
111
+ mediaMimeHint || (filename ? guessMimeType(filename) : ""),
112
+ );
113
+ originalMediaUrl = filename || "inline-buffer";
114
+ } else if (mediaPath) {
115
+ media = await readMediaFile(mediaPath, ctx);
116
+ originalMediaUrl = mediaPath;
117
+ }
118
+
101
119
  // Send media via SSE outbound
102
- if (mediaPath) {
103
- const result = await readMediaFile(mediaPath, ctx);
104
- if (result) {
105
- const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
106
- const saved = await saveMediaBuffer(result.buffer, result.mimeType, "inbound");
107
- if (saved.id) {
108
- const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
109
- sseEmitter.broadcast(
110
- {
111
- type: "outbound",
112
- data: {
113
- op: "media",
114
- ts: Date.now(),
115
- runId,
116
- deviceId: to,
117
- sessionKey,
118
- audioAsVoice: false,
119
- caption: caption || text,
120
- mediaUrl: publicUrl,
121
- ctx: { to, text: caption || text, originalMediaUrl: mediaPath },
122
- },
120
+ if (media) {
121
+ const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
122
+ const saved = await saveMediaBuffer(media.buffer, media.mimeType, "inbound");
123
+ if (saved.id) {
124
+ const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
125
+ sseEmitter.broadcast(
126
+ {
127
+ type: "outbound",
128
+ data: {
129
+ op: "media",
130
+ ts: Date.now(),
131
+ runId,
132
+ deviceId: to,
133
+ sessionKey,
134
+ audioAsVoice: false,
135
+ caption: caption || text,
136
+ mediaUrl: publicUrl,
137
+ ctx: { to, text: caption || text, originalMediaUrl },
123
138
  },
124
- to,
125
- true,
126
- );
127
- }
139
+ },
140
+ to,
141
+ true,
142
+ );
128
143
  }
129
144
  }
130
145
 
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
2
+ import { decodeBase64Media, downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
3
3
 
4
4
  describe("isHttpUrl", () => {
5
5
  it("matches http/https links, rejects local paths", () => {
@@ -76,3 +76,32 @@ describe("downloadRemoteMedia", () => {
76
76
  expect(await downloadRemoteMedia("https://example.com/x.png")).toBeNull();
77
77
  });
78
78
  });
79
+
80
+ describe("decodeBase64Media", () => {
81
+ const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
82
+
83
+ it("decodes a bare base64 string with a mime hint", () => {
84
+ const result = decodeBase64Media(jpegBytes.toString("base64"), "image/jpeg");
85
+ expect(result?.mimeType).toBe("image/jpeg");
86
+ expect(result?.buffer.equals(jpegBytes)).toBe(true);
87
+ });
88
+
89
+ it("decodes a data: URL and infers the mime from it", () => {
90
+ const dataUrl = `data:image/png;base64,${jpegBytes.toString("base64")}`;
91
+ const result = decodeBase64Media(dataUrl);
92
+ expect(result?.mimeType).toBe("image/png");
93
+ expect(result?.buffer.equals(jpegBytes)).toBe(true);
94
+ });
95
+
96
+ it("defaults to octet-stream when no mime is known", () => {
97
+ expect(decodeBase64Media(jpegBytes.toString("base64"))?.mimeType).toBe(
98
+ "application/octet-stream",
99
+ );
100
+ });
101
+
102
+ it("rejects local paths and URLs (not base64)", () => {
103
+ expect(decodeBase64Media("/tmp/test.jpg")).toBeNull();
104
+ expect(decodeBase64Media("https://example.com/a.png")).toBeNull();
105
+ expect(decodeBase64Media("")).toBeNull();
106
+ });
107
+ });
@@ -17,6 +17,44 @@ export function isHttpUrl(value: string): boolean {
17
17
  return /^https?:\/\//i.test(value.trim());
18
18
  }
19
19
 
20
+ const DATA_URL_PREFIX_RE = /^data:([^;,]+)?(;base64)?,/i;
21
+
22
+ /**
23
+ * Decode an inline base64 attachment (the `message` tool's `buffer` param). Accepts both a bare
24
+ * base64 string and a `data:<mime>;base64,...` data URL.
25
+ *
26
+ * The charset guard rejects local paths / http URLs (which contain `:`/`.`), so callers can pass a
27
+ * possibly-misused value safely — those fall through to the path/url resolver instead of being
28
+ * decoded into garbage bytes. Like remote downloads, the mime is only a hint; `saveMediaBuffer`
29
+ * re-detects the real type from magic bytes.
30
+ */
31
+ export function decodeBase64Media(
32
+ raw: string,
33
+ mimeHint?: string,
34
+ ): { buffer: Buffer; mimeType: string } | null {
35
+ let body = raw.trim();
36
+ let dataUrlMime = "";
37
+ const match = body.match(DATA_URL_PREFIX_RE);
38
+ if (match) {
39
+ dataUrlMime = (match[1] ?? "").trim().toLowerCase();
40
+ body = body.slice(match[0].length);
41
+ }
42
+ body = body.replace(/\s+/g, "");
43
+ if (!body || !/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return null;
44
+
45
+ let buffer: Buffer;
46
+ try {
47
+ buffer = Buffer.from(body, "base64");
48
+ } catch {
49
+ return null;
50
+ }
51
+ if (!buffer.length) return null;
52
+
53
+ const mimeType =
54
+ mimeHint?.trim().toLowerCase() || dataUrlMime || "application/octet-stream";
55
+ return { buffer, mimeType };
56
+ }
57
+
20
58
  /**
21
59
  * Download an http/https URL into a buffer. Returns null on any failure (non-2xx, oversize, timeout,
22
60
  * network error) so callers degrade to text-only rather than throwing.