@syengup/friday-channel-next 0.0.1

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.
Files changed (67) hide show
  1. package/README.md +35 -0
  2. package/index.ts +191 -0
  3. package/install.mjs +158 -0
  4. package/install.sh +118 -0
  5. package/openclaw.plugin.json +53 -0
  6. package/package.json +65 -0
  7. package/src/agent/abort-run.ts +10 -0
  8. package/src/agent/active-runs.ts +26 -0
  9. package/src/agent/dispatch-bridge.ts +18 -0
  10. package/src/agent/media-bridge.ts +23 -0
  11. package/src/agent-forward-runtime.ts +30 -0
  12. package/src/agent-run-context-bridge.ts +32 -0
  13. package/src/channel-actions.ts +129 -0
  14. package/src/channel.ts +284 -0
  15. package/src/collect-message-media-paths.ts +132 -0
  16. package/src/config.test.ts +33 -0
  17. package/src/config.ts +64 -0
  18. package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
  19. package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
  20. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
  21. package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
  22. package/src/e2e/offline-replay.e2e.test.ts +43 -0
  23. package/src/e2e/send-text.e2e.test.ts +73 -0
  24. package/src/e2e/slash-commands.e2e.test.ts +33 -0
  25. package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
  26. package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
  27. package/src/friday-inbound-stats.ts +10 -0
  28. package/src/friday-session.forward-agent.test.ts +270 -0
  29. package/src/friday-session.ts +327 -0
  30. package/src/host-config.ts +20 -0
  31. package/src/http/handlers/cancel.test.ts +70 -0
  32. package/src/http/handlers/cancel.ts +35 -0
  33. package/src/http/handlers/files-download.ts +239 -0
  34. package/src/http/handlers/files-upload.ts +166 -0
  35. package/src/http/handlers/files.ts +335 -0
  36. package/src/http/handlers/messages.test.ts +119 -0
  37. package/src/http/handlers/messages.ts +555 -0
  38. package/src/http/handlers/models-list.ts +126 -0
  39. package/src/http/handlers/sessions-delete.ts +59 -0
  40. package/src/http/handlers/sessions-settings.ts +90 -0
  41. package/src/http/handlers/sse.test.ts +71 -0
  42. package/src/http/handlers/sse.ts +84 -0
  43. package/src/http/handlers/status.test.ts +52 -0
  44. package/src/http/handlers/status.ts +33 -0
  45. package/src/http/middleware/auth.test.ts +46 -0
  46. package/src/http/middleware/auth.ts +31 -0
  47. package/src/http/middleware/body.test.ts +27 -0
  48. package/src/http/middleware/body.ts +28 -0
  49. package/src/http/middleware/cors.test.ts +40 -0
  50. package/src/http/middleware/cors.ts +12 -0
  51. package/src/http/server.ts +106 -0
  52. package/src/logging.ts +27 -0
  53. package/src/openclaw.d.ts +32 -0
  54. package/src/run-metadata.ts +180 -0
  55. package/src/runtime.ts +14 -0
  56. package/src/session/session-manager.ts +230 -0
  57. package/src/session-usage-snapshot.ts +80 -0
  58. package/src/sse/emitter.test.ts +85 -0
  59. package/src/sse/emitter.ts +249 -0
  60. package/src/sse/frame-format.test.ts +56 -0
  61. package/src/sse/offline-queue.test.ts +65 -0
  62. package/src/sse/offline-queue.ts +140 -0
  63. package/src/test-support/app-simulator.ts +243 -0
  64. package/src/test-support/mock-dispatch.ts +181 -0
  65. package/src/test-support/mock-runtime.ts +74 -0
  66. package/src/vendor/runtime-store.ts +99 -0
  67. package/tsconfig.json +17 -0
