@syengup/friday-channel-next 0.1.25 → 0.1.26

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.
@@ -9,6 +9,7 @@
9
9
  * `runtime.subagent.getSessionMessages`), which already resolves the active
10
10
  * branch and compaction, then normalizes each raw message into a stable DTO.
11
11
  */
12
+ import { fileURLToPath } from "node:url";
12
13
  import { getFridayNextRuntime } from "../../runtime.js";
13
14
  import { extractBearerToken } from "../middleware/auth.js";
14
15
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
@@ -17,6 +18,28 @@ import { resolveMediaAttachment } from "./files.js";
17
18
  import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
18
19
  const DEFAULT_LIMIT = 200;
19
20
  const MAX_LIMIT = 1000;
21
+ /**
22
+ * For an `images[].url` produced from a `[media attached: …]` marker: returns the
23
+ * server-local filesystem path to resolve (`file://…` or a bare absolute path), or
24
+ * null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
25
+ * remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
26
+ * `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
27
+ */
28
+ function serverLocalPathForImageUrl(url) {
29
+ if (url.startsWith("file://")) {
30
+ try {
31
+ return fileURLToPath(url);
32
+ }
33
+ catch {
34
+ return url.slice("file://".length);
35
+ }
36
+ }
37
+ if (url.startsWith("/friday-next/files/"))
38
+ return null;
39
+ if (url.startsWith("/"))
40
+ return url;
41
+ return null;
42
+ }
20
43
  function resolveSubagentApi() {
21
44
  try {
22
45
  const runtime = getFridayNextRuntime();
@@ -75,16 +98,34 @@ export async function handleHistoryMessages(req, res) {
75
98
  // (copies the file into the plugin's attachments/ dir — the same mechanism the
76
99
  // live deliver path uses), then drop the raw paths from the wire.
77
100
  for (const message of messages) {
78
- if (!message.mediaPaths?.length)
79
- continue;
80
- const resolved = message.mediaPaths
81
- .map((p) => resolveMediaAttachment(p))
82
- .filter((r) => Boolean(r))
83
- .map((r) => ({ url: r.url, filename: r.fileName }));
84
- if (resolved.length) {
85
- message.images = [...(message.images ?? []), ...resolved];
101
+ if (message.mediaPaths?.length) {
102
+ const resolved = message.mediaPaths
103
+ .map((p) => resolveMediaAttachment(p))
104
+ .filter((r) => Boolean(r))
105
+ .map((r) => ({ url: r.url, filename: r.fileName }));
106
+ if (resolved.length) {
107
+ message.images = [...(message.images ?? []), ...resolved];
108
+ }
109
+ delete message.mediaPaths;
110
+ }
111
+ // User attachments arrive as `[media attached: file://<server-path>]` markers,
112
+ // which normalize-message extracts into images[].url as a RAW server-local path.
113
+ // Unlike MEDIA: paths (resolved above), these were never copied into the file
114
+ // store, so the app would try to load a path that only exists on the gateway host
115
+ // and the attachment bubble is lost on history sync. Resolve them the same way.
116
+ if (message.images?.length) {
117
+ message.images = message.images.map((img) => {
118
+ if (!img.url || img.data)
119
+ return img;
120
+ const local = serverLocalPathForImageUrl(img.url);
121
+ if (!local)
122
+ return img;
123
+ const resolved = resolveMediaAttachment(local);
124
+ if (!resolved)
125
+ return img;
126
+ return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
127
+ });
86
128
  }
87
- delete message.mediaPaths;
88
129
  }
89
130
  const sessionId = resolveSessionId(sessionKey);
90
131
  // Cumulative session-usage snapshot (model + context window/used) read from the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -170,6 +170,39 @@ describe("handleHistoryMessages", () => {
170
170
  expect(body.messages[0].text).toBe("from app");
171
171
  });
172
172
 
173
+ it("resolves user [media attached: file://] markers into downloadable /friday-next/files URLs", async () => {
174
+ // The server-local source the marker points at (only exists on the gateway host).
175
+ const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "friday-inbound-"));
176
+ const srcFile = path.join(srcDir, "ce1ff405-28ad-48b9-b4a7-4f2228d77649.jpg");
177
+ fs.writeFileSync(srcFile, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]));
178
+ const file = writeTranscript("media.jsonl", [
179
+ {
180
+ type: "message",
181
+ id: "u1",
182
+ message: {
183
+ role: "user",
184
+ content: `你见过这个可乐吗?\n\n[media attached: file://${srcFile}]`,
185
+ },
186
+ },
187
+ ]);
188
+ setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
189
+
190
+ const res = new MockRes();
191
+ await handleHistoryMessages(
192
+ makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
193
+ res as any,
194
+ );
195
+ expect(res.statusCode).toBe(200);
196
+ const body = JSON.parse(res.body);
197
+ const userMsg = body.messages.find((m: any) => m.role === "user");
198
+ expect(userMsg.images?.length).toBe(1);
199
+ // The raw server-local file:// path must be resolved to a gateway-served URL the
200
+ // app can actually download — otherwise the attachment bubble is lost on history sync.
201
+ expect(userMsg.images[0].url.startsWith("file://")).toBe(false);
202
+ expect(userMsg.images[0].url.startsWith("/friday-next/files/")).toBe(true);
203
+ fs.rmSync(srcDir, { recursive: true, force: true });
204
+ });
205
+
173
206
  it("falls back to getSessionMessages when the transcript is not on disk", async () => {
174
207
  setForward({}); // no entry → disk read yields nothing
175
208
  setRuntime(async () => ({
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { IncomingMessage, ServerResponse } from "node:http";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { getFridayNextRuntime } from "../../runtime.js";
15
16
  import { extractBearerToken } from "../middleware/auth.js";
16
17
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
@@ -28,6 +29,26 @@ type SubagentSessionApi = {
28
29
  }) => Promise<{ messages?: unknown[] }>;
29
30
  };
30
31
 
32
+ /**
33
+ * For an `images[].url` produced from a `[media attached: …]` marker: returns the
34
+ * server-local filesystem path to resolve (`file://…` or a bare absolute path), or
35
+ * null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
36
+ * remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
37
+ * `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
38
+ */
39
+ function serverLocalPathForImageUrl(url: string): string | null {
40
+ if (url.startsWith("file://")) {
41
+ try {
42
+ return fileURLToPath(url);
43
+ } catch {
44
+ return url.slice("file://".length);
45
+ }
46
+ }
47
+ if (url.startsWith("/friday-next/files/")) return null;
48
+ if (url.startsWith("/")) return url;
49
+ return null;
50
+ }
51
+
31
52
  function resolveSubagentApi(): SubagentSessionApi | undefined {
32
53
  try {
33
54
  const runtime = getFridayNextRuntime();
@@ -95,15 +116,32 @@ export async function handleHistoryMessages(
95
116
  // (copies the file into the plugin's attachments/ dir — the same mechanism the
96
117
  // live deliver path uses), then drop the raw paths from the wire.
97
118
  for (const message of messages) {
98
- if (!message.mediaPaths?.length) continue;
99
- const resolved = message.mediaPaths
100
- .map((p) => resolveMediaAttachment(p))
101
- .filter((r): r is NonNullable<typeof r> => Boolean(r))
102
- .map((r) => ({ url: r.url, filename: r.fileName }));
103
- if (resolved.length) {
104
- message.images = [...(message.images ?? []), ...resolved];
119
+ if (message.mediaPaths?.length) {
120
+ const resolved = message.mediaPaths
121
+ .map((p) => resolveMediaAttachment(p))
122
+ .filter((r): r is NonNullable<typeof r> => Boolean(r))
123
+ .map((r) => ({ url: r.url, filename: r.fileName }));
124
+ if (resolved.length) {
125
+ message.images = [...(message.images ?? []), ...resolved];
126
+ }
127
+ delete message.mediaPaths;
128
+ }
129
+
130
+ // User attachments arrive as `[media attached: file://<server-path>]` markers,
131
+ // which normalize-message extracts into images[].url as a RAW server-local path.
132
+ // Unlike MEDIA: paths (resolved above), these were never copied into the file
133
+ // store, so the app would try to load a path that only exists on the gateway host
134
+ // and the attachment bubble is lost on history sync. Resolve them the same way.
135
+ if (message.images?.length) {
136
+ message.images = message.images.map((img) => {
137
+ if (!img.url || img.data) return img;
138
+ const local = serverLocalPathForImageUrl(img.url);
139
+ if (!local) return img;
140
+ const resolved = resolveMediaAttachment(local);
141
+ if (!resolved) return img;
142
+ return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
143
+ });
105
144
  }
106
- delete message.mediaPaths;
107
145
  }
108
146
 
109
147
  const sessionId = resolveSessionId(sessionKey);