@syengup/friday-channel-next 0.1.23 → 0.1.25

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 };
@@ -110,10 +110,14 @@ function readAgentSessions(agentId) {
110
110
  continue;
111
111
  const entry = rawEntry;
112
112
  const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
113
- // Drop internal/system sessions and subagent links.
113
+ // Drop internal/system sessions and subagent links. We key only on
114
+ // `spawnedBy`/`subagentRole` (plus the `subagent:` prefix in
115
+ // isInternalSessionKey) — NOT on `parentSessionKey`, which real user
116
+ // conversations (e.g. webchat sessions branched off another session) also
117
+ // carry and which would otherwise be wrongly excluded from the sidebar.
114
118
  if (isInternalSessionKey(canonicalKey, storeKey))
115
119
  continue;
116
- if (entry.spawnedBy || entry.subagentRole || entry.parentSessionKey)
120
+ if (entry.spawnedBy || entry.subagentRole)
117
121
  continue;
118
122
  // Drop archived/empty sessions (transcript moved away or never written).
119
123
  if (!hasLiveTranscript(entry, storePath))
@@ -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.25",
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
 
@@ -143,4 +143,34 @@ describe("handleHistorySessions", () => {
143
143
  const keys = JSON.parse(res.body).sessions.map((s: any) => s.sessionKey);
144
144
  expect(keys).toEqual(["agent:main:main"]);
145
145
  });
146
+
147
+ it("keeps real conversations that merely carry a parentSessionKey", async () => {
148
+ setForward(
149
+ { agents: { list: [{ id: "main" }] } },
150
+ {
151
+ main: {
152
+ // Webchat session branched off another session: has parentSessionKey
153
+ // but is a genuine user conversation — must be surfaced.
154
+ "agent:main:dashboard:b91ad945": {
155
+ sessionId: "wc",
156
+ updatedAt: 9,
157
+ parentSessionKey: "agent:main:fridaynext:mq5zn7dp",
158
+ sessionFile: transcript("wc.jsonl"),
159
+ },
160
+ // Real subagent fork (spawnedBy) must still be filtered.
161
+ "agent:main:fork": {
162
+ sessionId: "fk",
163
+ updatedAt: 8,
164
+ spawnedBy: "agent:main:main",
165
+ parentSessionKey: "agent:main:main",
166
+ sessionFile: transcript("fk.jsonl"),
167
+ },
168
+ },
169
+ },
170
+ );
171
+ const res = new MockRes();
172
+ await handleHistorySessions(makeReq(AUTH), res as any);
173
+ const keys = JSON.parse(res.body).sessions.map((s: any) => s.sessionKey);
174
+ expect(keys).toEqual(["agent:main:dashboard:b91ad945"]);
175
+ });
146
176
  });
@@ -125,9 +125,13 @@ function readAgentSessions(agentId: string): FridayHistorySessionSummary[] {
125
125
  const entry = rawEntry as Record<string, unknown>;
126
126
  const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
127
127
 
128
- // Drop internal/system sessions and subagent links.
128
+ // Drop internal/system sessions and subagent links. We key only on
129
+ // `spawnedBy`/`subagentRole` (plus the `subagent:` prefix in
130
+ // isInternalSessionKey) — NOT on `parentSessionKey`, which real user
131
+ // conversations (e.g. webchat sessions branched off another session) also
132
+ // carry and which would otherwise be wrongly excluded from the sidebar.
129
133
  if (isInternalSessionKey(canonicalKey, storeKey)) continue;
130
- if (entry.spawnedBy || entry.subagentRole || entry.parentSessionKey) continue;
134
+ if (entry.spawnedBy || entry.subagentRole) continue;
131
135
  // Drop archived/empty sessions (transcript moved away or never written).
132
136
  if (!hasLiveTranscript(entry, storePath)) continue;
133
137
 
@@ -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.