@@ -0,0 +1,23 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+
6
+ export async function saveInboundMediaBuffer(
7
+ buffer: Buffer,
8
+ mimeType: string,
9
+ ): Promise<{ id: string; path: string }> {
10
+ try {
11
+ const sdk = await import("openclaw/plugin-sdk/media-store");
12
+ const saved = await sdk.saveMediaBuffer(buffer, mimeType, "inbound");
13
+ if (saved?.id && saved?.path) return { id: saved.id, path: saved.path };
14
+ } catch {
15
+ // fallback for tests or stripped runtime
16
+ }
17
+ const id = crypto.randomUUID();
18
+ const dir = path.join(os.tmpdir(), "friday-next-media");
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ const p = path.join(dir, id);
21
+ fs.writeFileSync(p, buffer);
22
+ return { id, path: p };
23
+ }
@@ -0,0 +1,30 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
+
3
+ export type FridayAgentForwardRuntime = {
4
+ resolveStorePath: (store?: string, opts?: { agentId?: string }) => string;
5
+ loadSessionStore: (
6
+ path: string,
7
+ options?: { skipCache?: boolean; maintenanceConfig?: unknown; clone?: boolean },
8
+ ) => Record<string, unknown>;
9
+ getConfig: () => unknown;
10
+ };
11
+
12
+ let forwardRuntime: FridayAgentForwardRuntime | null = null;
13
+
14
+ /** Called from `registerFull` so terminal lifecycle forwards can read `sessions.json` after persist. */
15
+ export function setFridayAgentForwardRuntime(api: OpenClawPluginApi): void {
16
+ forwardRuntime = {
17
+ resolveStorePath: api.runtime.agent.session.resolveStorePath,
18
+ loadSessionStore: api.runtime.agent.session.loadSessionStore,
19
+ getConfig: () => api.runtime.config.current(),
20
+ };
21
+ }
22
+
23
+ export function getFridayAgentForwardRuntime(): FridayAgentForwardRuntime | null {
24
+ return forwardRuntime;
25
+ }
26
+
27
+ /** Vitest-only */
28
+ export function resetFridayAgentForwardRuntimeForTest(): void {
29
+ forwardRuntime = null;
30
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Read OpenClaw agent run context (sessionKey, …) from the same global singleton
3
+ * as `src/infra/agent-events.ts` (`Symbol.for("openclaw.agentEvents.state")`).
4
+ *
5
+ * When a run is hidden from Control UI, `emitAgentEvent` strips `sessionKey` from
6
+ * the listener payload, but `runContextById` still holds it — we need that for
7
+ * Friday SSE and tool hooks without importing the `openclaw` package from this folder.
8
+ */
9
+
10
+ const AGENT_EVENT_STATE_KEY = Symbol.for("openclaw.agentEvents.state");
11
+
12
+ export type OpenClawAgentRunContextBridge = {
13
+ sessionKey?: string;
14
+ isControlUiVisible?: boolean;
15
+ };
16
+
17
+ type AgentEventStateLike = {
18
+ runContextById: Map<string, OpenClawAgentRunContextBridge>;
19
+ };
20
+
21
+ function getAgentEventState(): AgentEventStateLike | undefined {
22
+ const raw = (globalThis as Record<PropertyKey, unknown>)[AGENT_EVENT_STATE_KEY];
23
+ if (!raw || typeof raw !== "object") return undefined;
24
+ const runContextById = (raw as { runContextById?: unknown }).runContextById;
25
+ if (!(runContextById instanceof Map)) return undefined;
26
+ return { runContextById };
27
+ }
28
+
29
+ export function getOpenClawAgentRunContext(runId: string): OpenClawAgentRunContextBridge | undefined {
30
+ if (!runId) return undefined;
31
+ return getAgentEventState()?.runContextById.get(runId);
32
+ }
@@ -0,0 +1,129 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { sseEmitter } from "./sse/emitter.js";
4
+ import { guessMimeType } from "./http/handlers/files.js";
5
+
6
+ type MessageActionCtx = {
7
+ action: string;
8
+ params: Record<string, unknown>;
9
+ mediaReadFile?: (filePath: string) => Promise<Buffer>;
10
+ sessionKey?: string | null;
11
+ requesterSenderId?: string | null;
12
+ };
13
+
14
+ const DISCOVERY = {
15
+ actions: ["send", "channel-info", "channel-list"] as const,
16
+ capabilities: ["text", "media"] as const,
17
+ };
18
+
19
+ const CHANNEL_INFO_RESPONSE = {
20
+ ok: true as const,
21
+ channels: [{ id: "friday-next", name: "Friday Next", transport: "http+sse" }],
22
+ };
23
+
24
+ export function describeMessageActions() {
25
+ return DISCOVERY;
26
+ }
27
+
28
+ function pickString(params: Record<string, unknown>, keys: string[]): string {
29
+ for (const k of keys) {
30
+ const v = params[k];
31
+ if (typeof v === "string" && v.trim()) return v.trim();
32
+ }
33
+ return "";
34
+ }
35
+
36
+ async function readMediaFile(
37
+ mediaPath: string,
38
+ ctx: MessageActionCtx,
39
+ ): Promise<{ buffer: Buffer; mimeType: string } | null> {
40
+ if (ctx.mediaReadFile) {
41
+ try {
42
+ const buffer = await ctx.mediaReadFile(mediaPath);
43
+ if (buffer?.length) {
44
+ return { buffer, mimeType: guessMimeType(mediaPath) };
45
+ }
46
+ } catch { /* fall through */ }
47
+ }
48
+ try {
49
+ const buffer = fs.readFileSync(mediaPath);
50
+ return { buffer, mimeType: guessMimeType(mediaPath) };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
57
+ const to = pickString(ctx.params, ["to", "target"]).toUpperCase();
58
+ const text = pickString(ctx.params, ["message", "text", "content"]);
59
+ const mediaPath = pickString(ctx.params, ["media", "path", "filePath", "fileUrl"]);
60
+ const caption = pickString(ctx.params, ["caption"]);
61
+
62
+ if (!to) {
63
+ return { ok: false, error: "Missing required param: to" };
64
+ }
65
+
66
+ const runId = crypto.randomUUID();
67
+ const sessionKey = ctx.sessionKey ?? undefined;
68
+
69
+ // Send text via SSE outbound
70
+ if (text) {
71
+ sseEmitter.broadcast(
72
+ {
73
+ type: "outbound",
74
+ data: {
75
+ op: "text",
76
+ ts: Date.now(),
77
+ runId,
78
+ deviceId: to,
79
+ sessionKey,
80
+ ctx: { text, to },
81
+ },
82
+ },
83
+ to,
84
+ true,
85
+ );
86
+ }
87
+
88
+ // Send media via SSE outbound
89
+ if (mediaPath) {
90
+ const result = await readMediaFile(mediaPath, ctx);
91
+ if (result) {
92
+ const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
93
+ const saved = await saveMediaBuffer(result.buffer, result.mimeType, "inbound");
94
+ if (saved.id) {
95
+ const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
96
+ sseEmitter.broadcast(
97
+ {
98
+ type: "outbound",
99
+ data: {
100
+ op: "media",
101
+ ts: Date.now(),
102
+ runId,
103
+ deviceId: to,
104
+ sessionKey,
105
+ audioAsVoice: false,
106
+ caption: caption || text,
107
+ mediaUrl: publicUrl,
108
+ ctx: { to, text: caption || text, originalMediaUrl: mediaPath },
109
+ },
110
+ },
111
+ to,
112
+ true,
113
+ );
114
+ }
115
+ }
116
+ }
117
+
118
+ return { ok: true, runId, to };
119
+ }
120
+
121
+ export async function handleMessageAction(ctx: MessageActionCtx): Promise<unknown> {
122
+ if (ctx.action === "channel-info" || ctx.action === "channel-list") {
123
+ return CHANNEL_INFO_RESPONSE;
124
+ }
125
+ if (ctx.action === "send") {
126
+ return handleSend(ctx);
127
+ }
128
+ return null;
129
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Friday Next Channel Plugin Definition.
3
+ *
4
+ * HTTP/SSE bridge for the Friday app; outbound sendText/sendMedia are forwarded as `outbound` SSE events.
5
+ */
6
+
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
11
+ import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
12
+ import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
13
+ import { sseEmitter } from "./sse/emitter.js";
14
+ import { describeMessageActions, handleMessageAction } from "./channel-actions.js";
15
+ import { guessMimeType, resolveMediaAttachment } from "./http/handlers/files.js";
16
+ import {
17
+ resolveFridayDeviceIdForOutbound,
18
+ resolveHistorySessionKeyForFridayDevice,
19
+ } from "./friday-session.js";
20
+ import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
21
+
22
+ const CHANNEL_ID = "friday-next" as const;
23
+
24
+ function pickFirstString(source: Record<string, unknown>, keys: string[]): string | undefined {
25
+ for (const key of keys) {
26
+ const val = source[key];
27
+ if (typeof val === "string" && val.trim()) return val.trim();
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ function resolveLocalMediaPath(mediaUrl: string, localRoots?: string[]): string {
33
+ if (path.isAbsolute(mediaUrl)) return mediaUrl;
34
+ const roots = localRoots ?? [process.cwd(), "/tmp"];
35
+ for (const root of roots) {
36
+ const candidate = path.join(root, mediaUrl);
37
+ if (fs.existsSync(candidate)) return candidate;
38
+ }
39
+ return path.join(process.cwd(), mediaUrl);
40
+ }
41
+
42
+ const fridayConfigAdapter = {
43
+ listAccountIds: () => ["default"],
44
+ resolveAccount: () => ({ accountId: "default", enabled: true }),
45
+ defaultAccountId: () => "default",
46
+ isConfigured: () => true,
47
+ unconfiguredReason: () => null,
48
+ describeAccount: () => ({ accountId: "default", name: "Friday Next Channel", enabled: true }),
49
+ };
50
+
51
+ const fridayMeta = {
52
+ id: CHANNEL_ID,
53
+ label: "Friday Next",
54
+ selectionLabel: "Friday Next (Apple App)",
55
+ docsPath: "/channels/friday-next",
56
+ blurb: "Apple app channel with HTTP + SSE transparent OpenClaw proxy.",
57
+ };
58
+
59
+ const fridayCapabilities = {
60
+ chatTypes: ["direct"] as const,
61
+ markdown: true,
62
+ media: true,
63
+ reactions: false,
64
+ edit: false,
65
+ threads: false,
66
+ polls: false,
67
+ typing: false,
68
+ readReceipts: false,
69
+ };
70
+
71
+ const fridayLifecycle = {
72
+ async onAccountConfigChanged() {
73
+ // No-op
74
+ },
75
+ };
76
+
77
+ const fridayStatus = {
78
+ buildAccountSnapshot: async (params: {
79
+ account: { accountId?: string; name?: string; enabled?: boolean };
80
+ runtime?: ChannelAccountSnapshot;
81
+ }): Promise<ChannelAccountSnapshot> => {
82
+ const { account, runtime } = params;
83
+ const accountId =
84
+ typeof account?.accountId === "string" && account.accountId.trim()
85
+ ? account.accountId.trim()
86
+ : "default";
87
+ const inbound = getLastFridayInboundAt();
88
+ const connected = sseEmitter.getConnectionCount() > 0;
89
+ return {
90
+ accountId,
91
+ name: typeof account?.name === "string" ? account.name : "Friday Next Channel",
92
+ enabled: account?.enabled !== false,
93
+ configured: true,
94
+ running: true,
95
+ connected,
96
+ lastInboundAt: inbound ?? runtime?.lastInboundAt ?? null,
97
+ mode: "http+sse",
98
+ };
99
+ },
100
+ };
101
+
102
+ export const fridayNextChannelPlugin = createChatChannelPlugin({
103
+ base: {
104
+ id: CHANNEL_ID,
105
+ meta: fridayMeta,
106
+ actions: {
107
+ describeMessageTool: describeMessageActions,
108
+ handleAction: handleMessageAction,
109
+ },
110
+ capabilities: fridayCapabilities,
111
+ defaults: {
112
+ queue: { debounceMs: 300 },
113
+ },
114
+ config: fridayConfigAdapter,
115
+ lifecycle: fridayLifecycle,
116
+ status: fridayStatus,
117
+ bindings: {
118
+ compileConfiguredBinding: () => null,
119
+ matchInboundConversation: () => null,
120
+ resolveCommandConversation: () => null,
121
+ },
122
+ conversationBindings: {
123
+ supportsCurrentConversationBinding: false,
124
+ },
125
+ messaging: {
126
+ normalizeTarget: (raw: string) => {
127
+ const trimmed = raw?.trim() ?? "";
128
+ return trimmed || "friday-next";
129
+ },
130
+ targetResolver: {
131
+ hint: "Use the deviceId (e.g. your device identifier).",
132
+ resolveTarget: async (ctx: any) => {
133
+ return { to: ctx.normalized };
134
+ },
135
+ },
136
+ parseExplicitTarget: () => ({ to: "friday-next" }),
137
+ formatTargetDisplay: ({ display }: any) => display || "Friday Next",
138
+ },
139
+ },
140
+ outbound: {
141
+ deliveryMode: "direct" as const,
142
+ sendText: async (ctx: any) => {
143
+ const text = ctx.text ?? "";
144
+ const rawCtx = ctx as unknown as Record<string, unknown>;
145
+ const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
146
+ const runIdFromCtx = pickFirstString(rawCtx, [
147
+ "parentRunId",
148
+ "requesterRunId",
149
+ "originRunId",
150
+ "runId",
151
+ ]);
152
+ const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
153
+ const sessionKey =
154
+ pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
155
+ resolveHistorySessionKeyForFridayDevice(deviceId);
156
+
157
+ const conn = sseEmitter.getConnection(deviceId);
158
+ const ts = new Date().toISOString();
159
+ console.error(
160
+ `[Friday-OUT] [${ts}] [SEND_TEXT] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} textLen=${text.length} online=${!!conn}`,
161
+ );
162
+
163
+ if (conn) {
164
+ sseEmitter.broadcast(
165
+ {
166
+ type: "outbound",
167
+ data: {
168
+ op: "text",
169
+ ts: Date.now(),
170
+ runId,
171
+ deviceId,
172
+ sessionKey,
173
+ ctx: {
174
+ text,
175
+ to: ctx.to,
176
+ mediaUrl: ctx.mediaUrl,
177
+ audioAsVoice: ctx.audioAsVoice,
178
+ },
179
+ },
180
+ },
181
+ deviceId,
182
+ true,
183
+ );
184
+ }
185
+
186
+ return {
187
+ channel: CHANNEL_ID,
188
+ messageId: crypto.randomUUID(),
189
+ timestamp: Date.now(),
190
+ };
191
+ },
192
+ sendMedia: async (ctx: any) => {
193
+ const rawCtx = ctx as unknown as Record<string, unknown>;
194
+ const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
195
+ const mediaUrl = ctx.mediaUrl;
196
+ const runIdFromCtx = pickFirstString(rawCtx, [
197
+ "parentRunId",
198
+ "requesterRunId",
199
+ "originRunId",
200
+ "runId",
201
+ ]);
202
+ const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
203
+ const sessionKey =
204
+ pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
205
+ resolveHistorySessionKeyForFridayDevice(deviceId);
206
+ const audioAsVoice = ctx.audioAsVoice === true;
207
+ const caption = ctx.text ?? "";
208
+
209
+ if (!mediaUrl) {
210
+ return {
211
+ channel: CHANNEL_ID,
212
+ messageId: crypto.randomUUID(),
213
+ timestamp: Date.now(),
214
+ };
215
+ }
216
+
217
+ let buffer: Buffer | null = null;
218
+
219
+ if (ctx.mediaReadFile) {
220
+ try {
221
+ buffer = await ctx.mediaReadFile(mediaUrl);
222
+ } catch {
223
+ // fall through to fs
224
+ }
225
+ }
226
+
227
+ if (!buffer) {
228
+ try {
229
+ const resolvedPath = resolveLocalMediaPath(mediaUrl, ctx.mediaLocalRoots);
230
+ buffer = fs.readFileSync(resolvedPath);
231
+ } catch {
232
+ // file not found — skip media
233
+ }
234
+ }
235
+
236
+ if (buffer) {
237
+ const mimeType = guessMimeType(mediaUrl);
238
+ const saved = await saveMediaBuffer(buffer, mimeType, "inbound");
239
+ if (saved.id) {
240
+ const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
241
+ const resolved = resolveMediaAttachment(fileUrl);
242
+ const publicUrl = resolved ? resolved.url : fileUrl;
243
+
244
+ const conn = sseEmitter.getConnection(deviceId);
245
+ const ts = new Date().toISOString();
246
+ console.error(
247
+ `[Friday-OUT] [${ts}] [SEND_MEDIA] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} audioAsVoice=${audioAsVoice} url=${publicUrl} online=${!!conn}`,
248
+ );
249
+
250
+ if (conn) {
251
+ sseEmitter.broadcast(
252
+ {
253
+ type: "outbound",
254
+ data: {
255
+ op: "media",
256
+ ts: Date.now(),
257
+ runId,
258
+ deviceId,
259
+ sessionKey,
260
+ audioAsVoice,
261
+ caption,
262
+ mediaUrl: publicUrl,
263
+ ctx: {
264
+ to: ctx.to,
265
+ text: caption,
266
+ originalMediaUrl: mediaUrl,
267
+ },
268
+ },
269
+ },
270
+ deviceId,
271
+ true,
272
+ );
273
+ }
274
+ }
275
+ }
276
+
277
+ return {
278
+ channel: CHANNEL_ID,
279
+ messageId: crypto.randomUUID(),
280
+ timestamp: Date.now(),
281
+ };
282
+ },
283
+ },
284
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Extract filesystem / Friday file URLs from message tool result or params (nested JSON).
3
+ */
4
+
5
+ /** Bare path string when JSON.parse fails (e.g. not JSON); not http(s) URLs. */
6
+ function looksLikeLocalFilePath(v: string): boolean {
7
+ const t = v.trim();
8
+ if (t.length < 2 || /^https?:\/\//i.test(t)) return false;
9
+ const abs =
10
+ t.startsWith("/") ||
11
+ t.startsWith("~/") ||
12
+ t.startsWith("~\\") ||
13
+ /^file:/i.test(t) ||
14
+ /^[a-zA-Z]:[\\/]/.test(t);
15
+ if (!abs) return false;
16
+ const lastSeg = t.split(/[/\\]/).filter(Boolean).pop() ?? "";
17
+ return lastSeg.includes(".");
18
+ }
19
+
20
+ export function collectMediaPathsFromToolResult(raw: unknown, acc?: Set<string>): Set<string> {
21
+ const out = acc ?? new Set<string>();
22
+ const add = (s: string) => {
23
+ const t = s.trim();
24
+ if (t.length > 0) out.add(t);
25
+ };
26
+ const visit = (v: unknown): void => {
27
+ if (v == null) return;
28
+ if (typeof v === "string") {
29
+ try {
30
+ visit(JSON.parse(v));
31
+ } catch {
32
+ if (looksLikeLocalFilePath(v)) add(v);
33
+ }
34
+ return;
35
+ }
36
+ if (typeof v !== "object") return;
37
+ if (Array.isArray(v)) {
38
+ for (const x of v) visit(x);
39
+ return;
40
+ }
41
+ const o = v as Record<string, unknown>;
42
+ const mu = o.mediaUrls;
43
+ if (Array.isArray(mu)) for (const x of mu) if (typeof x === "string") add(x);
44
+ const m = o.mediaUrl;
45
+ if (typeof m === "string") add(m);
46
+ const audioPath = o.audioPath;
47
+ if (typeof audioPath === "string") add(audioPath);
48
+ const media = o.media;
49
+ if (typeof media === "string") add(media);
50
+ else if (media && typeof media === "object" && !Array.isArray(media)) visit(media);
51
+ const filePath = o.filePath;
52
+ if (typeof filePath === "string") add(filePath);
53
+ for (const k of ["details", "result", "content", "text", "body", "message", "arguments", "args"]) {
54
+ if (o[k] !== undefined) visit(o[k]);
55
+ }
56
+ for (const val of Object.values(o)) {
57
+ if (typeof val !== "string") continue;
58
+ const s = val.trimStart();
59
+ if (s.startsWith("{") || s.startsWith("[")) visit(val);
60
+ }
61
+ };
62
+ visit(raw);
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Scan the same tool `text` string that is sent on SSE (often JSON.stringify(result)).
68
+ * Inner nested JSON uses escaped quotes (\"), so keys like "mediaUrl" may not match
69
+ * naive JSON walks on the outer value. Unescaped absolute paths still appear verbatim
70
+ * (e.g. /Users/.../file.md) and are extracted here.
71
+ */
72
+ export function extractLocalPathsFromToolTextBlob(s: string): Set<string> {
73
+ const out = new Set<string>();
74
+ const add = (raw: string) => {
75
+ const t = raw.trim();
76
+ if (t.length > 0 && looksLikeLocalFilePath(t)) out.add(t);
77
+ };
78
+ if (!s || s.length < 8) return out;
79
+
80
+ /**
81
+ * After JSON.stringify(toolResult), nested JSON appears as key\"\":\"value\" in the outer string,
82
+ * e.g. mediaUrl\":\"/Users/me/file.md\" — not \"mediaUrl\".
83
+ */
84
+ for (const m of s.matchAll(/mediaUrl\\":\\"([^"\\]+)\\"/gi)) {
85
+ add(m[1] ?? "");
86
+ }
87
+ for (const m of s.matchAll(/media\\":\\"([^"\\]+)\\"/gi)) {
88
+ add(m[1] ?? "");
89
+ }
90
+ for (const m of s.matchAll(/filePath\\":\\"([^"\\]+)\\"/gi)) {
91
+ add(m[1] ?? "");
92
+ }
93
+
94
+ // "mediaUrls":["/a","/b"] → in outer stringify: mediaUrls\":[\"/a\",\"/b\"]
95
+ for (const m of s.matchAll(/mediaUrls\\":\[((?:\\"[^"\\]+\\",?)+)\]/gi)) {
96
+ const inner = m[1] ?? "";
97
+ for (const q of inner.matchAll(/\\"([^"\\]+)\\"/g)) {
98
+ add(q[1] ?? "");
99
+ }
100
+ }
101
+
102
+ // Unescaped JSON fragments (raw object / pretty-print)
103
+ for (const m of s.matchAll(/"mediaUrl"\s*:\s*"([^"\\]+)"/gi)) {
104
+ add(m[1] ?? "");
105
+ }
106
+ for (const m of s.matchAll(/"mediaUrls"\s*:\s*\[([\s\S]*?)\]/gi)) {
107
+ const inner = m[1] ?? "";
108
+ for (const q of inner.matchAll(/"([^"\\]*)"/g)) {
109
+ add(q[1] ?? "");
110
+ }
111
+ }
112
+
113
+ // Verbatim /Users/.../file.ext (stop before quote or backslash — avoids eating JSON commas)
114
+ for (const m of s.matchAll(/(\/Users\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
115
+ add(m[1]!);
116
+ }
117
+ for (const m of s.matchAll(/(\/private\/var\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
118
+ add(m[1]!);
119
+ }
120
+ for (const m of s.matchAll(/(\/tmp\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
121
+ add(m[1]!);
122
+ }
123
+ for (const m of s.matchAll(/(\/home\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
124
+ add(m[1]!);
125
+ }
126
+
127
+ for (const m of s.matchAll(/([A-Za-z]:\\[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
128
+ add(m[1]!);
129
+ }
130
+
131
+ return out;
132
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveFridayNextConfig } from "./config.js";
3
+
4
+ describe("resolveFridayNextConfig", () => {
5
+ it("uses defaults", () => {
6
+ const cfg = resolveFridayNextConfig({});
7
+ expect(cfg.channelId).toBe("friday-next");
8
+ expect(cfg.pathPrefix).toBe("/friday-next");
9
+ expect(cfg.sseKeepaliveSec).toBe(30);
10
+ });
11
+
12
+ it("prefers gateway auth token over channel token", () => {
13
+ const cfg = resolveFridayNextConfig({
14
+ gateway: { auth: { token: "g1" } },
15
+ channels: { "friday-next": { authToken: "c1" } },
16
+ });
17
+ expect(cfg.authToken).toBe("g1");
18
+ });
19
+
20
+ it("clamps numeric settings to schema bounds", () => {
21
+ const cfg = resolveFridayNextConfig({
22
+ channels: {
23
+ "friday-next": {
24
+ historyLimit: 9999,
25
+ sse: { keepaliveSec: 1, backlogPerDevice: -2 },
26
+ },
27
+ },
28
+ });
29
+ expect(cfg.historyLimit).toBe(200);
30
+ expect(cfg.sseKeepaliveSec).toBe(5);
31
+ expect(cfg.sseBacklogPerDevice).toBe(0);
32
+ });
33
+ });