@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,335 @@
1
+ /**
2
+ * File manager for Friday Next channel attachments.
3
+ *
4
+ * Files are copied under the plugin root `attachments/` and served at
5
+ * GET /friday-next/files/{token} so the app can use stable gateway URLs after restarts.
6
+ */
7
+
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ function getPluginRootDir(): string {
15
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
16
+ }
17
+
18
+ /** Plugin-root `attachments/` directory; created on first use. */
19
+ export function getAttachmentsDir(): string {
20
+ const dir = path.join(getPluginRootDir(), "attachments");
21
+ try {
22
+ fs.mkdirSync(dir, { recursive: true });
23
+ } catch {
24
+ // Already exists or permission denied
25
+ }
26
+ return dir;
27
+ }
28
+
29
+ export interface StoredFile {
30
+ id: string;
31
+ /** Path segment for /friday-next/files/{urlToken} (on-disk basename under attachments/). */
32
+ urlToken: string;
33
+ filename: string;
34
+ mimeType: string;
35
+ size: number;
36
+ path: string;
37
+ createdAt: number;
38
+ }
39
+
40
+ /** In-memory index of stored files (keys: uuid id and urlToken / disk basename). */
41
+ const fileIndex = new Map<string, StoredFile>();
42
+ const fileTokenIndex = new Map<string, StoredFile>();
43
+ const externalFileSourceIndex = new Map<string, string>();
44
+
45
+ function registerStoredFile(file: StoredFile): void {
46
+ fileIndex.set(file.id, file);
47
+ fileIndex.set(file.urlToken, file);
48
+ fileTokenIndex.set(file.id, file);
49
+ fileTokenIndex.set(file.urlToken, file);
50
+ }
51
+
52
+ function resolveStoredFile(key: string): StoredFile | undefined {
53
+ return fileIndex.get(key) ?? fileTokenIndex.get(key);
54
+ }
55
+
56
+ /**
57
+ * Read a file from `attachments/` by URL path token (disk basename).
58
+ * Used when the in-memory index was cleared after a gateway restart.
59
+ */
60
+ export function readAttachmentFileFromDisk(fileToken: string): {
61
+ buffer: Buffer;
62
+ mimeType: string;
63
+ filename: string;
64
+ } | null {
65
+ const safe = path.basename(fileToken);
66
+ if (!safe || safe === "." || safe === "..") return null;
67
+ const dir = getAttachmentsDir();
68
+ const full = path.join(dir, safe);
69
+ if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return null;
70
+ try {
71
+ const buffer = fs.readFileSync(full);
72
+ return { buffer, mimeType: guessMimeType(safe), filename: safe };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Copy a local file into `attachments/` and register it (no full-buffer read for the copy path).
80
+ */
81
+ /** Expand ~, file://, etc. for paths coming from the agent / message tool. */
82
+ export function normalizeAgentMediaPath(raw: string): string {
83
+ const s = raw.trim();
84
+ if (!s) return s;
85
+ try {
86
+ if (/^file:/i.test(s)) {
87
+ return fileURLToPath(s);
88
+ }
89
+ } catch {
90
+ // ignore malformed file URL
91
+ }
92
+ if (s.startsWith("~/") || s.startsWith("~\\")) {
93
+ return path.join(os.homedir(), s.slice(2));
94
+ }
95
+ if (s === "~") {
96
+ return os.homedir();
97
+ }
98
+ return s;
99
+ }
100
+
101
+ function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
102
+ const resolvedPath = normalizeAgentMediaPath(sourcePath);
103
+ const filename = path.basename(resolvedPath);
104
+ if (!filename) return null;
105
+ try {
106
+ if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) return null;
107
+ const id = crypto.randomUUID();
108
+ const ext = path.extname(filename);
109
+ const urlToken = ext ? `${id}${ext}` : id;
110
+ const storedPath = path.join(getAttachmentsDir(), urlToken);
111
+ try {
112
+ fs.copyFileSync(resolvedPath, storedPath);
113
+ } catch (copyErr) {
114
+ // macOS Desktop/iCloud files may fail copyFile with unknown errno (-11).
115
+ // Fallback to read+write so attachment persistence still works.
116
+ const raw = fs.readFileSync(resolvedPath);
117
+ fs.writeFileSync(storedPath, raw);
118
+ console.error(`[files] copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`);
119
+ }
120
+ const stat = fs.statSync(storedPath);
121
+ const mimeType = guessMimeType(filename);
122
+ const file: StoredFile = {
123
+ id,
124
+ urlToken,
125
+ filename,
126
+ mimeType,
127
+ size: stat.size,
128
+ path: storedPath,
129
+ createdAt: Date.now(),
130
+ };
131
+ registerStoredFile(file);
132
+ return file;
133
+ } catch (err) {
134
+ console.error(`[files] copyLocalFileToAttachments failed for "${resolvedPath}": ${String(err)}`);
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Store a file buffer and return its ID and metadata.
141
+ */
142
+ export function storeFile(buffer: Buffer, filename: string, mimeType: string): StoredFile {
143
+ const id = crypto.randomUUID();
144
+ const safeFilename = path.basename(filename) || "file";
145
+ const ext = path.extname(safeFilename);
146
+ const urlToken = ext ? `${id}${ext}` : id;
147
+ const storedPath = path.join(getAttachmentsDir(), urlToken);
148
+
149
+ try {
150
+ fs.writeFileSync(storedPath, buffer);
151
+ } catch (err) {
152
+ throw new Error(`Failed to store file: ${String(err)}`);
153
+ }
154
+
155
+ const file: StoredFile = {
156
+ id,
157
+ urlToken,
158
+ filename: safeFilename,
159
+ mimeType,
160
+ size: buffer.length,
161
+ path: storedPath,
162
+ createdAt: Date.now(),
163
+ };
164
+
165
+ registerStoredFile(file);
166
+ return file;
167
+ }
168
+
169
+ /**
170
+ * Retrieve file metadata by ID or url token.
171
+ */
172
+ export function getFile(id: string): StoredFile | undefined {
173
+ return resolveStoredFile(id);
174
+ }
175
+
176
+ /**
177
+ * Path segment for lookup: raw uuid / urlToken, or token extracted from `/friday-next/files/...`.
178
+ */
179
+ export function fridayAttachmentLookupKey(ref: string): string {
180
+ const s = ref.trim();
181
+ if (!s) return s;
182
+ if (s.startsWith("/friday-next/files/")) {
183
+ return decodeURIComponent(s.slice("/friday-next/files/".length));
184
+ }
185
+ return s;
186
+ }
187
+
188
+ /**
189
+ * Canonical gateway URL `/friday-next/files/{urlToken}` with extension when stored (for history, MediaUrls).
190
+ */
191
+ export function fridayFilesPublicUrl(ref: string): string {
192
+ const lookupKey = fridayAttachmentLookupKey(ref);
193
+ if (!lookupKey) return ref.trim();
194
+
195
+ const file = resolveStoredFile(lookupKey);
196
+ if (file) {
197
+ return `/friday-next/files/${encodeURIComponent(file.urlToken)}`;
198
+ }
199
+
200
+ const disk = readAttachmentFileFromDisk(lookupKey);
201
+ if (disk) {
202
+ return `/friday-next/files/${encodeURIComponent(disk.filename)}`;
203
+ }
204
+
205
+ const trimmed = ref.trim();
206
+ if (trimmed.startsWith("/friday-next/files/")) {
207
+ return `/friday-next/files/${encodeURIComponent(lookupKey)}`;
208
+ }
209
+ return `/friday-next/files/${encodeURIComponent(lookupKey)}`;
210
+ }
211
+
212
+ export function getExternalFileSourceByUrlToken(token: string): string | undefined {
213
+ return externalFileSourceIndex.get(token);
214
+ }
215
+
216
+ /**
217
+ * Read a file as a Buffer with its MIME type (by id or urlToken).
218
+ */
219
+ export function readFile(id: string): { buffer: Buffer | null; mimeType: string } {
220
+ const file = resolveStoredFile(id);
221
+ if (!file) return { buffer: null, mimeType: "application/octet-stream" };
222
+ try {
223
+ return { buffer: fs.readFileSync(file.path), mimeType: file.mimeType };
224
+ } catch {
225
+ return { buffer: null, mimeType: file.mimeType };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Copy a file from a local filesystem path into the Friday Next channel file store
231
+ * and return its /friday-next/files/{token} URL. If the path is already a Friday Next channel
232
+ * file URL (i.e. starts with "/friday-next/files/"), return it as-is.
233
+ */
234
+ export function resolveMediaUrl(localPath: string): string {
235
+ if (localPath.startsWith("/friday-next/files/")) {
236
+ return localPath;
237
+ }
238
+
239
+ const stored = copyLocalFileToAttachments(localPath);
240
+ if (!stored) {
241
+ console.error(`[files] resolveMediaUrl: file not found or unreadable: ${localPath}`);
242
+ return localPath;
243
+ }
244
+ console.log(`[files] resolveMediaUrl: copied "${stored.filename}" → ${stored.urlToken}`);
245
+ return `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
246
+ }
247
+
248
+ export interface ResolvedAttachment {
249
+ fileName: string;
250
+ url: string;
251
+ }
252
+
253
+ /**
254
+ * Resolve a local path into a Friday-served attachment descriptor.
255
+ * Returns null when source file is missing or cannot be copied.
256
+ */
257
+ export function resolveMediaAttachment(localPath: string): ResolvedAttachment | null {
258
+ if (localPath.startsWith("/friday-next/files/")) {
259
+ const token = decodeURIComponent(localPath.slice("/friday-next/files/".length));
260
+ const file = resolveStoredFile(token);
261
+ if (file) {
262
+ return {
263
+ fileName: file.filename,
264
+ url: `/friday-next/files/${encodeURIComponent(file.urlToken)}`,
265
+ };
266
+ }
267
+ const disk = readAttachmentFileFromDisk(token);
268
+ if (disk) {
269
+ return {
270
+ fileName: disk.filename,
271
+ url: `/friday-next/files/${encodeURIComponent(token)}`,
272
+ };
273
+ }
274
+ const fallback = path.basename(token);
275
+ return { fileName: fallback, url: localPath };
276
+ }
277
+
278
+ const filename = path.basename(localPath);
279
+ if (!filename) return null;
280
+
281
+ const stored = copyLocalFileToAttachments(localPath);
282
+ if (!stored) {
283
+ // Best-effort fallback: still return a Friday URL so app can receive attachment event.
284
+ // Download handler will try reading external source path lazily by token.
285
+ const id = crypto.randomUUID();
286
+ const ext = path.extname(filename);
287
+ const token = ext ? `${id}${ext}` : id;
288
+ externalFileSourceIndex.set(token, normalizeAgentMediaPath(localPath));
289
+ return {
290
+ fileName: filename,
291
+ url: `/friday-next/files/${encodeURIComponent(token)}`,
292
+ };
293
+ }
294
+ return {
295
+ fileName: stored.filename,
296
+ url: `/friday-next/files/${encodeURIComponent(stored.urlToken)}`,
297
+ };
298
+ }
299
+
300
+
301
+ /**
302
+ * Guess MIME type from filename extension.
303
+ */
304
+ export function guessMimeType(filename: string): string {
305
+ const ext = path.extname(filename).toLowerCase();
306
+ const mimeTypes: Record<string, string> = {
307
+ ".png": "image/png",
308
+ ".jpg": "image/jpeg",
309
+ ".jpeg": "image/jpeg",
310
+ ".gif": "image/gif",
311
+ ".webp": "image/webp",
312
+ ".heic": "image/heic",
313
+ ".pdf": "application/pdf",
314
+ ".mp4": "video/mp4",
315
+ ".mov": "video/quicktime",
316
+ ".mp3": "audio/mpeg",
317
+ ".wav": "audio/wav",
318
+ ".ogg": "audio/ogg",
319
+ ".opus": "audio/opus",
320
+ ".m4a": "audio/mp4",
321
+ ".aac": "audio/aac",
322
+ ".flac": "audio/flac",
323
+ ".zip": "application/zip",
324
+ ".txt": "text/plain",
325
+ ".md": "text/markdown",
326
+ ".markdown": "text/markdown",
327
+ ".doc": "application/msword",
328
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
329
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
330
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
331
+ ".csv": "text/csv",
332
+ ".json": "application/json",
333
+ };
334
+ return mimeTypes[ext] ?? "application/octet-stream";
335
+ }
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import { handleMessages } from "./messages.js";
6
+ import { clearFridayNextRuntime, setFridayNextRuntime } from "../../runtime.js";
7
+ import {
8
+ __resetMockFridayDispatchForTests,
9
+ __setMockFridayDispatchForTests,
10
+ } from "../../agent/dispatch-bridge.js";
11
+ import { sseEmitter } from "../../sse/emitter.js";
12
+
13
+ class MockRes extends EventEmitter {
14
+ statusCode = 0;
15
+ headers: Record<string, string> = {};
16
+ body = "";
17
+ setHeader(name: string, value: string): void {
18
+ this.headers[name.toLowerCase()] = value;
19
+ }
20
+ end(body?: string): void {
21
+ if (body) this.body += body;
22
+ this.emit("finish");
23
+ }
24
+ }
25
+
26
+ describe("handleMessages dispatch context (owner fields)", () => {
27
+ afterEach(() => {
28
+ clearFridayNextRuntime();
29
+ __resetMockFridayDispatchForTests();
30
+ });
31
+
32
+ it("passes SenderId and OwnerAllowFrom as normalized device id to runFridayDispatch", async () => {
33
+ setFridayNextRuntime({
34
+ config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
35
+ } as never);
36
+
37
+ let capturedCtx: Record<string, unknown> | null = null;
38
+ const dispatchCalled = new Promise<void>((resolve) => {
39
+ __setMockFridayDispatchForTests((args: unknown) => {
40
+ const a = args as { ctx?: Record<string, unknown> };
41
+ capturedCtx = a.ctx ?? null;
42
+ resolve();
43
+ return Promise.resolve();
44
+ });
45
+ });
46
+
47
+ const req = new PassThrough() as unknown as IncomingMessage;
48
+ req.method = "POST";
49
+ req.headers = { authorization: "Bearer tok" };
50
+
51
+ const res = new MockRes() as unknown as ServerResponse;
52
+ const p = handleMessages(req, res);
53
+
54
+ req.end(
55
+ JSON.stringify({
56
+ deviceId: "aa11bb22-cc33-dd44-ee55-ff6677889900",
57
+ text: "hello",
58
+ sessionKey: "default",
59
+ }),
60
+ );
61
+
62
+ await p;
63
+ await dispatchCalled;
64
+
65
+ expect((res as unknown as MockRes).statusCode).toBe(202);
66
+ expect(capturedCtx).not.toBeNull();
67
+ const want = "AA11BB22-CC33-DD44-EE55-FF6677889900";
68
+ expect(capturedCtx!.SenderId).toBe(want);
69
+ expect(capturedCtx!.OwnerAllowFrom).toEqual([want]);
70
+ expect(capturedCtx!.From).toBe(want);
71
+ });
72
+
73
+ it("adds fridayNext mediaKind metadata for audio deliver payload", async () => {
74
+ setFridayNextRuntime({
75
+ config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
76
+ } as never);
77
+
78
+ const dispatchCalled = new Promise<void>((resolve) => {
79
+ __setMockFridayDispatchForTests(async (args: unknown) => {
80
+ const a = args as {
81
+ dispatcherOptions?: { deliver?: (payload: unknown, info: { kind: string }) => Promise<void> };
82
+ };
83
+ if (a.dispatcherOptions?.deliver) {
84
+ await a.dispatcherOptions.deliver(
85
+ { mediaUrl: "/tmp/tts-run/voice-1.mp3", audioAsVoice: false },
86
+ { kind: "block" },
87
+ );
88
+ }
89
+ resolve();
90
+ });
91
+ });
92
+
93
+ const req = new PassThrough() as unknown as IncomingMessage;
94
+ req.method = "POST";
95
+ req.headers = { authorization: "Bearer tok" };
96
+ const res = new MockRes() as unknown as ServerResponse;
97
+ let observedPayload: Record<string, unknown> | null = null;
98
+ const broadcastSpy = vi.spyOn(sseEmitter, "broadcastToRun").mockImplementation((_: string, evt: unknown) => {
99
+ const data = (evt as { data?: { payload?: Record<string, unknown> } })?.data;
100
+ if (data?.payload) observedPayload = data.payload;
101
+ });
102
+
103
+ const p = handleMessages(req, res);
104
+ req.end(
105
+ JSON.stringify({
106
+ deviceId: "AA11",
107
+ text: "hello",
108
+ sessionKey: "default",
109
+ }),
110
+ );
111
+ await p;
112
+ await dispatchCalled;
113
+ broadcastSpy.mockRestore();
114
+
115
+ expect(observedPayload).not.toBeNull();
116
+ const channelData = observedPayload!.channelData as { fridayNext?: { mediaKind?: string } };
117
+ expect(channelData?.fridayNext?.mediaKind).toBe("tts_likely");
118
+ });
119
+ });