@syengup/friday-channel-next 0.1.36 → 0.1.37
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/dist/index.js +1 -1
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
- package/dist/src/agent/node-pairing-bridge.js +6 -2
- package/dist/src/agent/subagent-registry.js +0 -3
- package/dist/src/channel-actions.js +3 -1
- package/dist/src/channel.js +0 -2
- package/dist/src/collect-message-media-paths.js +10 -1
- package/dist/src/friday-session.js +34 -10
- package/dist/src/history/normalize-message.js +22 -8
- package/dist/src/http/handlers/agent-config.js +10 -4
- package/dist/src/http/handlers/cancel.js +4 -2
- package/dist/src/http/handlers/device-approve.js +3 -1
- package/dist/src/http/handlers/files-download.js +6 -8
- package/dist/src/http/handlers/files.js +1 -1
- package/dist/src/http/handlers/health.js +18 -4
- package/dist/src/http/handlers/history-messages.js +1 -1
- package/dist/src/http/handlers/history-sessions.js +5 -3
- package/dist/src/http/handlers/messages.js +25 -11
- package/dist/src/http/handlers/models-list.js +1 -1
- package/dist/src/http/handlers/nodes-approve.js +1 -6
- package/dist/src/http/handlers/plugin-info.js +1 -1
- package/dist/src/http/server.js +4 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/plugin-install-info.js +4 -1
- package/dist/src/session/session-manager.js +9 -3
- package/dist/src/session-usage-store.js +3 -1
- package/dist/src/skills-discovery.d.ts +5 -4
- package/dist/src/skills-discovery.js +27 -22
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/tool-catalog.js +2 -3
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +3 -1
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +2 -3
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.ts +9 -2
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +10 -4
- package/src/channel-actions.ts +3 -1
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +121 -123
- package/src/collect-message-media-paths.ts +15 -6
- package/src/config.ts +1 -4
- package/src/e2e/agents-list.e2e.test.ts +9 -2
- package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
- package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
- package/src/e2e/auto-approve.integration.test.ts +13 -7
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
- package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
- package/src/e2e/offline-replay.e2e.test.ts +17 -3
- package/src/e2e/send-text.e2e.test.ts +11 -2
- package/src/e2e/slash-commands.e2e.test.ts +5 -1
- package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
- package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
- package/src/e2e/subagent.e2e.test.ts +136 -53
- package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
- package/src/friday-session.forward-agent.test.ts +44 -12
- package/src/friday-session.ts +44 -20
- package/src/history/normalize-message.test.ts +35 -8
- package/src/history/normalize-message.ts +24 -12
- package/src/history/read-transcript.ts +1 -4
- package/src/http/handlers/agent-config.test.ts +10 -3
- package/src/http/handlers/agent-config.ts +22 -8
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/cancel.test.ts +12 -3
- package/src/http/handlers/cancel.ts +4 -2
- package/src/http/handlers/device-approve.test.ts +12 -3
- package/src/http/handlers/device-approve.ts +33 -21
- package/src/http/handlers/files-download.ts +17 -13
- package/src/http/handlers/files.test.ts +8 -2
- package/src/http/handlers/files.ts +21 -7
- package/src/http/handlers/health.test.ts +43 -11
- package/src/http/handlers/health.ts +22 -6
- package/src/http/handlers/history-messages.test.ts +51 -9
- package/src/http/handlers/history-messages.ts +4 -1
- package/src/http/handlers/history-sessions.test.ts +46 -9
- package/src/http/handlers/history-sessions.ts +5 -3
- package/src/http/handlers/history-set-title.test.ts +14 -5
- package/src/http/handlers/link-preview.test.ts +57 -16
- package/src/http/handlers/link-preview.ts +4 -1
- package/src/http/handlers/messages.test.ts +12 -8
- package/src/http/handlers/messages.ts +57 -19
- package/src/http/handlers/models-list.ts +14 -8
- package/src/http/handlers/nodes-approve.test.ts +15 -4
- package/src/http/handlers/nodes-approve.ts +38 -40
- package/src/http/handlers/plugin-info.ts +5 -6
- package/src/http/handlers/plugin-upgrade.ts +4 -1
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +9 -6
- package/src/link-preview/og-parse.test.ts +6 -2
- package/src/link-preview/og-parse.ts +10 -3
- package/src/link-preview/preview-service.ts +4 -1
- package/src/link-preview/ssrf-guard.test.ts +72 -15
- package/src/link-preview/ssrf-guard.ts +2 -1
- package/src/media-fetch.test.ts +7 -2
- package/src/media-fetch.ts +1 -2
- package/src/openclaw.d.ts +16 -9
- package/src/plugin-install-info.ts +20 -9
- package/src/run-metadata.ts +2 -1
- package/src/session/session-manager.ts +19 -11
- package/src/session-usage-snapshot.ts +3 -1
- package/src/session-usage-store.ts +3 -1
- package/src/skills-discovery.test.ts +14 -10
- package/src/skills-discovery.ts +43 -27
- package/src/sse/emitter.test.ts +1 -1
- package/src/sse/emitter.ts +9 -3
- package/src/sse/offline-queue.ts +17 -8
- package/src/test-support/app-simulator.ts +17 -3
- package/src/test-support/mock-dispatch.ts +17 -4
- package/src/thinking-levels.ts +3 -1
- package/src/tool-catalog.ts +16 -7
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +5 -1
- package/tsconfig.json +1 -1
|
@@ -11,7 +11,8 @@ import crypto from "node:crypto";
|
|
|
11
11
|
*/
|
|
12
12
|
export async function resolveMediaMaxBytes(mimeType: string): Promise<number | undefined> {
|
|
13
13
|
try {
|
|
14
|
-
const { maxBytesForKind, mediaKindFromMime } =
|
|
14
|
+
const { maxBytesForKind, mediaKindFromMime } =
|
|
15
|
+
await import("openclaw/plugin-sdk/media-runtime");
|
|
15
16
|
return maxBytesForKind(mediaKindFromMime(mimeType) ?? "document");
|
|
16
17
|
} catch {
|
|
17
18
|
return undefined;
|
|
@@ -31,7 +32,13 @@ export async function saveInboundMediaBuffer(
|
|
|
31
32
|
// Pass the original filename (5th arg) so core's media-store preserves the
|
|
32
33
|
// name+extension instead of saving a bare uuid. Otherwise the agent receives
|
|
33
34
|
// `[media attached: file://.../inbound/<uuid>]` with no file-format signal.
|
|
34
|
-
const saved = await sdk.saveMediaBuffer(
|
|
35
|
+
const saved = await sdk.saveMediaBuffer(
|
|
36
|
+
buffer,
|
|
37
|
+
mimeType,
|
|
38
|
+
"inbound",
|
|
39
|
+
maxBytes,
|
|
40
|
+
originalFilename,
|
|
41
|
+
);
|
|
35
42
|
if (saved?.id && saved?.path) return { id: saved.id, path: saved.path };
|
|
36
43
|
} catch {
|
|
37
44
|
// fallback for tests or stripped runtime
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
2
2
|
import { delimiter, dirname, join } from "node:path";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Results come from the untyped OpenClaw dist module, so the resolved shapes are
|
|
5
|
+
// `any` at this host boundary — callers read dynamic fields (.pending, .status, …).
|
|
6
|
+
type ListNodePairingFn = () => Promise<any>;
|
|
7
|
+
type ApproveNodePairingFn = (
|
|
8
|
+
requestId: string,
|
|
9
|
+
options: { callerScopes?: unknown },
|
|
10
|
+
) => Promise<any>;
|
|
11
|
+
type NodePairingModule = {
|
|
12
|
+
listNodePairing: ListNodePairingFn;
|
|
13
|
+
approveNodePairing: ApproveNodePairingFn;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let cache: NodePairingModule | null = null;
|
|
5
17
|
|
|
6
18
|
function resolveOpenClawDistFromPath(): string | null {
|
|
7
19
|
// Walk PATH looking for the openclaw binary, then resolve its real
|
|
@@ -16,7 +28,9 @@ function resolveOpenClawDistFromPath(): string | null {
|
|
|
16
28
|
const dist = join(dirname(real), "dist");
|
|
17
29
|
readdirSync(dist);
|
|
18
30
|
return dist;
|
|
19
|
-
} catch {
|
|
31
|
+
} catch {
|
|
32
|
+
// Not a real dist dir — keep walking PATH.
|
|
33
|
+
}
|
|
20
34
|
}
|
|
21
35
|
return null;
|
|
22
36
|
}
|
|
@@ -43,15 +57,17 @@ function resolveOpenClawDist(): string {
|
|
|
43
57
|
].filter((v): v is string => typeof v === "string" && v.length > 0);
|
|
44
58
|
|
|
45
59
|
for (const root of candidates) {
|
|
46
|
-
try {
|
|
60
|
+
try {
|
|
61
|
+
readdirSync(root);
|
|
62
|
+
return root;
|
|
63
|
+
} catch {
|
|
64
|
+
// Candidate dir doesn't exist — try the next one.
|
|
65
|
+
}
|
|
47
66
|
}
|
|
48
67
|
throw new Error("OpenClaw dist directory not found. Set OPENCLAW_DIST env var.");
|
|
49
68
|
}
|
|
50
69
|
|
|
51
|
-
export async function loadNodePairingModule(): Promise<{
|
|
52
|
-
listNodePairing: Function;
|
|
53
|
-
approveNodePairing: Function;
|
|
54
|
-
}> {
|
|
70
|
+
export async function loadNodePairingModule(): Promise<NodePairingModule> {
|
|
55
71
|
if (cache) return cache;
|
|
56
72
|
const dist = resolveOpenClawDist();
|
|
57
73
|
const file = readdirSync(dist).find(
|
|
@@ -63,12 +79,13 @@ export async function loadNodePairingModule(): Promise<{
|
|
|
63
79
|
// bundled module uses `export { listNodePairing as r, … }`. Resolve the
|
|
64
80
|
// correct functions by Function.name, which preserves the original name.
|
|
65
81
|
const mod = await import(join(dist, file));
|
|
66
|
-
let listNodePairing:
|
|
67
|
-
let approveNodePairing:
|
|
82
|
+
let listNodePairing: ListNodePairingFn | undefined;
|
|
83
|
+
let approveNodePairing: ApproveNodePairingFn | undefined;
|
|
68
84
|
for (const value of Object.values(mod)) {
|
|
69
85
|
if (typeof value === "function") {
|
|
70
|
-
if (value.name === "listNodePairing") listNodePairing = value;
|
|
71
|
-
else if (value.name === "approveNodePairing")
|
|
86
|
+
if (value.name === "listNodePairing") listNodePairing = value as ListNodePairingFn;
|
|
87
|
+
else if (value.name === "approveNodePairing")
|
|
88
|
+
approveNodePairing = value as ApproveNodePairingFn;
|
|
72
89
|
}
|
|
73
90
|
}
|
|
74
91
|
if (!listNodePairing || !approveNodePairing) {
|
|
@@ -79,9 +96,6 @@ export async function loadNodePairingModule(): Promise<{
|
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
/** Vitest-only: inject mock pairing functions. */
|
|
82
|
-
export function __setMockNodePairingForTests(mock: {
|
|
83
|
-
listNodePairing: Function;
|
|
84
|
-
approveNodePairing: Function;
|
|
85
|
-
}): void {
|
|
99
|
+
export function __setMockNodePairingForTests(mock: NodePairingModule): void {
|
|
86
100
|
cache = mock;
|
|
87
101
|
}
|
|
@@ -39,8 +39,10 @@ export function accumulateRunUsage(
|
|
|
39
39
|
const entry = ensure(runId);
|
|
40
40
|
if (typeof usage.input === "number" && usage.input > 0) entry.input += usage.input;
|
|
41
41
|
if (typeof usage.output === "number" && usage.output > 0) entry.output += usage.output;
|
|
42
|
-
if (typeof usage.cacheRead === "number" && usage.cacheRead > 0)
|
|
43
|
-
|
|
42
|
+
if (typeof usage.cacheRead === "number" && usage.cacheRead > 0)
|
|
43
|
+
entry.cacheRead += usage.cacheRead;
|
|
44
|
+
if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0)
|
|
45
|
+
entry.cacheWrite += usage.cacheWrite;
|
|
44
46
|
if (typeof usage.total === "number" && usage.total > 0) entry.total += usage.total;
|
|
45
47
|
if (model && model.trim()) entry.model = model.trim();
|
|
46
48
|
if (provider && provider.trim()) entry.provider = provider.trim();
|
|
@@ -39,10 +39,6 @@ export function registerSessionKeyForRun(sessionKey: string, runId: string): voi
|
|
|
39
39
|
sessionKeyToRunId.set(sessionKey, runId);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function resolveRunIdForSessionKey(sessionKey: string): string | undefined {
|
|
43
|
-
return sessionKeyToRunId.get(sessionKey);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
42
|
/**
|
|
47
43
|
* Parse OpenClaw announce compound runId:
|
|
48
44
|
* announce:v<version>:<sessionKey>:<bareRunId>
|
|
@@ -26,7 +26,9 @@ function getAgentEventState(): AgentEventStateLike | undefined {
|
|
|
26
26
|
return { runContextById };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function getOpenClawAgentRunContext(
|
|
29
|
+
export function getOpenClawAgentRunContext(
|
|
30
|
+
runId: string,
|
|
31
|
+
): OpenClawAgentRunContextBridge | undefined {
|
|
30
32
|
if (!runId) return undefined;
|
|
31
33
|
return getAgentEventState()?.runContextById.get(runId);
|
|
32
34
|
}
|
|
@@ -6,7 +6,11 @@ import { handleMessageAction } from "./channel-actions.js";
|
|
|
6
6
|
import { sseEmitter } from "./sse/emitter.js";
|
|
7
7
|
import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
|
|
8
8
|
import { registerRunRoute } from "./run-metadata.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createTempHistoryDir,
|
|
11
|
+
removeTempHistoryDir,
|
|
12
|
+
setMockRuntime,
|
|
13
|
+
} from "./test-support/mock-runtime.js";
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* The `message` tool's `action=send` is handled here (NOT via outbound.sendText/sendMedia).
|
|
@@ -107,8 +111,8 @@ describe("channel-actions handleSend sessionKey routing", () => {
|
|
|
107
111
|
// 8-byte PNG magic header so saveMediaBuffer's magic-byte detection recognizes an image.
|
|
108
112
|
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]);
|
|
109
113
|
const directLink = "https://picsum.photos/600/400";
|
|
110
|
-
const fetchMock = vi.fn(
|
|
111
|
-
new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
|
|
114
|
+
const fetchMock = vi.fn(
|
|
115
|
+
async () => new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
|
|
112
116
|
);
|
|
113
117
|
vi.stubGlobal("fetch", fetchMock);
|
|
114
118
|
|
|
@@ -220,7 +224,9 @@ describe("channel-actions handleSend sessionKey routing", () => {
|
|
|
220
224
|
sessionKey: "agent:operator:friday:direct:fallback-session",
|
|
221
225
|
});
|
|
222
226
|
|
|
223
|
-
const text = parseOutboundFrames(res).find(
|
|
227
|
+
const text = parseOutboundFrames(res).find(
|
|
228
|
+
(f) => f.type === "outbound" && f.data.op === "text",
|
|
229
|
+
);
|
|
224
230
|
expect(text?.data.sessionKey).toBe("agent:operator:friday:direct:fallback-session");
|
|
225
231
|
});
|
|
226
232
|
});
|
package/src/channel-actions.ts
CHANGED
|
@@ -6,7 +6,11 @@ import { fridayNextChannelPlugin } from "./channel.js";
|
|
|
6
6
|
import { sseEmitter } from "./sse/emitter.js";
|
|
7
7
|
import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
|
|
8
8
|
import { registerRunRoute } from "./run-metadata.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createTempHistoryDir,
|
|
11
|
+
removeTempHistoryDir,
|
|
12
|
+
setMockRuntime,
|
|
13
|
+
} from "./test-support/mock-runtime.js";
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Outbound (message-tool send) must route to the session that started the run.
|
|
@@ -95,11 +99,19 @@ describe("friday-next channel outbound sessionKey routing", () => {
|
|
|
95
99
|
it("run-route wins over ctx sessionKey (ctx carries the agent's base/main session, not the active app session)", async () => {
|
|
96
100
|
const deviceId = "DEV-TEXT-2";
|
|
97
101
|
const runId = "run-text-2";
|
|
98
|
-
registerRunRoute({
|
|
102
|
+
registerRunRoute({
|
|
103
|
+
runId,
|
|
104
|
+
deviceId,
|
|
105
|
+
sessionKey: "agent:operator:friday-next:direct:route-session",
|
|
106
|
+
});
|
|
99
107
|
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
100
108
|
const res = connect(deviceId);
|
|
101
109
|
|
|
102
|
-
await outbound.sendText({
|
|
110
|
+
await outbound.sendText({
|
|
111
|
+
to: deviceId,
|
|
112
|
+
text: "hi",
|
|
113
|
+
requesterSessionKey: "agent:operator:main",
|
|
114
|
+
});
|
|
103
115
|
|
|
104
116
|
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
105
117
|
expect(evt?.data.sessionKey).toBe("agent:operator:friday-next:direct:route-session");
|
|
@@ -129,7 +141,9 @@ describe("friday-next channel outbound sessionKey routing", () => {
|
|
|
129
141
|
|
|
130
142
|
await outbound.sendMedia({ to: deviceId, text: "caption", mediaUrl: mediaFile });
|
|
131
143
|
|
|
132
|
-
const evt = parseOutboundFrames(res).find(
|
|
144
|
+
const evt = parseOutboundFrames(res).find(
|
|
145
|
+
(f) => f.type === "outbound" && f.data.op === "media",
|
|
146
|
+
);
|
|
133
147
|
expect(evt).toBeDefined();
|
|
134
148
|
expect(evt?.data.sessionKey).toBe(sessionKey);
|
|
135
149
|
expect(evt?.data.deviceId).toBe(deviceId);
|
package/src/channel.ts
CHANGED
|
@@ -194,153 +194,151 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
194
194
|
outbound: {
|
|
195
195
|
deliveryMode: "direct" as const,
|
|
196
196
|
sendText: async (ctx: any) => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
197
|
+
const text = ctx.text ?? "";
|
|
198
|
+
const rawCtx = ctx as unknown as Record<string, unknown>;
|
|
199
|
+
const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
|
|
200
|
+
const runIdFromCtx = pickFirstString(rawCtx, [
|
|
201
|
+
"parentRunId",
|
|
202
|
+
"requesterRunId",
|
|
203
|
+
"originRunId",
|
|
204
|
+
"runId",
|
|
205
|
+
]);
|
|
206
|
+
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
207
|
+
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
);
|
|
209
|
+
const conn = sseEmitter.getConnection(deviceId);
|
|
210
|
+
logger.info(
|
|
211
|
+
`[SEND_TEXT] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} textLen=${text.length} online=${!!conn}`,
|
|
212
|
+
);
|
|
214
213
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
},
|
|
214
|
+
if (conn) {
|
|
215
|
+
sseEmitter.broadcast(
|
|
216
|
+
{
|
|
217
|
+
type: "outbound",
|
|
218
|
+
data: {
|
|
219
|
+
op: "text",
|
|
220
|
+
ts: Date.now(),
|
|
221
|
+
runId,
|
|
222
|
+
deviceId,
|
|
223
|
+
sessionKey,
|
|
224
|
+
ctx: {
|
|
225
|
+
text,
|
|
226
|
+
to: ctx.to,
|
|
227
|
+
mediaUrl: ctx.mediaUrl,
|
|
228
|
+
audioAsVoice: ctx.audioAsVoice,
|
|
231
229
|
},
|
|
232
230
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
231
|
+
},
|
|
232
|
+
deviceId,
|
|
233
|
+
true,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
channel: CHANNEL_ID,
|
|
239
|
+
messageId: crypto.randomUUID(),
|
|
240
|
+
timestamp: Date.now(),
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
sendMedia: async (ctx: any) => {
|
|
244
|
+
const rawCtx = ctx as unknown as Record<string, unknown>;
|
|
245
|
+
const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
|
|
246
|
+
const mediaUrl = ctx.mediaUrl;
|
|
247
|
+
const runIdFromCtx = pickFirstString(rawCtx, [
|
|
248
|
+
"parentRunId",
|
|
249
|
+
"requesterRunId",
|
|
250
|
+
"originRunId",
|
|
251
|
+
"runId",
|
|
252
|
+
]);
|
|
253
|
+
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
254
|
+
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
255
|
+
const audioAsVoice = ctx.audioAsVoice === true;
|
|
256
|
+
const caption = ctx.text ?? "";
|
|
237
257
|
|
|
258
|
+
if (!mediaUrl) {
|
|
238
259
|
return {
|
|
239
260
|
channel: CHANNEL_ID,
|
|
240
261
|
messageId: crypto.randomUUID(),
|
|
241
262
|
timestamp: Date.now(),
|
|
242
263
|
};
|
|
243
|
-
}
|
|
244
|
-
sendMedia: async (ctx: any) => {
|
|
245
|
-
const rawCtx = ctx as unknown as Record<string, unknown>;
|
|
246
|
-
const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
|
|
247
|
-
const mediaUrl = ctx.mediaUrl;
|
|
248
|
-
const runIdFromCtx = pickFirstString(rawCtx, [
|
|
249
|
-
"parentRunId",
|
|
250
|
-
"requesterRunId",
|
|
251
|
-
"originRunId",
|
|
252
|
-
"runId",
|
|
253
|
-
]);
|
|
254
|
-
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
255
|
-
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
256
|
-
const audioAsVoice = ctx.audioAsVoice === true;
|
|
257
|
-
const caption = ctx.text ?? "";
|
|
258
|
-
|
|
259
|
-
if (!mediaUrl) {
|
|
260
|
-
return {
|
|
261
|
-
channel: CHANNEL_ID,
|
|
262
|
-
messageId: crypto.randomUUID(),
|
|
263
|
-
timestamp: Date.now(),
|
|
264
|
-
};
|
|
265
|
-
}
|
|
264
|
+
}
|
|
266
265
|
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
let buffer: Buffer | null = null;
|
|
267
|
+
let downloadedMimeType: string | null = null;
|
|
269
268
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
269
|
+
if (ctx.mediaReadFile) {
|
|
270
|
+
try {
|
|
271
|
+
buffer = await ctx.mediaReadFile(mediaUrl);
|
|
272
|
+
} catch {
|
|
273
|
+
// fall through to remote download / fs
|
|
276
274
|
}
|
|
275
|
+
}
|
|
277
276
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
277
|
+
if (!buffer && isHttpUrl(mediaUrl)) {
|
|
278
|
+
const remote = await downloadRemoteMedia(mediaUrl);
|
|
279
|
+
if (remote) {
|
|
280
|
+
buffer = remote.buffer;
|
|
281
|
+
downloadedMimeType = remote.mimeType;
|
|
284
282
|
}
|
|
283
|
+
}
|
|
285
284
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
285
|
+
if (!buffer) {
|
|
286
|
+
try {
|
|
287
|
+
const resolvedPath = resolveLocalMediaPath(mediaUrl, ctx.mediaLocalRoots);
|
|
288
|
+
buffer = fs.readFileSync(resolvedPath);
|
|
289
|
+
} catch {
|
|
290
|
+
// file not found — skip media
|
|
293
291
|
}
|
|
292
|
+
}
|
|
294
293
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
294
|
+
if (buffer) {
|
|
295
|
+
const mimeType = downloadedMimeType ?? guessMimeType(mediaUrl);
|
|
296
|
+
// Match what openclaw itself supports for this media kind rather than
|
|
297
|
+
// saveMediaBuffer's 5MB default.
|
|
298
|
+
const maxBytes = await resolveMediaMaxBytes(mimeType);
|
|
299
|
+
const saved = await saveMediaBuffer(buffer, mimeType, "inbound", maxBytes);
|
|
300
|
+
if (saved.id) {
|
|
301
|
+
const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
|
|
302
|
+
const resolved = resolveMediaAttachment(fileUrl);
|
|
303
|
+
const publicUrl = resolved ? resolved.url : fileUrl;
|
|
305
304
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
);
|
|
305
|
+
const conn = sseEmitter.getConnection(deviceId);
|
|
306
|
+
logger.info(
|
|
307
|
+
`[SEND_MEDIA] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} audioAsVoice=${audioAsVoice} url=${publicUrl} online=${!!conn}`,
|
|
308
|
+
);
|
|
311
309
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
},
|
|
310
|
+
if (conn) {
|
|
311
|
+
sseEmitter.broadcast(
|
|
312
|
+
{
|
|
313
|
+
type: "outbound",
|
|
314
|
+
data: {
|
|
315
|
+
op: "media",
|
|
316
|
+
ts: Date.now(),
|
|
317
|
+
runId,
|
|
318
|
+
deviceId,
|
|
319
|
+
sessionKey,
|
|
320
|
+
audioAsVoice,
|
|
321
|
+
caption,
|
|
322
|
+
mediaUrl: publicUrl,
|
|
323
|
+
ctx: {
|
|
324
|
+
to: ctx.to,
|
|
325
|
+
text: caption,
|
|
326
|
+
originalMediaUrl: mediaUrl,
|
|
330
327
|
},
|
|
331
328
|
},
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
329
|
+
},
|
|
330
|
+
deviceId,
|
|
331
|
+
true,
|
|
332
|
+
);
|
|
336
333
|
}
|
|
337
334
|
}
|
|
335
|
+
}
|
|
338
336
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
337
|
+
return {
|
|
338
|
+
channel: CHANNEL_ID,
|
|
339
|
+
messageId: crypto.randomUUID(),
|
|
340
|
+
timestamp: Date.now(),
|
|
341
|
+
};
|
|
342
|
+
},
|
|
345
343
|
},
|
|
346
344
|
});
|
|
@@ -50,7 +50,16 @@ export function collectMediaPathsFromToolResult(raw: unknown, acc?: Set<string>)
|
|
|
50
50
|
else if (media && typeof media === "object" && !Array.isArray(media)) visit(media);
|
|
51
51
|
const filePath = o.filePath;
|
|
52
52
|
if (typeof filePath === "string") add(filePath);
|
|
53
|
-
for (const k of [
|
|
53
|
+
for (const k of [
|
|
54
|
+
"details",
|
|
55
|
+
"result",
|
|
56
|
+
"content",
|
|
57
|
+
"text",
|
|
58
|
+
"body",
|
|
59
|
+
"message",
|
|
60
|
+
"arguments",
|
|
61
|
+
"args",
|
|
62
|
+
]) {
|
|
54
63
|
if (o[k] !== undefined) visit(o[k]);
|
|
55
64
|
}
|
|
56
65
|
for (const val of Object.values(o)) {
|
|
@@ -112,20 +121,20 @@ export function extractLocalPathsFromToolTextBlob(s: string): Set<string> {
|
|
|
112
121
|
|
|
113
122
|
// Verbatim /Users/.../file.ext (stop before quote or backslash — avoids eating JSON commas)
|
|
114
123
|
for (const m of s.matchAll(/(\/Users\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
|
|
115
|
-
add(m[1]
|
|
124
|
+
add(m[1]);
|
|
116
125
|
}
|
|
117
126
|
for (const m of s.matchAll(/(\/private\/var\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
|
|
118
|
-
add(m[1]
|
|
127
|
+
add(m[1]);
|
|
119
128
|
}
|
|
120
129
|
for (const m of s.matchAll(/(\/tmp\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
|
|
121
|
-
add(m[1]
|
|
130
|
+
add(m[1]);
|
|
122
131
|
}
|
|
123
132
|
for (const m of s.matchAll(/(\/home\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
|
|
124
|
-
add(m[1]
|
|
133
|
+
add(m[1]);
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
for (const m of s.matchAll(/([A-Za-z]:\\[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
|
|
128
|
-
add(m[1]
|
|
137
|
+
add(m[1]);
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
return out;
|
package/src/config.ts
CHANGED
|
@@ -52,10 +52,7 @@ export function resolveFridayNextConfig(cfg: unknown): FridayNextConfig {
|
|
|
52
52
|
pathPrefix: asString(section.pathPrefix, "/friday-next"),
|
|
53
53
|
transport: asString(section.transport, "http+sse"),
|
|
54
54
|
historyLimit: asNumber(section.historyLimit, 25, 1, 200),
|
|
55
|
-
historyDir: asString(
|
|
56
|
-
section.historyDir,
|
|
57
|
-
`${homedir()}/.openclaw/friday-next/history`,
|
|
58
|
-
),
|
|
55
|
+
historyDir: asString(section.historyDir, `${homedir()}/.openclaw/friday-next/history`),
|
|
59
56
|
logLevel: asString(section.logLevel, "info") as FridayNextLogLevel,
|
|
60
57
|
authToken,
|
|
61
58
|
corsEnabled: asBool(cors.enabled, false),
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createTempHistoryDir,
|
|
5
|
+
removeTempHistoryDir,
|
|
6
|
+
setMockRuntime,
|
|
7
|
+
} from "../test-support/mock-runtime.js";
|
|
4
8
|
import {
|
|
5
9
|
setFridayAgentForwardRuntime,
|
|
6
10
|
resetFridayAgentForwardRuntimeForTest,
|
|
@@ -20,7 +24,10 @@ function setForwardConfig(config: unknown): void {
|
|
|
20
24
|
} as never);
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
async function getAgents(
|
|
27
|
+
async function getAgents(
|
|
28
|
+
app: ReturnType<typeof createAppSimulator>,
|
|
29
|
+
headers?: Record<string, string>,
|
|
30
|
+
) {
|
|
24
31
|
const res = await app.rawRequest({ method: "GET", path: "/friday-next/agents", headers });
|
|
25
32
|
return { status: res.status, body: res.body ? JSON.parse(res.body) : {}, headers: res.headers };
|
|
26
33
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
3
|
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createTempHistoryDir,
|
|
6
|
+
removeTempHistoryDir,
|
|
7
|
+
setMockRuntime,
|
|
8
|
+
} from "../test-support/mock-runtime.js";
|
|
5
9
|
|
|
6
10
|
describe("e2e attachments inbound", () => {
|
|
7
11
|
let historyDir = "";
|
|
@@ -3,7 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
4
4
|
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
5
5
|
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
createTempHistoryDir,
|
|
8
|
+
removeTempHistoryDir,
|
|
9
|
+
setMockRuntime,
|
|
10
|
+
} from "../test-support/mock-runtime.js";
|
|
7
11
|
|
|
8
12
|
describe("e2e attachments outbound", () => {
|
|
9
13
|
let historyDir = "";
|
|
@@ -36,7 +40,8 @@ describe("e2e attachments outbound", () => {
|
|
|
36
40
|
|
|
37
41
|
const delivers = frames.filter((x) => x.event === "deliver");
|
|
38
42
|
expect(delivers.length).toBeGreaterThanOrEqual(1);
|
|
39
|
-
const urls =
|
|
43
|
+
const urls =
|
|
44
|
+
(delivers[delivers.length - 1]?.data?.payload as { mediaUrls?: string[] })?.mediaUrls ?? [];
|
|
40
45
|
expect(urls.some((u) => u.includes("/friday-next/files/"))).toBe(true);
|
|
41
46
|
app.disconnectSSE();
|
|
42
47
|
});
|