@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.
- package/README.md +35 -0
- package/index.ts +191 -0
- package/install.mjs +158 -0
- package/install.sh +118 -0
- package/openclaw.plugin.json +53 -0
- package/package.json +65 -0
- package/src/agent/abort-run.ts +10 -0
- package/src/agent/active-runs.ts +26 -0
- package/src/agent/dispatch-bridge.ts +18 -0
- package/src/agent/media-bridge.ts +23 -0
- package/src/agent-forward-runtime.ts +30 -0
- package/src/agent-run-context-bridge.ts +32 -0
- package/src/channel-actions.ts +129 -0
- package/src/channel.ts +284 -0
- package/src/collect-message-media-paths.ts +132 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +64 -0
- package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
- package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
- package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
- package/src/e2e/offline-replay.e2e.test.ts +43 -0
- package/src/e2e/send-text.e2e.test.ts +73 -0
- package/src/e2e/slash-commands.e2e.test.ts +33 -0
- package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
- package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
- package/src/friday-inbound-stats.ts +10 -0
- package/src/friday-session.forward-agent.test.ts +270 -0
- package/src/friday-session.ts +327 -0
- package/src/host-config.ts +20 -0
- package/src/http/handlers/cancel.test.ts +70 -0
- package/src/http/handlers/cancel.ts +35 -0
- package/src/http/handlers/files-download.ts +239 -0
- package/src/http/handlers/files-upload.ts +166 -0
- package/src/http/handlers/files.ts +335 -0
- package/src/http/handlers/messages.test.ts +119 -0
- package/src/http/handlers/messages.ts +555 -0
- package/src/http/handlers/models-list.ts +126 -0
- package/src/http/handlers/sessions-delete.ts +59 -0
- package/src/http/handlers/sessions-settings.ts +90 -0
- package/src/http/handlers/sse.test.ts +71 -0
- package/src/http/handlers/sse.ts +84 -0
- package/src/http/handlers/status.test.ts +52 -0
- package/src/http/handlers/status.ts +33 -0
- package/src/http/middleware/auth.test.ts +46 -0
- package/src/http/middleware/auth.ts +31 -0
- package/src/http/middleware/body.test.ts +27 -0
- package/src/http/middleware/body.ts +28 -0
- package/src/http/middleware/cors.test.ts +40 -0
- package/src/http/middleware/cors.ts +12 -0
- package/src/http/server.ts +106 -0
- package/src/logging.ts +27 -0
- package/src/openclaw.d.ts +32 -0
- package/src/run-metadata.ts +180 -0
- package/src/runtime.ts +14 -0
- package/src/session/session-manager.ts +230 -0
- package/src/session-usage-snapshot.ts +80 -0
- package/src/sse/emitter.test.ts +85 -0
- package/src/sse/emitter.ts +249 -0
- package/src/sse/frame-format.test.ts +56 -0
- package/src/sse/offline-queue.test.ts +65 -0
- package/src/sse/offline-queue.ts +140 -0
- package/src/test-support/app-simulator.ts +243 -0
- package/src/test-support/mock-dispatch.ts +181 -0
- package/src/test-support/mock-runtime.ts +74 -0
- package/src/vendor/runtime-store.ts +99 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message handler for POST /friday-next/messages
|
|
3
|
+
*
|
|
4
|
+
* Dispatches to the OpenClaw agent and streams native events via SSE (transparent proxy).
|
|
5
|
+
*
|
|
6
|
+
* **Owner / `nodes` tool visibility (OpenClaw):** With `tools.profile: "coding"`, add
|
|
7
|
+
* `tools.alsoAllow: ["nodes"]` so profile filtering does not hide `nodes`. Bearer-authenticated
|
|
8
|
+
* requests set `SenderId` and `OwnerAllowFrom` on the dispatch context so
|
|
9
|
+
* `resolveCommandAuthorization` treats this device as owner when channel `allowFrom` is open
|
|
10
|
+
* (empty / wildcard) and `commands.ownerAllowFrom` is not already a non-matching explicit list.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
15
|
+
/** Subset of OpenClaw reply payload used for deliver translation (avoids static SDK import in tests). */
|
|
16
|
+
export type FridayReplyPayload = {
|
|
17
|
+
text?: string;
|
|
18
|
+
mediaUrls?: string[];
|
|
19
|
+
mediaUrl?: string | null;
|
|
20
|
+
isError?: boolean;
|
|
21
|
+
audioAsVoice?: boolean;
|
|
22
|
+
isReasoning?: boolean;
|
|
23
|
+
isCompactionNotice?: boolean;
|
|
24
|
+
interactive?: unknown;
|
|
25
|
+
channelData?: unknown;
|
|
26
|
+
};
|
|
27
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
28
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
29
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
30
|
+
import { ensureSessionLevels, toSessionStoreKey } from "../../session/session-manager.js";
|
|
31
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
32
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
33
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
34
|
+
import { registerFridaySessionDeviceMapping } from "../../friday-session.js";
|
|
35
|
+
import { touchFridayInbound } from "../../friday-inbound-stats.js";
|
|
36
|
+
import {
|
|
37
|
+
fridayAttachmentLookupKey,
|
|
38
|
+
fridayFilesPublicUrl,
|
|
39
|
+
readFile,
|
|
40
|
+
resolveMediaAttachment,
|
|
41
|
+
resolveMediaUrl,
|
|
42
|
+
} from "./files.js";
|
|
43
|
+
import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
|
|
44
|
+
import { saveInboundMediaBuffer } from "../../agent/media-bridge.js";
|
|
45
|
+
import {
|
|
46
|
+
contextTokensFromUsageRecord,
|
|
47
|
+
getRunMetadata,
|
|
48
|
+
getRunRoute,
|
|
49
|
+
hasRunFinalDelivered,
|
|
50
|
+
markRunFinalDelivered,
|
|
51
|
+
registerRunRoute,
|
|
52
|
+
setRunMetadata,
|
|
53
|
+
} from "../../run-metadata.js";
|
|
54
|
+
|
|
55
|
+
const log = (action: string, deviceId: string, runId?: string, detail?: string) => {
|
|
56
|
+
const ts = new Date().toISOString();
|
|
57
|
+
const runPart = runId ? ` runId=${runId}` : "";
|
|
58
|
+
const detailPart = detail ? ` detail=${detail}` : "";
|
|
59
|
+
console.error(`[Friday-MSG] [${ts}] [${action}] deviceId=${deviceId}${runPart}${detailPart}`);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function collectReplyPayloadMediaUrls(pl: { mediaUrls?: string[]; mediaUrl?: string | null }): string[] {
|
|
63
|
+
const fromArr = Array.isArray(pl.mediaUrls)
|
|
64
|
+
? pl.mediaUrls.filter((u): u is string => typeof u === "string" && u.trim().length > 0)
|
|
65
|
+
: [];
|
|
66
|
+
const single = typeof pl.mediaUrl === "string" && pl.mediaUrl.trim() ? pl.mediaUrl.trim() : "";
|
|
67
|
+
if (!single) return fromArr;
|
|
68
|
+
if (fromArr.includes(single)) return fromArr;
|
|
69
|
+
return [...fromArr, single];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isAudioLikeUrl(url: string): boolean {
|
|
73
|
+
const lower = url.toLowerCase();
|
|
74
|
+
return (
|
|
75
|
+
lower.endsWith(".mp3") ||
|
|
76
|
+
lower.endsWith(".wav") ||
|
|
77
|
+
lower.endsWith(".ogg") ||
|
|
78
|
+
lower.endsWith(".opus") ||
|
|
79
|
+
lower.endsWith(".m4a") ||
|
|
80
|
+
lower.endsWith(".aac") ||
|
|
81
|
+
lower.endsWith(".flac") ||
|
|
82
|
+
lower.includes("audio/")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function inferFridayNextMediaKind(params: {
|
|
87
|
+
originalUrls: string[];
|
|
88
|
+
translatedUrls: string[];
|
|
89
|
+
audioAsVoice?: boolean;
|
|
90
|
+
kind: string;
|
|
91
|
+
}): "tts" | "tts_likely" | "audio" | "file" | "image" {
|
|
92
|
+
const urls = params.translatedUrls.length > 0 ? params.translatedUrls : params.originalUrls;
|
|
93
|
+
const hasAudio = urls.some(isAudioLikeUrl);
|
|
94
|
+
const hasImage = urls.some((u) => {
|
|
95
|
+
const lower = u.toLowerCase();
|
|
96
|
+
return (
|
|
97
|
+
lower.endsWith(".jpg") ||
|
|
98
|
+
lower.endsWith(".jpeg") ||
|
|
99
|
+
lower.endsWith(".png") ||
|
|
100
|
+
lower.endsWith(".webp") ||
|
|
101
|
+
lower.endsWith(".gif") ||
|
|
102
|
+
lower.includes("image/")
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
if (params.audioAsVoice === true && hasAudio) {
|
|
106
|
+
return "tts";
|
|
107
|
+
}
|
|
108
|
+
if (hasAudio) {
|
|
109
|
+
const sourceHints = params.originalUrls.join("\n").toLowerCase();
|
|
110
|
+
const looksLikeGeneratedTts =
|
|
111
|
+
sourceHints.includes("/tts-") ||
|
|
112
|
+
sourceHints.includes("\\tts-") ||
|
|
113
|
+
sourceHints.includes("/voice-") ||
|
|
114
|
+
sourceHints.includes("\\voice-");
|
|
115
|
+
if (looksLikeGeneratedTts || params.kind.toLowerCase() === "final") {
|
|
116
|
+
return "tts_likely";
|
|
117
|
+
}
|
|
118
|
+
return "audio";
|
|
119
|
+
}
|
|
120
|
+
if (hasImage) {
|
|
121
|
+
return "image";
|
|
122
|
+
}
|
|
123
|
+
return "file";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Map local / gateway paths to public `/friday-next/files/...` URLs where possible. */
|
|
127
|
+
function translateDeliverPayload(
|
|
128
|
+
pl: FridayReplyPayload,
|
|
129
|
+
kind: string,
|
|
130
|
+
meta?: { modelName?: string; totalTokens?: number; contextTokensUsed?: number; contextWindowMax?: number },
|
|
131
|
+
): Record<string, unknown> {
|
|
132
|
+
const raw = { ...pl } as Record<string, unknown>;
|
|
133
|
+
const originalUrls = collectReplyPayloadMediaUrls(pl);
|
|
134
|
+
if (typeof pl.mediaUrl === "string" && pl.mediaUrl.trim()) {
|
|
135
|
+
const r = resolveMediaAttachment(pl.mediaUrl.trim());
|
|
136
|
+
if (r) raw.mediaUrl = r.url;
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(pl.mediaUrls)) {
|
|
139
|
+
raw.mediaUrls = pl.mediaUrls.map((u) => {
|
|
140
|
+
if (typeof u !== "string") return u;
|
|
141
|
+
const r = resolveMediaAttachment(u);
|
|
142
|
+
return r ? r.url : resolveMediaUrl(u);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const translatedUrls = collectReplyPayloadMediaUrls({
|
|
146
|
+
mediaUrl: typeof raw.mediaUrl === "string" ? raw.mediaUrl : null,
|
|
147
|
+
mediaUrls: Array.isArray(raw.mediaUrls) ? (raw.mediaUrls as string[]) : undefined,
|
|
148
|
+
});
|
|
149
|
+
const baseChannelData =
|
|
150
|
+
pl.channelData && typeof pl.channelData === "object" && !Array.isArray(pl.channelData)
|
|
151
|
+
? (pl.channelData as Record<string, unknown>)
|
|
152
|
+
: {};
|
|
153
|
+
const baseFridayNext =
|
|
154
|
+
baseChannelData.fridayNext &&
|
|
155
|
+
typeof baseChannelData.fridayNext === "object" &&
|
|
156
|
+
!Array.isArray(baseChannelData.fridayNext)
|
|
157
|
+
? (baseChannelData.fridayNext as Record<string, unknown>)
|
|
158
|
+
: {};
|
|
159
|
+
|
|
160
|
+
const nextFridayNext: Record<string, unknown> = { ...baseFridayNext };
|
|
161
|
+
if (translatedUrls.length > 0) {
|
|
162
|
+
nextFridayNext.mediaKind = inferFridayNextMediaKind({
|
|
163
|
+
originalUrls,
|
|
164
|
+
translatedUrls,
|
|
165
|
+
audioAsVoice: pl.audioAsVoice,
|
|
166
|
+
kind,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (typeof meta?.modelName === "string" && meta.modelName.trim()) {
|
|
170
|
+
nextFridayNext.modelName = meta.modelName.trim();
|
|
171
|
+
}
|
|
172
|
+
if (typeof meta?.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
|
|
173
|
+
nextFridayNext.totalTokens = Math.floor(meta.totalTokens);
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
typeof meta?.contextTokensUsed === "number" &&
|
|
177
|
+
Number.isFinite(meta.contextTokensUsed) &&
|
|
178
|
+
meta.contextTokensUsed > 0
|
|
179
|
+
) {
|
|
180
|
+
nextFridayNext.contextTokensUsed = Math.floor(meta.contextTokensUsed);
|
|
181
|
+
}
|
|
182
|
+
if (
|
|
183
|
+
typeof meta?.contextWindowMax === "number" &&
|
|
184
|
+
Number.isFinite(meta.contextWindowMax) &&
|
|
185
|
+
meta.contextWindowMax > 0
|
|
186
|
+
) {
|
|
187
|
+
nextFridayNext.contextWindowMax = Math.floor(meta.contextWindowMax);
|
|
188
|
+
}
|
|
189
|
+
if (Object.keys(nextFridayNext).length > 0) {
|
|
190
|
+
raw.channelData = {
|
|
191
|
+
...baseChannelData,
|
|
192
|
+
fridayNext: nextFridayNext,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return raw;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function scheduleLateFinalMetaPatch(runId: string, attempts = 6): void {
|
|
199
|
+
const route = getRunRoute(runId);
|
|
200
|
+
if (!route) return;
|
|
201
|
+
const intervalMs = 300;
|
|
202
|
+
const tryOnce = (remaining: number) => {
|
|
203
|
+
const meta = getRunMetadata(runId);
|
|
204
|
+
if (
|
|
205
|
+
meta?.modelName ||
|
|
206
|
+
typeof meta?.totalTokens === "number" ||
|
|
207
|
+
typeof meta?.contextTokensUsed === "number" ||
|
|
208
|
+
typeof meta?.contextWindowMax === "number"
|
|
209
|
+
) {
|
|
210
|
+
if (!hasRunFinalDelivered(runId)) return;
|
|
211
|
+
sseEmitter.broadcastToRun(
|
|
212
|
+
runId,
|
|
213
|
+
{
|
|
214
|
+
type: "outbound",
|
|
215
|
+
data: {
|
|
216
|
+
op: "final_meta",
|
|
217
|
+
runId,
|
|
218
|
+
deviceId: route.deviceId,
|
|
219
|
+
sessionKey: route.sessionKey,
|
|
220
|
+
modelName: meta.modelName ?? null,
|
|
221
|
+
totalTokens: typeof meta.totalTokens === "number" ? meta.totalTokens : null,
|
|
222
|
+
contextTokensUsed: typeof meta.contextTokensUsed === "number" ? meta.contextTokensUsed : null,
|
|
223
|
+
contextWindowMax: typeof meta.contextWindowMax === "number" ? meta.contextWindowMax : null,
|
|
224
|
+
ts: Date.now(),
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
true,
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (remaining <= 0) return;
|
|
232
|
+
setTimeout(() => tryOnce(remaining - 1), intervalMs);
|
|
233
|
+
};
|
|
234
|
+
setTimeout(() => tryOnce(attempts), intervalMs);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function pickMetadataFromMessageLike(message: unknown): {
|
|
238
|
+
modelName?: string;
|
|
239
|
+
totalTokens?: number;
|
|
240
|
+
contextTokensUsed?: number;
|
|
241
|
+
contextWindowMax?: number;
|
|
242
|
+
} | null {
|
|
243
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return null;
|
|
244
|
+
const m = message as Record<string, unknown>;
|
|
245
|
+
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
|
|
246
|
+
if (role && role !== "assistant") return null;
|
|
247
|
+
const modelName =
|
|
248
|
+
(typeof m.model === "string" && m.model.trim()) ||
|
|
249
|
+
(typeof m.modelName === "string" && m.modelName.trim()) ||
|
|
250
|
+
undefined;
|
|
251
|
+
const usage =
|
|
252
|
+
m.usage && typeof m.usage === "object" && !Array.isArray(m.usage)
|
|
253
|
+
? (m.usage as Record<string, unknown>)
|
|
254
|
+
: undefined;
|
|
255
|
+
const totalFromUsage =
|
|
256
|
+
(typeof usage?.totalTokens === "number" && Number.isFinite(usage.totalTokens)
|
|
257
|
+
? usage.totalTokens
|
|
258
|
+
: undefined) ??
|
|
259
|
+
(typeof usage?.total === "number" && Number.isFinite(usage.total) ? usage.total : undefined) ??
|
|
260
|
+
(typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens) ? usage.total_tokens : undefined);
|
|
261
|
+
const totalFromMessage =
|
|
262
|
+
(typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens) ? m.totalTokens : undefined) ??
|
|
263
|
+
(typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens) ? m.total_tokens : undefined);
|
|
264
|
+
const totalTokens = Math.floor((totalFromUsage ?? totalFromMessage ?? 0));
|
|
265
|
+
|
|
266
|
+
let contextTokensUsed: number | undefined;
|
|
267
|
+
if (usage) {
|
|
268
|
+
const ctx = contextTokensFromUsageRecord(usage);
|
|
269
|
+
if (typeof ctx === "number" && ctx > 0) {
|
|
270
|
+
contextTokensUsed = ctx;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const ctxMaxRaw =
|
|
275
|
+
(typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow) ? m.contextWindow : undefined) ??
|
|
276
|
+
(typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens) ? m.maxContextTokens : undefined);
|
|
277
|
+
const contextWindowMax =
|
|
278
|
+
typeof ctxMaxRaw === "number" && ctxMaxRaw > 0 ? Math.floor(ctxMaxRaw) : undefined;
|
|
279
|
+
|
|
280
|
+
if (!modelName && !(totalTokens > 0) && !contextTokensUsed && !contextWindowMax) return null;
|
|
281
|
+
return {
|
|
282
|
+
modelName,
|
|
283
|
+
totalTokens: totalTokens > 0 ? totalTokens : undefined,
|
|
284
|
+
contextTokensUsed,
|
|
285
|
+
contextWindowMax,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function resolveRunMetadataFromRuntimeSession(
|
|
290
|
+
runtime: ReturnType<typeof getFridayNextRuntime>,
|
|
291
|
+
sessionKey: string,
|
|
292
|
+
): Promise<{
|
|
293
|
+
modelName?: string;
|
|
294
|
+
totalTokens?: number;
|
|
295
|
+
contextTokensUsed?: number;
|
|
296
|
+
contextWindowMax?: number;
|
|
297
|
+
} | null> {
|
|
298
|
+
const sessionApi = (runtime as unknown as {
|
|
299
|
+
subagent?: { getSessionMessages?: (params: { sessionKey: string; limit?: number }) => Promise<{ messages?: unknown[] }> };
|
|
300
|
+
}).subagent;
|
|
301
|
+
if (!sessionApi?.getSessionMessages) return null;
|
|
302
|
+
try {
|
|
303
|
+
const response = await sessionApi.getSessionMessages({ sessionKey, limit: 80 });
|
|
304
|
+
const messages = Array.isArray(response?.messages) ? response.messages : [];
|
|
305
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
306
|
+
const picked = pickMetadataFromMessageLike(messages[i]);
|
|
307
|
+
if (picked) return picked;
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Best-effort fallback only.
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface FridayMessagePayload {
|
|
316
|
+
deviceId: string;
|
|
317
|
+
text: string;
|
|
318
|
+
sessionKey: string;
|
|
319
|
+
attachments?: string[];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function buildBodyForAgentWithAttachments(text: string, attachmentIds: string[]): Promise<string> {
|
|
323
|
+
if (attachmentIds.length === 0) return text.trim();
|
|
324
|
+
|
|
325
|
+
const mediaRefs: string[] = [];
|
|
326
|
+
for (const id of attachmentIds) {
|
|
327
|
+
const { buffer, mimeType } = readFile(fridayAttachmentLookupKey(id));
|
|
328
|
+
if (!buffer) continue;
|
|
329
|
+
|
|
330
|
+
const saved = await saveInboundMediaBuffer(buffer, mimeType);
|
|
331
|
+
if (saved.id && saved.path) {
|
|
332
|
+
mediaRefs.push(`[media attached: file://${saved.path}]`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (mediaRefs.length === 0) return text.trim();
|
|
337
|
+
return `${text.trim()}\n\n${mediaRefs.join("\n")}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function handleMessages(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
341
|
+
if (req.method !== "POST") {
|
|
342
|
+
res.statusCode = 405;
|
|
343
|
+
res.setHeader("Content-Type", "application/json");
|
|
344
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const token = extractBearerToken(req);
|
|
349
|
+
if (!token) {
|
|
350
|
+
log("AUTH_FAILED", "(unknown)", undefined, "missing or invalid token");
|
|
351
|
+
res.statusCode = 401;
|
|
352
|
+
res.setHeader("Content-Type", "application/json");
|
|
353
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const payload = (await readJsonBody(req)) as FridayMessagePayload | null;
|
|
358
|
+
if (!payload) {
|
|
359
|
+
log("BAD_REQUEST", "(unknown)", undefined, "invalid JSON body");
|
|
360
|
+
res.statusCode = 400;
|
|
361
|
+
res.setHeader("Content-Type", "application/json");
|
|
362
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const { deviceId, text, attachments = [], sessionKey: rawSessionKey } = payload;
|
|
367
|
+
const normalizedDeviceId = deviceId?.trim().toUpperCase();
|
|
368
|
+
|
|
369
|
+
if (typeof rawSessionKey !== "string" || !rawSessionKey.length) {
|
|
370
|
+
log("BAD_REQUEST", "(unknown)", undefined, "missing sessionKey");
|
|
371
|
+
res.statusCode = 400;
|
|
372
|
+
res.setHeader("Content-Type", "application/json");
|
|
373
|
+
res.end(JSON.stringify({ error: "Missing required field: sessionKey" }));
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const appSessionKey = rawSessionKey.trim();
|
|
378
|
+
const baseSessionKey = toSessionStoreKey(appSessionKey);
|
|
379
|
+
|
|
380
|
+
if (!normalizedDeviceId) {
|
|
381
|
+
log("BAD_REQUEST", "(unknown)", undefined, "missing deviceId");
|
|
382
|
+
res.statusCode = 400;
|
|
383
|
+
res.setHeader("Content-Type", "application/json");
|
|
384
|
+
res.end(JSON.stringify({ error: "Missing required field: deviceId" }));
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!text || !text.trim()) {
|
|
389
|
+
log("BAD_REQUEST", normalizedDeviceId, undefined, "missing text");
|
|
390
|
+
res.statusCode = 400;
|
|
391
|
+
res.setHeader("Content-Type", "application/json");
|
|
392
|
+
res.end(JSON.stringify({ error: "Missing required field: text" }));
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const trimmedText = text.trim();
|
|
397
|
+
touchFridayInbound();
|
|
398
|
+
|
|
399
|
+
const isSlashCommand = trimmedText.startsWith("/");
|
|
400
|
+
|
|
401
|
+
const runId = crypto.randomUUID();
|
|
402
|
+
const runtime = getFridayNextRuntime();
|
|
403
|
+
|
|
404
|
+
res.statusCode = 202;
|
|
405
|
+
res.setHeader("Content-Type", "application/json");
|
|
406
|
+
res.end(JSON.stringify({ accepted: true, deviceId: normalizedDeviceId, runId }));
|
|
407
|
+
|
|
408
|
+
log(
|
|
409
|
+
"MESSAGE_RECEIVED",
|
|
410
|
+
normalizedDeviceId,
|
|
411
|
+
runId,
|
|
412
|
+
`textLen=${trimmedText.length} attachments=${attachments.length} sessionKey=${baseSessionKey}`,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
|
|
416
|
+
ensureSessionLevels(baseSessionKey, "stream", "medium", cfg.historyDir);
|
|
417
|
+
|
|
418
|
+
registerFridaySessionDeviceMapping(appSessionKey, normalizedDeviceId);
|
|
419
|
+
sseEmitter.trackDeviceForRun(normalizedDeviceId, runId);
|
|
420
|
+
registerRunRoute({ runId, deviceId: normalizedDeviceId, sessionKey: baseSessionKey });
|
|
421
|
+
|
|
422
|
+
const bodyForAgent = await buildBodyForAgentWithAttachments(text, attachments);
|
|
423
|
+
|
|
424
|
+
const msgContext = {
|
|
425
|
+
Body: trimmedText,
|
|
426
|
+
BodyForAgent: bodyForAgent,
|
|
427
|
+
RawBody: trimmedText,
|
|
428
|
+
CommandBody: trimmedText,
|
|
429
|
+
BodyForCommands: trimmedText,
|
|
430
|
+
SenderId: normalizedDeviceId,
|
|
431
|
+
OwnerAllowFrom: [normalizedDeviceId],
|
|
432
|
+
From: normalizedDeviceId,
|
|
433
|
+
To: "friday-next",
|
|
434
|
+
OriginatingTo: normalizedDeviceId,
|
|
435
|
+
SessionKey: baseSessionKey,
|
|
436
|
+
MediaUrls: attachments.map(fridayFilesPublicUrl),
|
|
437
|
+
channel: "friday-next" as const,
|
|
438
|
+
Provider: "friday-next" as const,
|
|
439
|
+
ChatType: "direct" as const,
|
|
440
|
+
CommandAuthorized: true,
|
|
441
|
+
CommandSource: isSlashCommand ? ("native" as const) : undefined,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const runAgent = async () => {
|
|
445
|
+
try {
|
|
446
|
+
await runFridayDispatch({
|
|
447
|
+
ctx: msgContext,
|
|
448
|
+
cfg: getHostOpenClawConfigSnapshot(runtime.config),
|
|
449
|
+
dispatcherOptions: {
|
|
450
|
+
deliver: async (pl: any, info: any) => {
|
|
451
|
+
let meta = getRunMetadata(runId);
|
|
452
|
+
if (info.kind.toLowerCase() === "final" && !(meta?.modelName || typeof meta?.totalTokens === "number")) {
|
|
453
|
+
const resolved = await resolveRunMetadataFromRuntimeSession(runtime, baseSessionKey);
|
|
454
|
+
if (resolved) {
|
|
455
|
+
setRunMetadata(runId, resolved);
|
|
456
|
+
meta = getRunMetadata(runId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const payload = translateDeliverPayload(pl, info.kind, meta);
|
|
460
|
+
log("EVENT_SENT", normalizedDeviceId, runId, `deliver kind=${info.kind}`);
|
|
461
|
+
sseEmitter.broadcastToRun(
|
|
462
|
+
runId,
|
|
463
|
+
{
|
|
464
|
+
type: "deliver",
|
|
465
|
+
data: {
|
|
466
|
+
kind: info.kind,
|
|
467
|
+
payload,
|
|
468
|
+
runId,
|
|
469
|
+
sessionKey: baseSessionKey,
|
|
470
|
+
deviceId: normalizedDeviceId,
|
|
471
|
+
ts: Date.now(),
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
true,
|
|
475
|
+
);
|
|
476
|
+
if (info.kind.toLowerCase() === "final") {
|
|
477
|
+
markRunFinalDelivered(runId);
|
|
478
|
+
if (!(meta?.modelName || typeof meta?.totalTokens === "number")) {
|
|
479
|
+
scheduleLateFinalMetaPatch(runId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
onError: (err: unknown) => {
|
|
484
|
+
log("RUN_ERROR", normalizedDeviceId, runId, String(err));
|
|
485
|
+
sseEmitter.broadcastToRun(
|
|
486
|
+
runId,
|
|
487
|
+
{
|
|
488
|
+
type: "outbound",
|
|
489
|
+
data: {
|
|
490
|
+
op: "dispatch_error",
|
|
491
|
+
error: String(err),
|
|
492
|
+
runId,
|
|
493
|
+
sessionKey: baseSessionKey,
|
|
494
|
+
deviceId: normalizedDeviceId,
|
|
495
|
+
ts: Date.now(),
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
true,
|
|
499
|
+
);
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
replyOptions: {
|
|
503
|
+
runId,
|
|
504
|
+
suppressTyping: true,
|
|
505
|
+
disableBlockStreaming: true,
|
|
506
|
+
onModelSelected: (sel: any) => {
|
|
507
|
+
const name = typeof sel.model === "string" ? sel.model.trim() : "";
|
|
508
|
+
if (name) {
|
|
509
|
+
setRunMetadata(runId, { modelName: name });
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
// OpenClaw `pi-embedded-subscribe` gates `streamReasoning` on `typeof onReasoningStream === "function"`.
|
|
513
|
+
// Without this, `emitReasoningStream` never runs and Friday SSE never sees `stream: "thinking"`.
|
|
514
|
+
onReasoningStream: async (pl: unknown) => {
|
|
515
|
+
const text =
|
|
516
|
+
typeof pl === "object" && pl !== null && "text" in pl
|
|
517
|
+
? String((pl as { text?: unknown }).text ?? "")
|
|
518
|
+
: "";
|
|
519
|
+
log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
|
|
520
|
+
},
|
|
521
|
+
onReasoningEnd: async () => {
|
|
522
|
+
log("REASONING_STREAM_END", normalizedDeviceId, runId);
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
log("RUN_COMPLETE", normalizedDeviceId, runId);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
log("RUN_ERROR", normalizedDeviceId, runId, String(err));
|
|
529
|
+
sseEmitter.broadcastToRun(
|
|
530
|
+
runId,
|
|
531
|
+
{
|
|
532
|
+
type: "outbound",
|
|
533
|
+
data: {
|
|
534
|
+
op: "dispatch_error",
|
|
535
|
+
error: String(err),
|
|
536
|
+
runId,
|
|
537
|
+
sessionKey: baseSessionKey,
|
|
538
|
+
deviceId: normalizedDeviceId,
|
|
539
|
+
ts: Date.now(),
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
true,
|
|
543
|
+
);
|
|
544
|
+
} finally {
|
|
545
|
+
sseEmitter.untrackRun(runId);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
runAgent().catch((err) => {
|
|
550
|
+
log("RUN_ERROR", normalizedDeviceId, runId, String(err));
|
|
551
|
+
sseEmitter.untrackRun(runId);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
3
|
+
import { splitModelRef } from "../../session/session-manager.js";
|
|
4
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
|
+
|
|
6
|
+
export interface FridayModelEntry {
|
|
7
|
+
id: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
provider: string;
|
|
10
|
+
reasoning?: boolean;
|
|
11
|
+
contextWindow?: number;
|
|
12
|
+
maxTokens?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ResolvedModels {
|
|
16
|
+
models: FridayModelEntry[];
|
|
17
|
+
defaultModel: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveConfiguredModels(): ResolvedModels {
|
|
21
|
+
const rt = getFridayAgentForwardRuntime();
|
|
22
|
+
if (!rt) return { models: [], defaultModel: "" };
|
|
23
|
+
const cfg = rt.getConfig() as Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
const providerMeta = buildProviderModelMeta(cfg);
|
|
26
|
+
|
|
27
|
+
const agents = cfg?.agents as Record<string, unknown> | undefined;
|
|
28
|
+
const agentDefaults = agents?.defaults as Record<string, unknown> | undefined;
|
|
29
|
+
const agentModels = agentDefaults?.models as Record<string, Record<string, unknown>> | undefined;
|
|
30
|
+
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
const entries: FridayModelEntry[] = [];
|
|
33
|
+
|
|
34
|
+
if (agentModels) {
|
|
35
|
+
for (const [modelKey, info] of Object.entries(agentModels)) {
|
|
36
|
+
const split = splitModelRef(modelKey);
|
|
37
|
+
if (!split.provider) continue;
|
|
38
|
+
const meta = providerMeta.get(modelKey);
|
|
39
|
+
seen.add(modelKey);
|
|
40
|
+
entries.push({
|
|
41
|
+
id: modelKey,
|
|
42
|
+
name: typeof info?.alias === "string" ? info.alias : meta?.name ?? split.modelId,
|
|
43
|
+
provider: split.provider,
|
|
44
|
+
reasoning: meta?.reasoning,
|
|
45
|
+
contextWindow: meta?.contextWindow,
|
|
46
|
+
maxTokens: meta?.maxTokens,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const agent of (agents?.list as Array<Record<string, unknown>> | undefined) ?? []) {
|
|
52
|
+
const primaryModel = typeof agent?.model === "string" ? agent.model : undefined;
|
|
53
|
+
if (primaryModel && !seen.has(primaryModel)) {
|
|
54
|
+
const split = splitModelRef(primaryModel);
|
|
55
|
+
const meta = providerMeta.get(primaryModel);
|
|
56
|
+
entries.push({
|
|
57
|
+
id: primaryModel,
|
|
58
|
+
name: meta?.name ?? split.modelId,
|
|
59
|
+
provider: split.provider ?? "",
|
|
60
|
+
reasoning: meta?.reasoning,
|
|
61
|
+
contextWindow: meta?.contextWindow,
|
|
62
|
+
maxTokens: meta?.maxTokens,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const agentModel = agentDefaults?.model as Record<string, unknown> | undefined;
|
|
68
|
+
const defaultModel = typeof agentModel?.primary === "string" ? agentModel.primary : "";
|
|
69
|
+
|
|
70
|
+
return { models: entries, defaultModel };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildProviderModelMeta(cfg: Record<string, unknown>): Map<string, {
|
|
74
|
+
name?: string;
|
|
75
|
+
reasoning?: boolean;
|
|
76
|
+
contextWindow?: number;
|
|
77
|
+
maxTokens?: number;
|
|
78
|
+
}> {
|
|
79
|
+
const meta = new Map<string, { name?: string; reasoning?: boolean; contextWindow?: number; maxTokens?: number }>();
|
|
80
|
+
const models = cfg?.models as Record<string, unknown> | undefined;
|
|
81
|
+
const providers = models?.providers as Record<string, unknown> | undefined;
|
|
82
|
+
if (providers) {
|
|
83
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
84
|
+
const providerModels = (provider as { models?: Array<Record<string, unknown>> })?.models;
|
|
85
|
+
if (!Array.isArray(providerModels)) continue;
|
|
86
|
+
for (const m of providerModels) {
|
|
87
|
+
const modelId = typeof m.id === "string" ? m.id : typeof m.name === "string" ? m.name : "";
|
|
88
|
+
if (!modelId) continue;
|
|
89
|
+
meta.set(`${providerId}/${modelId}`, {
|
|
90
|
+
name: typeof m.name === "string" ? m.name : undefined,
|
|
91
|
+
reasoning: typeof m.reasoning === "boolean" ? m.reasoning : undefined,
|
|
92
|
+
contextWindow: typeof m.contextWindow === "number" ? m.contextWindow : undefined,
|
|
93
|
+
maxTokens: typeof m.maxTokens === "number" ? m.maxTokens : undefined,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return meta;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function handleModelsList(
|
|
102
|
+
req: IncomingMessage,
|
|
103
|
+
res: ServerResponse,
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
if (req.method !== "GET") {
|
|
106
|
+
res.statusCode = 405;
|
|
107
|
+
res.setHeader("Content-Type", "application/json");
|
|
108
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const token = extractBearerToken(req);
|
|
113
|
+
if (!token) {
|
|
114
|
+
res.statusCode = 401;
|
|
115
|
+
res.setHeader("Content-Type", "application/json");
|
|
116
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { models, defaultModel } = resolveConfiguredModels();
|
|
121
|
+
|
|
122
|
+
res.statusCode = 200;
|
|
123
|
+
res.setHeader("Content-Type", "application/json");
|
|
124
|
+
res.end(JSON.stringify({ ok: true, models, defaultModel }));
|
|
125
|
+
return true;
|
|
126
|
+
}
|