@otterware/openclaw-otter 0.1.3 → 0.2.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/control.js +413 -42
- package/index.js +191 -27
- package/otter.js +42 -3
- package/package.json +1 -1
package/control.js
CHANGED
|
@@ -1,12 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
1
6
|
/** A held tab is released this long after Otter last sent input or a heartbeat (it quit, say). */
|
|
2
7
|
export const CONTROL_TTL_MS = 30_000;
|
|
3
8
|
/** How long a batch waits for one sent before it, before giving up on that one. */
|
|
4
9
|
const GAP_MS = 150;
|
|
10
|
+
/** A tab nobody watches or holds for this long loses its DevTools connection. */
|
|
11
|
+
const IDLE_MS = 60_000;
|
|
12
|
+
const EVENTS_KEPT = 200;
|
|
13
|
+
const CALL_TIMEOUT_MS = 5_000;
|
|
14
|
+
/** Files travel base64 in one gateway message (16 MB at most). */
|
|
15
|
+
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
16
|
+
const UPLOADS_KEPT_MS = 10 * 60_000;
|
|
17
|
+
|
|
18
|
+
/** What the agent's browser tool may still do while you hold a tab: read pages, not act on them. */
|
|
19
|
+
export const READ_ACTIONS = new Set([
|
|
20
|
+
"doctor",
|
|
21
|
+
"status",
|
|
22
|
+
"tabs",
|
|
23
|
+
"snapshot",
|
|
24
|
+
"screenshot",
|
|
25
|
+
"requests",
|
|
26
|
+
"errors",
|
|
27
|
+
"text",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const WORLD = "otter-watch";
|
|
31
|
+
const BINDING = "__otterSaw";
|
|
32
|
+
/**
|
|
33
|
+
* Runs in a world of its own, where the page can't see or stop it: where clicks land, the field
|
|
34
|
+
* being typed in, and what you copy (Chromium's clipboard isn't yours, so the copy is read here).
|
|
35
|
+
*/
|
|
36
|
+
const WATCH_SCRIPT = `(() => {
|
|
37
|
+
if (globalThis.${BINDING}Ready || window !== top) return;
|
|
38
|
+
globalThis.${BINDING}Ready = true;
|
|
39
|
+
const say = (event) => { try { ${BINDING}(JSON.stringify(event)); } catch {} };
|
|
40
|
+
let move = null;
|
|
41
|
+
addEventListener("pointermove", (event) => {
|
|
42
|
+
if (!event.isTrusted) return;
|
|
43
|
+
if (!move) setTimeout(() => { say(move); move = null; }, 33);
|
|
44
|
+
move = { type: "pointer", phase: "move", x: event.clientX, y: event.clientY };
|
|
45
|
+
}, true);
|
|
46
|
+
addEventListener("pointerdown", (event) => {
|
|
47
|
+
if (event.isTrusted) say({ type: "pointer", phase: "click", x: event.clientX, y: event.clientY });
|
|
48
|
+
}, true);
|
|
49
|
+
const field = () => {
|
|
50
|
+
let found = document.activeElement;
|
|
51
|
+
while (found?.shadowRoot?.activeElement) found = found.shadowRoot.activeElement;
|
|
52
|
+
return found;
|
|
53
|
+
};
|
|
54
|
+
let copying = false;
|
|
55
|
+
addEventListener("keydown", (event) => {
|
|
56
|
+
if (!event.isTrusted) return;
|
|
57
|
+
const key = event.key.toLowerCase();
|
|
58
|
+
if ((event.ctrlKey || event.metaKey) && !event.altKey && (key === "c" || key === "x")) {
|
|
59
|
+
copying = true;
|
|
60
|
+
setTimeout(() => { copying = false; });
|
|
61
|
+
}
|
|
62
|
+
const typed = field();
|
|
63
|
+
if (!typed || typed === document.body || typed === document.documentElement) return;
|
|
64
|
+
const box = typed.getBoundingClientRect();
|
|
65
|
+
say({ type: "typing", x: box.x, y: box.y, width: box.width, height: box.height });
|
|
66
|
+
}, true);
|
|
67
|
+
const selection = () => {
|
|
68
|
+
const typed = field();
|
|
69
|
+
if ((typed instanceof HTMLInputElement || typed instanceof HTMLTextAreaElement) && typed.selectionStart !== null)
|
|
70
|
+
return typed.value.slice(typed.selectionStart, typed.selectionEnd ?? typed.selectionStart);
|
|
71
|
+
return String(document.getSelection() ?? "");
|
|
72
|
+
};
|
|
73
|
+
for (const type of ["copy", "cut"]) {
|
|
74
|
+
addEventListener(type, (event) => {
|
|
75
|
+
if (copying && event.isTrusted) say({ type: "clipboard", text: selection().slice(0, 1048576) });
|
|
76
|
+
}, true);
|
|
77
|
+
addEventListener(type, (event) => {
|
|
78
|
+
if (!copying || !event.isTrusted || !event.defaultPrevented) return;
|
|
79
|
+
const text = event.clipboardData?.getData("text/plain");
|
|
80
|
+
if (text) say({ type: "clipboard", text: text.slice(0, 1048576) });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
})();`;
|
|
5
84
|
|
|
6
85
|
const MOUSE_TYPES = { move: "mouseMoved", down: "mousePressed", up: "mouseReleased" };
|
|
7
86
|
const BUTTONS = new Set(["none", "left", "middle", "right"]);
|
|
8
87
|
const num = (value) => (Number.isFinite(value) ? value : 0);
|
|
9
88
|
|
|
89
|
+
/** Headless Linux Chromium only copies, cuts (and with ⌘, selects all or undoes) when told to. */
|
|
90
|
+
export function editingCommand(key, modifiers) {
|
|
91
|
+
const modifier = modifiers & 0b0111;
|
|
92
|
+
if (modifier !== 2 && modifier !== 4) return null;
|
|
93
|
+
const lower = key.toLowerCase();
|
|
94
|
+
const command =
|
|
95
|
+
lower === "c"
|
|
96
|
+
? "copy"
|
|
97
|
+
: lower === "x"
|
|
98
|
+
? "cut"
|
|
99
|
+
: modifier !== 4
|
|
100
|
+
? null
|
|
101
|
+
: lower === "a"
|
|
102
|
+
? "selectAll"
|
|
103
|
+
: lower === "z"
|
|
104
|
+
? modifiers & 8
|
|
105
|
+
? "redo"
|
|
106
|
+
: "undo"
|
|
107
|
+
: null;
|
|
108
|
+
return command ? { commands: [command] } : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
10
111
|
/** The DevTools call that replays one of Otter's input events, as a person's input. */
|
|
11
112
|
export function cdpInput(event) {
|
|
12
113
|
const modifiers = num(event?.modifiers);
|
|
@@ -39,16 +140,22 @@ export function cdpInput(event) {
|
|
|
39
140
|
];
|
|
40
141
|
case "key": {
|
|
41
142
|
if (typeof event.key !== "string" || typeof event.code !== "string") return null;
|
|
143
|
+
if (event.action === "up")
|
|
144
|
+
return [
|
|
145
|
+
"Input.dispatchKeyEvent",
|
|
146
|
+
{ type: "keyUp", key: event.key, code: event.code, modifiers },
|
|
147
|
+
];
|
|
42
148
|
const text = typeof event.text === "string" ? event.text : undefined;
|
|
43
149
|
return [
|
|
44
150
|
"Input.dispatchKeyEvent",
|
|
45
151
|
{
|
|
46
|
-
type:
|
|
152
|
+
type: text ? "keyDown" : "rawKeyDown",
|
|
47
153
|
key: event.key,
|
|
48
154
|
code: event.code,
|
|
49
155
|
windowsVirtualKeyCode: num(event.keyCode),
|
|
50
156
|
nativeVirtualKeyCode: num(event.keyCode),
|
|
51
157
|
...(text ? { text, unmodifiedText: text } : {}),
|
|
158
|
+
...editingCommand(event.key, modifiers),
|
|
52
159
|
modifiers,
|
|
53
160
|
},
|
|
54
161
|
];
|
|
@@ -57,12 +164,26 @@ export function cdpInput(event) {
|
|
|
57
164
|
return typeof event.text === "string"
|
|
58
165
|
? ["Input.insertText", { text: event.text.slice(0, 10_000) }]
|
|
59
166
|
: null;
|
|
167
|
+
case "dialog":
|
|
168
|
+
return [
|
|
169
|
+
"Page.handleJavaScriptDialog",
|
|
170
|
+
{
|
|
171
|
+
accept: event.accept === true,
|
|
172
|
+
...(typeof event.promptText === "string" ? { promptText: event.promptText } : {}),
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
case "navigate":
|
|
176
|
+
return typeof event.url === "string" && /^https?:\/\//i.test(event.url)
|
|
177
|
+
? ["Page.navigate", { url: event.url }]
|
|
178
|
+
: null;
|
|
179
|
+
case "reload":
|
|
180
|
+
return ["Page.reload", {}];
|
|
60
181
|
default:
|
|
61
182
|
return null;
|
|
62
183
|
}
|
|
63
184
|
}
|
|
64
185
|
|
|
65
|
-
/** A page's own DevTools connection
|
|
186
|
+
/** A page's own DevTools connection. Input goes without waiting for Chromium's replies. */
|
|
66
187
|
export async function connectTarget(cdpUrl, targetId) {
|
|
67
188
|
const list = await fetch(new URL("/json/list", cdpUrl), { signal: AbortSignal.timeout(5000) })
|
|
68
189
|
.then((response) => response.json())
|
|
@@ -83,79 +204,329 @@ export async function connectTarget(cdpUrl, targetId) {
|
|
|
83
204
|
);
|
|
84
205
|
});
|
|
85
206
|
let id = 0;
|
|
207
|
+
const replies = new Map();
|
|
208
|
+
const listeners = new Set();
|
|
209
|
+
socket.addEventListener("message", ({ data }) => {
|
|
210
|
+
const message = JSON.parse(String(data));
|
|
211
|
+
if (message.id === undefined) {
|
|
212
|
+
for (const listener of listeners) listener(message.method, message.params ?? {});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const reply = replies.get(message.id);
|
|
216
|
+
if (!reply) return;
|
|
217
|
+
replies.delete(message.id);
|
|
218
|
+
if (message.error) reply.reject(new Error(message.error.message));
|
|
219
|
+
else reply.resolve(message.result ?? {});
|
|
220
|
+
});
|
|
221
|
+
socket.addEventListener("close", () => {
|
|
222
|
+
for (const reply of replies.values()) reply.reject(new Error("That tab has closed."));
|
|
223
|
+
replies.clear();
|
|
224
|
+
});
|
|
86
225
|
return {
|
|
87
226
|
open: () => socket.readyState === WebSocket.OPEN,
|
|
88
227
|
send: (method, params) => socket.send(JSON.stringify({ id: ++id, method, params })),
|
|
228
|
+
// Bounded: a page showing a pop-up holds back what runs script in it, and a request left
|
|
229
|
+
// waiting keeps the plugin from being updated.
|
|
230
|
+
call: (method, params = {}) =>
|
|
231
|
+
new Promise((resolve, reject) => {
|
|
232
|
+
const n = ++id;
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
replies.delete(n);
|
|
235
|
+
reject(new Error("The agent's browser didn't answer."));
|
|
236
|
+
}, CALL_TIMEOUT_MS);
|
|
237
|
+
replies.set(n, {
|
|
238
|
+
resolve: (value) => (clearTimeout(timer), resolve(value)),
|
|
239
|
+
reject: (error) => (clearTimeout(timer), reject(error)),
|
|
240
|
+
});
|
|
241
|
+
socket.send(JSON.stringify({ id: n, method, params }));
|
|
242
|
+
}),
|
|
243
|
+
on: (listener) => listeners.add(listener),
|
|
89
244
|
close: () => socket.close(),
|
|
90
245
|
};
|
|
91
246
|
}
|
|
92
247
|
|
|
93
248
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
249
|
+
* The agent's tabs as Otter sees them: who holds which (and their input, replayed in the order
|
|
250
|
+
* Otter numbered it however the gateway runs requests), and what happens on them (where the
|
|
251
|
+
* pointer lands, what's typed in, pop-ups, file pickers, copies) for whoever watches.
|
|
96
252
|
*/
|
|
97
|
-
export function createBrowserControl({
|
|
98
|
-
|
|
253
|
+
export function createBrowserControl({
|
|
254
|
+
cdpUrlFor,
|
|
255
|
+
answerDialog,
|
|
256
|
+
openDialog,
|
|
257
|
+
connect = connectTarget,
|
|
258
|
+
now = Date.now,
|
|
259
|
+
uploads = path.join(tmpdir(), "otter-uploads"),
|
|
260
|
+
}) {
|
|
261
|
+
const tabs = new Map();
|
|
262
|
+
// Event numbers start over when the plugin does (an update, a gateway restart): say which run.
|
|
263
|
+
const run = randomUUID();
|
|
99
264
|
|
|
265
|
+
let sweeper;
|
|
266
|
+
const tabOf = (targetId) => {
|
|
267
|
+
let tab = tabs.get(targetId);
|
|
268
|
+
if (!tab) {
|
|
269
|
+
tab = {
|
|
270
|
+
targetId,
|
|
271
|
+
lock: null,
|
|
272
|
+
events: [],
|
|
273
|
+
seq: 0,
|
|
274
|
+
waiters: new Set(),
|
|
275
|
+
seen: now(),
|
|
276
|
+
chain: Promise.resolve(),
|
|
277
|
+
};
|
|
278
|
+
tabs.set(targetId, tab);
|
|
279
|
+
sweeper ??= setInterval(() => sweep(), IDLE_MS);
|
|
280
|
+
sweeper.unref?.();
|
|
281
|
+
}
|
|
282
|
+
return tab;
|
|
283
|
+
};
|
|
100
284
|
const holding = (targetId) => {
|
|
101
|
-
const tab =
|
|
102
|
-
if (tab && tab.until > now()) return tab;
|
|
103
|
-
if (tab)
|
|
285
|
+
const tab = tabs.get(targetId);
|
|
286
|
+
if (tab?.lock && tab.lock.until > now()) return tab;
|
|
287
|
+
if (tab?.lock) unlock(tab);
|
|
104
288
|
return undefined;
|
|
105
289
|
};
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
290
|
+
const unlock = (tab) => {
|
|
291
|
+
clearTimeout(tab.lock?.gap);
|
|
292
|
+
for (const batch of tab.lock?.waiting.values() ?? []) batch.done();
|
|
293
|
+
tab.lock = null;
|
|
294
|
+
tab.chooser = null;
|
|
295
|
+
void tab.page
|
|
296
|
+
?.then(
|
|
297
|
+
(page) =>
|
|
298
|
+
page.open() && page.call("Page.setInterceptFileChooserDialog", { enabled: false }),
|
|
299
|
+
)
|
|
300
|
+
.catch(() => {});
|
|
301
|
+
};
|
|
302
|
+
/** Lets go of tabs nobody has held or watched for a while, and their DevTools connections. */
|
|
303
|
+
const sweep = (all = false) => {
|
|
304
|
+
for (const [targetId, tab] of tabs) {
|
|
305
|
+
if (!all && (holding(targetId) || now() - tab.seen < IDLE_MS)) continue;
|
|
306
|
+
if (tab.lock) unlock(tab);
|
|
307
|
+
for (const wake of tab.waiters) wake();
|
|
308
|
+
tabs.delete(targetId);
|
|
309
|
+
void tab.page?.then((page) => page.close()).catch(() => {});
|
|
310
|
+
}
|
|
311
|
+
if (tabs.size === 0 && sweeper) {
|
|
312
|
+
clearInterval(sweeper);
|
|
313
|
+
sweeper = undefined;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const tell = (tab, event) => {
|
|
317
|
+
tab.seq += 1;
|
|
318
|
+
tab.events.push({ ...event, seq: tab.seq });
|
|
319
|
+
if (tab.events.length > EVENTS_KEPT) tab.events.shift();
|
|
320
|
+
for (const wake of tab.waiters) wake();
|
|
321
|
+
};
|
|
322
|
+
const watch = (tab, page) => {
|
|
323
|
+
page.on((method, params) => {
|
|
324
|
+
if (method === "Runtime.bindingCalled" && params.name === BINDING) {
|
|
325
|
+
try {
|
|
326
|
+
tell(tab, JSON.parse(params.payload));
|
|
327
|
+
} catch {}
|
|
328
|
+
} else if (method === "Page.javascriptDialogOpening") {
|
|
329
|
+
tab.dialog = {
|
|
330
|
+
dialogType: params.type,
|
|
331
|
+
message: String(params.message ?? "").slice(0, 4000),
|
|
332
|
+
defaultPrompt: String(params.defaultPrompt ?? "").slice(0, 4000),
|
|
333
|
+
};
|
|
334
|
+
tell(tab, { type: "dialog", ...tab.dialog });
|
|
335
|
+
} else if (method === "Page.javascriptDialogClosed") {
|
|
336
|
+
tab.dialog = null;
|
|
337
|
+
tell(tab, { type: "dialogClosed" });
|
|
338
|
+
} else if (method === "Page.fileChooserOpened" && tab.lock) {
|
|
339
|
+
tab.chooser = { backendNodeId: params.backendNodeId };
|
|
340
|
+
tell(tab, { type: "fileChooser", multiple: params.mode === "selectMultiple" });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
// Not waited for: a page showing a pop-up holds every command until it closes, and runs them
|
|
344
|
+
// in order then. Binding calls only arrive with Runtime on (its other events are ignored).
|
|
345
|
+
for (const [method, params] of [
|
|
346
|
+
["Runtime.enable", {}],
|
|
347
|
+
["Runtime.addBinding", { name: BINDING, executionContextName: WORLD }],
|
|
348
|
+
["Page.enable", {}],
|
|
349
|
+
[
|
|
350
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
351
|
+
{ source: WATCH_SCRIPT, worldName: WORLD, runImmediately: true },
|
|
352
|
+
],
|
|
353
|
+
])
|
|
354
|
+
void page.call(method, params).catch(() => {});
|
|
355
|
+
};
|
|
356
|
+
const open = async (tab, targetId, profile) => {
|
|
357
|
+
const page = await connect(await cdpUrlFor(profile), targetId);
|
|
358
|
+
watch(tab, page);
|
|
359
|
+
// A pop-up already open before we connected isn't announced again.
|
|
360
|
+
const already = await openDialog?.(targetId, profile).catch(() => null);
|
|
361
|
+
if (already && !tab.dialog) {
|
|
362
|
+
tab.dialog = already;
|
|
363
|
+
tell(tab, { type: "dialog", ...already });
|
|
364
|
+
}
|
|
365
|
+
return page;
|
|
111
366
|
};
|
|
112
|
-
const open = (targetId, profile) => cdpUrlFor(profile).then((url) => connect(url, targetId));
|
|
113
367
|
const pageOf = async (tab, targetId, profile) => {
|
|
114
|
-
tab.
|
|
368
|
+
tab.profile = profile;
|
|
369
|
+
tab.page ??= open(tab, targetId, profile);
|
|
115
370
|
const page = await tab.page.catch(() => null);
|
|
116
371
|
if (page?.open()) return page;
|
|
117
|
-
tab.page = open(targetId, profile);
|
|
372
|
+
tab.page = open(tab, targetId, profile);
|
|
118
373
|
return tab.page;
|
|
119
374
|
};
|
|
375
|
+
|
|
376
|
+
const replay = async (tab, page, event) => {
|
|
377
|
+
// Answered through OpenClaw when it can, so it knows the pop-up is gone and the agent can go on.
|
|
378
|
+
if (event?.type === "dialog" && answerDialog) {
|
|
379
|
+
const answer = { accept: event.accept === true, promptText: event.promptText };
|
|
380
|
+
if (
|
|
381
|
+
await answerDialog(tab.targetId, tab.profile, answer).then(
|
|
382
|
+
() => true,
|
|
383
|
+
() => false,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (event?.type === "history") {
|
|
389
|
+
const { currentIndex, entries } = await page.call("Page.getNavigationHistory");
|
|
390
|
+
const entry = entries?.[currentIndex + (event.delta === -1 ? -1 : 1)];
|
|
391
|
+
if (entry) page.send("Page.navigateToHistoryEntry", { entryId: entry.id });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const call = cdpInput(event);
|
|
395
|
+
if (call) page.send(...call);
|
|
396
|
+
};
|
|
397
|
+
/**
|
|
398
|
+
* Replays the batches now in order, one after another for the tab: a pop-up's answer or a
|
|
399
|
+
* history move waits on a reply, and what follows must not overtake it.
|
|
400
|
+
*/
|
|
120
401
|
const flush = (tab, page) => {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
tab.
|
|
128
|
-
|
|
402
|
+
const lock = tab.lock;
|
|
403
|
+
const missing = lock.next;
|
|
404
|
+
while (lock.waiting.has(lock.next)) {
|
|
405
|
+
const batch = lock.waiting.get(lock.next);
|
|
406
|
+
lock.waiting.delete(lock.next);
|
|
407
|
+
lock.next += 1;
|
|
408
|
+
tab.chain = tab.chain
|
|
409
|
+
.then(async () => {
|
|
410
|
+
// Handed back meanwhile: what's still queued is dropped, not sent over the agent.
|
|
411
|
+
for (const event of batch.events) {
|
|
412
|
+
if (tab.lock !== lock) return;
|
|
413
|
+
await replay(tab, page, event).catch(() => {});
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
.finally(batch.done);
|
|
129
417
|
}
|
|
130
418
|
// A batch is missing: let it arrive, then go on without it. Batches arriving behind it
|
|
131
419
|
// don't push that back.
|
|
132
|
-
if (
|
|
133
|
-
clearTimeout(
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
tab.
|
|
420
|
+
if (lock.gap && lock.next === missing && lock.waiting.size > 0) return;
|
|
421
|
+
clearTimeout(lock.gap);
|
|
422
|
+
lock.gap = undefined;
|
|
423
|
+
if (lock.waiting.size === 0) return;
|
|
424
|
+
lock.gap = setTimeout(() => {
|
|
425
|
+
lock.gap = undefined;
|
|
426
|
+
if (tab.lock !== lock) return;
|
|
427
|
+
lock.next = Math.min(...lock.waiting.keys());
|
|
139
428
|
flush(tab, page);
|
|
140
429
|
}, GAP_MS);
|
|
141
430
|
};
|
|
142
431
|
|
|
143
432
|
return {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
433
|
+
/** The plugin is being replaced: let go of everything. */
|
|
434
|
+
dispose: () => sweep(true),
|
|
435
|
+
/** Whether the agent's browser tool may run this call now: reads always, actions unless held. */
|
|
436
|
+
allows: (params) =>
|
|
437
|
+
READ_ACTIONS.has(params?.action) || ![...tabs.keys()].some((id) => holding(id)),
|
|
438
|
+
async take(targetId, profile) {
|
|
439
|
+
sweep();
|
|
440
|
+
const held = holding(targetId);
|
|
441
|
+
if (held) {
|
|
442
|
+
held.lock.until = now() + CONTROL_TTL_MS;
|
|
443
|
+
held.seen = now();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const tab = tabOf(targetId);
|
|
447
|
+
tab.seen = now();
|
|
448
|
+
tab.lock = { until: now() + CONTROL_TTL_MS, next: 1, waiting: new Map() };
|
|
449
|
+
// The page's own file picker is offered to you while you hold it.
|
|
450
|
+
const page = await pageOf(tab, targetId, profile);
|
|
451
|
+
void page.call("Page.setInterceptFileChooserDialog", { enabled: true }).catch(() => {});
|
|
452
|
+
},
|
|
453
|
+
release(targetId) {
|
|
454
|
+
const tab = tabs.get(targetId);
|
|
455
|
+
if (tab?.lock) unlock(tab);
|
|
149
456
|
},
|
|
150
|
-
release: drop,
|
|
151
457
|
async input(targetId, profile, seq, events) {
|
|
152
458
|
const tab = holding(targetId);
|
|
153
459
|
if (!tab) throw new Error("Take control of this tab first.");
|
|
154
|
-
tab.until = now() + CONTROL_TTL_MS;
|
|
155
|
-
|
|
156
|
-
|
|
460
|
+
tab.lock.until = now() + CONTROL_TTL_MS;
|
|
461
|
+
tab.seen = now();
|
|
462
|
+
if (!Number.isInteger(seq) || seq < tab.lock.next || !Array.isArray(events)) return;
|
|
463
|
+
const lock = tab.lock;
|
|
464
|
+
// The request stays open until its batch is replayed: answering a pop-up goes through
|
|
465
|
+
// OpenClaw, which drops the call once the request that made it returns.
|
|
466
|
+
const replayed = new Promise((done) =>
|
|
467
|
+
lock.waiting.set(seq, { events: events.slice(0, 200), done }),
|
|
468
|
+
);
|
|
469
|
+
const page = await pageOf(tab, targetId, profile);
|
|
470
|
+
if (tab.lock === lock) flush(tab, page);
|
|
471
|
+
await replayed;
|
|
472
|
+
},
|
|
473
|
+
/**
|
|
474
|
+
* What happened on the tab since `since`, waiting up to `waitMs` for something to. Without
|
|
475
|
+
* `since`: no events, just where they're up to and any open pop-up.
|
|
476
|
+
*/
|
|
477
|
+
async events(targetId, profile, since, waitMs, from) {
|
|
478
|
+
if (from !== run) since = undefined;
|
|
479
|
+
sweep();
|
|
480
|
+
const tab = tabOf(targetId);
|
|
481
|
+
tab.seen = now();
|
|
482
|
+
await pageOf(tab, targetId, profile);
|
|
483
|
+
const state = () => ({
|
|
484
|
+
run,
|
|
485
|
+
seq: tab.seq,
|
|
486
|
+
dialog: tab.dialog ?? null,
|
|
487
|
+
events: Number.isInteger(since) ? tab.events.filter((e) => e.seq > since) : [],
|
|
488
|
+
});
|
|
489
|
+
if (!Number.isInteger(since) || since < tab.seq || !(waitMs > 0)) return state();
|
|
490
|
+
await new Promise((resolve) => {
|
|
491
|
+
const done = () => {
|
|
492
|
+
clearTimeout(timer);
|
|
493
|
+
tab.waiters.delete(done);
|
|
494
|
+
resolve();
|
|
495
|
+
};
|
|
496
|
+
const timer = setTimeout(done, Math.min(waitMs, 25_000));
|
|
497
|
+
tab.waiters.add(done);
|
|
498
|
+
});
|
|
499
|
+
tab.seen = now();
|
|
500
|
+
return state();
|
|
501
|
+
},
|
|
502
|
+
/** The files you picked for the page's own picker (none cancels it). */
|
|
503
|
+
async upload(targetId, profile, files) {
|
|
504
|
+
const tab = holding(targetId);
|
|
505
|
+
if (!tab) throw new Error("Take control of this tab first.");
|
|
506
|
+
const chooser = tab.chooser;
|
|
507
|
+
if (!chooser) throw new Error("The page isn't asking for a file.");
|
|
508
|
+
tab.chooser = null;
|
|
509
|
+
const picked = Array.isArray(files) ? files : [];
|
|
510
|
+
const total = picked.reduce((sum, f) => sum + String(f?.data ?? "").length * 0.75, 0);
|
|
511
|
+
if (total > MAX_UPLOAD_BYTES) throw new Error("Those files are too large (10 MB at most).");
|
|
512
|
+
const dir = path.join(uploads, randomUUID());
|
|
513
|
+
const paths = [];
|
|
514
|
+
if (picked.length > 0) {
|
|
515
|
+
for (const [i, file] of picked.entries()) {
|
|
516
|
+
// A folder each, so two files of the same name both reach the page as named.
|
|
517
|
+
const at = path.join(dir, String(i));
|
|
518
|
+
await mkdir(at, { recursive: true });
|
|
519
|
+
const name = path.basename(String(file?.name ?? "")) || `file-${i + 1}`;
|
|
520
|
+
await writeFile(path.join(at, name), Buffer.from(String(file?.data ?? ""), "base64"));
|
|
521
|
+
paths.push(path.join(at, name));
|
|
522
|
+
}
|
|
523
|
+
setTimeout(() => void rm(dir, { recursive: true, force: true }), UPLOADS_KEPT_MS).unref?.();
|
|
524
|
+
}
|
|
157
525
|
const page = await pageOf(tab, targetId, profile);
|
|
158
|
-
|
|
526
|
+
await page.call("DOM.setFileInputFiles", {
|
|
527
|
+
files: paths,
|
|
528
|
+
backendNodeId: chooser.backendNodeId,
|
|
529
|
+
});
|
|
159
530
|
},
|
|
160
531
|
};
|
|
161
532
|
}
|
package/index.js
CHANGED
|
@@ -1,19 +1,46 @@
|
|
|
1
|
+
import { AsyncResource } from "node:async_hooks";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
createChannelPluginBase,
|
|
4
5
|
createChatChannelPlugin,
|
|
5
6
|
defineChannelPluginEntry,
|
|
6
7
|
} from "openclaw/plugin-sdk/channel-core";
|
|
8
|
+
import { toInboundMediaFacts } from "openclaw/plugin-sdk/channel-inbound";
|
|
9
|
+
import { defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
7
10
|
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
|
|
8
11
|
import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime";
|
|
9
12
|
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
|
10
13
|
import { createBrowserControl } from "./control.js";
|
|
14
|
+
import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards";
|
|
11
15
|
import { readWorkspaceFile } from "./files.js";
|
|
12
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
accountOf,
|
|
18
|
+
chatOf,
|
|
19
|
+
chatTarget,
|
|
20
|
+
isOwnerConfigured,
|
|
21
|
+
messageOf,
|
|
22
|
+
OTTER_TARGET,
|
|
23
|
+
OWNER,
|
|
24
|
+
pointerOf,
|
|
25
|
+
sessionOf,
|
|
26
|
+
tell,
|
|
27
|
+
} from "./otter.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where a message sent from Otter starts its run: outside the request that carried it,
|
|
31
|
+
* so the run is the channel's owner's and not that connection's, and outlives it.
|
|
32
|
+
*/
|
|
33
|
+
const receiving = new AsyncResource("otter-inbound");
|
|
34
|
+
|
|
35
|
+
const owner = defineStableChannelIngressIdentity({
|
|
36
|
+
key: "otter-sender",
|
|
37
|
+
normalize: (value) => String(value).trim(),
|
|
38
|
+
sensitivity: "pii",
|
|
39
|
+
});
|
|
13
40
|
|
|
14
41
|
async function deliver(ctx, payload) {
|
|
15
42
|
const account = accountOf(ctx.cfg);
|
|
16
|
-
const pointer = pointerOf({ sessionKey: ctx.sessionKey, payload });
|
|
43
|
+
const pointer = pointerOf({ sessionKey: ctx.sessionKey, to: ctx.to, payload });
|
|
17
44
|
await tell(account, pointer, { signal: ctx.signal });
|
|
18
45
|
return { channel: "otter", messageId: randomUUID(), timestamp: pointer.at };
|
|
19
46
|
}
|
|
@@ -38,33 +65,120 @@ export const otterChannel = createChatChannelPlugin({
|
|
|
38
65
|
},
|
|
39
66
|
}),
|
|
40
67
|
messaging: {
|
|
41
|
-
targetResolver: {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
targetResolver: {
|
|
69
|
+
looksLikeId: () => true,
|
|
70
|
+
hint: `"${OTTER_TARGET}", or "chat:<session key>" for one of Otter's chats`,
|
|
71
|
+
},
|
|
72
|
+
// A delivery to a chat is kept in its session; any other, in the agent's Otter session.
|
|
73
|
+
resolveOutboundSessionRoute: ({ agentId, target }) => {
|
|
74
|
+
const chat = chatOf(target);
|
|
75
|
+
const sessionKey = sessionOf(target, agentId);
|
|
76
|
+
const to = chat ? chatTarget(chat) : OTTER_TARGET;
|
|
77
|
+
return {
|
|
78
|
+
sessionKey,
|
|
79
|
+
baseSessionKey: sessionKey,
|
|
80
|
+
recipientSessionExact: true,
|
|
81
|
+
peer: { kind: "direct", id: to },
|
|
82
|
+
chatType: "direct",
|
|
83
|
+
from: "otter",
|
|
84
|
+
to,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
52
87
|
},
|
|
53
88
|
},
|
|
54
89
|
outbound: {
|
|
55
90
|
deliveryMode: "direct",
|
|
56
|
-
resolveTarget: () =>
|
|
91
|
+
resolveTarget: (params) => {
|
|
92
|
+
const chat = chatOf(params?.to);
|
|
93
|
+
return { ok: true, to: chat ? chatTarget(chat) : OTTER_TARGET };
|
|
94
|
+
},
|
|
57
95
|
sendText: (ctx) => deliver(ctx),
|
|
58
96
|
sendPayload: (ctx) => deliver(ctx, ctx.payload),
|
|
59
97
|
},
|
|
60
98
|
});
|
|
61
99
|
|
|
100
|
+
/** Runs what you sent from Otter as the channel's owner: an inbound message, as from any chat app. */
|
|
101
|
+
async function receive(api, cfg, message) {
|
|
102
|
+
const { sessionKey, agentId, text, messageId, files } = message;
|
|
103
|
+
const inbound = api.runtime.channel.inbound;
|
|
104
|
+
const saved = await Promise.all(
|
|
105
|
+
files.map((file) =>
|
|
106
|
+
api.runtime.channel.media.saveMediaBuffer(
|
|
107
|
+
Buffer.from(file.content, "base64"),
|
|
108
|
+
file.mimeType,
|
|
109
|
+
"inbound",
|
|
110
|
+
undefined,
|
|
111
|
+
typeof file.fileName === "string" ? file.fileName : undefined,
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
const channelIngress = await inbound.ingress.resolve({
|
|
116
|
+
channelId: "otter",
|
|
117
|
+
accountId: "default",
|
|
118
|
+
identity: owner,
|
|
119
|
+
subject: { stableId: OWNER },
|
|
120
|
+
conversation: { kind: "direct", id: sessionKey },
|
|
121
|
+
contextBinding: { agentId, sessionKey, messageId, inboundEventKind: "user_request" },
|
|
122
|
+
event: { kind: "message", authMode: "inbound", mayPair: false },
|
|
123
|
+
policy: { dmPolicy: "allowlist" },
|
|
124
|
+
allowFrom: [OWNER],
|
|
125
|
+
readStoreAllowFrom: async () => [],
|
|
126
|
+
});
|
|
127
|
+
const to = chatTarget(sessionKey);
|
|
128
|
+
const ctxPayload = await inbound.buildContext({
|
|
129
|
+
channelIngress,
|
|
130
|
+
channel: "otter",
|
|
131
|
+
accountId: "default",
|
|
132
|
+
messageId,
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
from: `otter:${OWNER}`,
|
|
135
|
+
sender: { id: OWNER, name: "Otter" },
|
|
136
|
+
conversation: { kind: "direct", id: sessionKey },
|
|
137
|
+
route: { agentId, accountId: "default", routeSessionKey: sessionKey },
|
|
138
|
+
reply: { to, originatingTo: to },
|
|
139
|
+
message: { body: text, rawBody: text, commandBody: text, bodyForAgent: text },
|
|
140
|
+
...(saved.length > 0 && {
|
|
141
|
+
media: toInboundMediaFacts(
|
|
142
|
+
saved.map((file) => ({ path: file.path, contentType: file.contentType, messageId })),
|
|
143
|
+
),
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
await inbound.dispatch({
|
|
147
|
+
cfg,
|
|
148
|
+
channel: "otter",
|
|
149
|
+
accountId: "default",
|
|
150
|
+
route: { agentId, sessionKey },
|
|
151
|
+
ctxPayload,
|
|
152
|
+
replyOptions: { runId: messageId, sourceReplyDeliveryMode: "automatic" },
|
|
153
|
+
// The reply is kept in the chat's session, which Otter is reading as it's written.
|
|
154
|
+
delivery: { deliver: async () => ({ visibleReplySent: true }) },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
62
158
|
export default defineChannelPluginEntry({
|
|
63
159
|
id: "otter",
|
|
64
160
|
name: "Otter",
|
|
65
161
|
description: "Send what your agents want you to see to Otter.",
|
|
66
162
|
plugin: otterChannel,
|
|
67
163
|
registerFull(api) {
|
|
164
|
+
// What you send in an Otter chat, as the gateway's owner: `chat.send`'s run, with the authority
|
|
165
|
+
// a configured channel owner has (an automation another chat made, for one).
|
|
166
|
+
api.registerGatewayMethod(
|
|
167
|
+
"otter.chat.send",
|
|
168
|
+
async ({ params, context }) => {
|
|
169
|
+
const cfg = context?.getRuntimeConfig?.() ?? api.config;
|
|
170
|
+
if (!isOwnerConfigured(cfg)) throw new Error("otter_not_owner");
|
|
171
|
+
const message = messageOf(params);
|
|
172
|
+
receiving.runInAsyncScope(() =>
|
|
173
|
+
runDetachedWebhookWork(() => receive(api, cfg, message)).catch((error) =>
|
|
174
|
+
api.logger?.warn?.(`otter: a message from Otter failed: ${String(error)}`),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
return { runId: message.messageId, status: "started" };
|
|
178
|
+
},
|
|
179
|
+
{ scope: "operator.admin" },
|
|
180
|
+
);
|
|
181
|
+
|
|
68
182
|
// Otter's preview of a file an agent linked: PDFs and other files sessions.files.get won't send.
|
|
69
183
|
api.registerGatewayMethod(
|
|
70
184
|
"otter.files.get",
|
|
@@ -81,15 +195,43 @@ export default defineChannelPluginEntry({
|
|
|
81
195
|
|
|
82
196
|
// Take control of the agent's browser from Otter: your input goes to the tab as a person's
|
|
83
197
|
// would, and the agent's browser waits until you hand it back.
|
|
198
|
+
const browser = async (method, path, query, body) => {
|
|
199
|
+
const result = await dispatchGatewayMethod("browser.request", {
|
|
200
|
+
method,
|
|
201
|
+
path,
|
|
202
|
+
...(query ? { query } : {}),
|
|
203
|
+
...(body ? { body } : {}),
|
|
204
|
+
});
|
|
205
|
+
if (!result?.ok) throw new Error(result?.error?.message ?? "The browser didn't answer.");
|
|
206
|
+
return result.payload;
|
|
207
|
+
};
|
|
208
|
+
const pendingDialog = async (targetId, profile) => {
|
|
209
|
+
const query = { ...(profile ? { profile } : {}), targetId };
|
|
210
|
+
return (await browser("GET", "/dialogs", query))?.browserState?.dialogs?.pending?.[0];
|
|
211
|
+
};
|
|
84
212
|
const control = createBrowserControl({
|
|
85
|
-
async
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
213
|
+
async openDialog(targetId, profile) {
|
|
214
|
+
const open = await pendingDialog(targetId, profile);
|
|
215
|
+
return open
|
|
216
|
+
? {
|
|
217
|
+
dialogType: String(open.type),
|
|
218
|
+
message: String(open.message ?? ""),
|
|
219
|
+
defaultPrompt: String(open.defaultValue ?? ""),
|
|
220
|
+
}
|
|
221
|
+
: null;
|
|
222
|
+
},
|
|
223
|
+
async answerDialog(targetId, profile, { accept, promptText }) {
|
|
224
|
+
const pending = await pendingDialog(targetId, profile);
|
|
225
|
+
if (!pending) throw new Error("No pop-up is open.");
|
|
226
|
+
await browser("POST", "/hooks/dialog", profile ? { profile } : undefined, {
|
|
227
|
+
targetId,
|
|
228
|
+
dialogId: pending.id,
|
|
229
|
+
accept,
|
|
230
|
+
...(typeof promptText === "string" ? { promptText } : {}),
|
|
89
231
|
});
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const profiles =
|
|
232
|
+
},
|
|
233
|
+
async cdpUrlFor(profile) {
|
|
234
|
+
const profiles = (await browser("GET", "/profiles"))?.profiles ?? [];
|
|
93
235
|
const found = profiles.find((p) => (profile ? p.name === profile : p.isDefault));
|
|
94
236
|
const url = typeof found?.cdpUrl === "string" ? URL.parse(found.cdpUrl) : null;
|
|
95
237
|
if (!url || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
|
|
@@ -97,15 +239,18 @@ export default defineChannelPluginEntry({
|
|
|
97
239
|
return url.href;
|
|
98
240
|
},
|
|
99
241
|
});
|
|
242
|
+
api.lifecycle?.onDispose?.(() => control.dispose());
|
|
100
243
|
const targetOf = (params) => {
|
|
101
244
|
if (typeof params?.targetId !== "string" || !params.targetId)
|
|
102
245
|
throw new Error("targetId is required");
|
|
103
246
|
return params.targetId;
|
|
104
247
|
};
|
|
248
|
+
const profileOf = (params) =>
|
|
249
|
+
typeof params?.profile === "string" ? params.profile : undefined;
|
|
105
250
|
api.registerGatewayMethod(
|
|
106
251
|
"otter.browser.control",
|
|
107
|
-
({ params }) => {
|
|
108
|
-
if (params?.take === true) control.take(targetOf(params));
|
|
252
|
+
async ({ params }) => {
|
|
253
|
+
if (params?.take === true) await control.take(targetOf(params), profileOf(params));
|
|
109
254
|
else control.release(targetOf(params));
|
|
110
255
|
return { controlled: params?.take === true };
|
|
111
256
|
},
|
|
@@ -114,18 +259,37 @@ export default defineChannelPluginEntry({
|
|
|
114
259
|
api.registerGatewayMethod(
|
|
115
260
|
"otter.browser.input",
|
|
116
261
|
async ({ params }) => {
|
|
117
|
-
|
|
118
|
-
|
|
262
|
+
await control.input(targetOf(params), profileOf(params), params?.seq, params?.events);
|
|
263
|
+
return {};
|
|
264
|
+
},
|
|
265
|
+
{ scope: "operator.admin" },
|
|
266
|
+
);
|
|
267
|
+
api.registerGatewayMethod(
|
|
268
|
+
"otter.browser.events",
|
|
269
|
+
({ params }) =>
|
|
270
|
+
control.events(
|
|
271
|
+
targetOf(params),
|
|
272
|
+
profileOf(params),
|
|
273
|
+
params?.since,
|
|
274
|
+
params?.waitMs,
|
|
275
|
+
params?.run,
|
|
276
|
+
),
|
|
277
|
+
{ scope: "operator.admin" },
|
|
278
|
+
);
|
|
279
|
+
api.registerGatewayMethod(
|
|
280
|
+
"otter.browser.upload",
|
|
281
|
+
async ({ params }) => {
|
|
282
|
+
await control.upload(targetOf(params), profileOf(params), params?.files);
|
|
119
283
|
return {};
|
|
120
284
|
},
|
|
121
285
|
{ scope: "operator.admin" },
|
|
122
286
|
);
|
|
123
287
|
api.on("before_tool_call", (event) =>
|
|
124
|
-
/(^|__)browser$/.test(event.toolName) && control.
|
|
288
|
+
/(^|__)browser$/.test(event.toolName) && !control.allows(event.params)
|
|
125
289
|
? {
|
|
126
290
|
block: true,
|
|
127
291
|
blockReason:
|
|
128
|
-
"The user has taken control of the browser.
|
|
292
|
+
"The user has taken control of the browser. Reading pages still works; wait until they hand it back before acting on one.",
|
|
129
293
|
}
|
|
130
294
|
: undefined,
|
|
131
295
|
);
|
package/otter.js
CHANGED
|
@@ -5,25 +5,48 @@ export const inboxKey = (agentId) => `agent:${agentId}:otter:inbox`;
|
|
|
5
5
|
|
|
6
6
|
export const OTTER_TARGET = "inbox";
|
|
7
7
|
|
|
8
|
+
/** Who sends what you type in Otter: the gateway's owner once `commands.ownerAllowFrom` lists `otter:owner`. */
|
|
9
|
+
export const OWNER = "owner";
|
|
10
|
+
export const OWNER_ENTRY = `otter:${OWNER}`;
|
|
11
|
+
|
|
12
|
+
/** A chat in Otter is a target of its own: what's sent to it is kept in its session. */
|
|
13
|
+
export const chatTarget = (sessionKey) => `chat:${sessionKey}`;
|
|
14
|
+
|
|
8
15
|
export const agentOf = (sessionKey) =>
|
|
9
16
|
typeof sessionKey === "string" && sessionKey.startsWith("agent:")
|
|
10
17
|
? sessionKey.split(":")[1]
|
|
11
18
|
: undefined;
|
|
12
19
|
|
|
20
|
+
/** The chat a target names, if it names one. */
|
|
21
|
+
export function chatOf(to) {
|
|
22
|
+
const target = typeof to === "string" ? to.trim().replace(/^otter:/, "") : "";
|
|
23
|
+
if (!target.startsWith("chat:")) return undefined;
|
|
24
|
+
const sessionKey = target.slice("chat:".length);
|
|
25
|
+
return agentOf(sessionKey) ? sessionKey : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The session a delivery to this target is kept in. */
|
|
29
|
+
export const sessionOf = (to, agentId) => chatOf(to) ?? inboxKey(agentId);
|
|
30
|
+
|
|
31
|
+
export const isOwnerConfigured = (cfg) =>
|
|
32
|
+
Array.isArray(cfg?.commands?.ownerAllowFrom) &&
|
|
33
|
+
cfg.commands.ownerAllowFrom.some((entry) => String(entry).trim() === OWNER_ENTRY);
|
|
34
|
+
|
|
13
35
|
/**
|
|
14
36
|
* What the relay is told of a delivery: never its text, only where to read it.
|
|
15
37
|
* Otter's apps read it from the gateway themselves.
|
|
16
38
|
*/
|
|
17
|
-
export function pointerOf({ sessionKey, payload, at = Date.now() }) {
|
|
39
|
+
export function pointerOf({ sessionKey, to, payload, at = Date.now() }) {
|
|
18
40
|
const data = payload?.channelData ?? {};
|
|
19
41
|
const approval = data.execApproval;
|
|
20
42
|
const question = data.askUser;
|
|
21
43
|
const session = approval?.sessionKey ?? sessionKey;
|
|
22
|
-
const
|
|
44
|
+
const chat = chatOf(to);
|
|
45
|
+
const agentId = approval?.agentId ?? agentOf(chat ?? session) ?? "main";
|
|
23
46
|
return {
|
|
24
47
|
kind: approval ? "approval" : question ? "question" : "message",
|
|
25
48
|
agentId,
|
|
26
|
-
sessionKey: approval || question ? (session ?? null) : inboxKey(agentId),
|
|
49
|
+
sessionKey: approval || question ? (session ?? null) : (chat ?? inboxKey(agentId)),
|
|
27
50
|
...(approval && {
|
|
28
51
|
approvalId: String(approval.approvalId),
|
|
29
52
|
approvalKind: approval.approvalKind === "plugin" ? "plugin" : "exec",
|
|
@@ -56,3 +79,19 @@ export async function tell(account, pointer, { fetch = globalThis.fetch, signal
|
|
|
56
79
|
if (!response.ok) throw new Error(`Otter's relay answered ${response.status}`);
|
|
57
80
|
return true;
|
|
58
81
|
}
|
|
82
|
+
|
|
83
|
+
/** A message sent from Otter, checked: where it goes and what it carries. */
|
|
84
|
+
export function messageOf(params) {
|
|
85
|
+
const { sessionKey, message, idempotencyKey, attachments } = params ?? {};
|
|
86
|
+
if (typeof sessionKey !== "string" || !agentOf(sessionKey))
|
|
87
|
+
throw new Error("sessionKey must be an agent's session");
|
|
88
|
+
if (typeof idempotencyKey !== "string" || !idempotencyKey.trim())
|
|
89
|
+
throw new Error("idempotencyKey is required");
|
|
90
|
+
const files = Array.isArray(attachments) ? attachments : [];
|
|
91
|
+
for (const file of files)
|
|
92
|
+
if (typeof file?.content !== "string" || typeof file?.mimeType !== "string")
|
|
93
|
+
throw new Error("each attachment needs its content (base64) and mimeType");
|
|
94
|
+
const text = typeof message === "string" ? message : "";
|
|
95
|
+
if (!text.trim() && files.length === 0) throw new Error("message or attachments are required");
|
|
96
|
+
return { sessionKey, agentId: agentOf(sessionKey), text, messageId: idempotencyKey, files };
|
|
97
|
+
}
|
package/package.json
CHANGED