Let outside services and computers' browser notifications give bots work
A bot hook (bot_hooks) is a secret URL, POST /api/hooks/<token>, whose calls queue the hook's brief plus the raw body as a task, per user and bot like a routine. Only the token's hash is kept (shown once, rotatable); an optional HMAC-SHA256 signing secret is stored encrypted. Calls are rate limited per address and per hook, recorded in bot_hook_events (pruned daily), deduplicated by delivery id, and a call while the last run still waits joins its brief instead of queueing another. The payload is fenced data and HookRunNote tells the bot never to take instructions from it; failed runs retry like routines. A hook can instead watch a computer's browser notifications: the app installs a small watcher that owns org.freedesktop.Notifications on the sandbox's session bus, so Chrome hands it every notification (it only looks for that service at start-up, so Chrome is restarted once, never mid-task), and it posts each one to its hooks, optionally filtered. Computers reach the app at COMPUTER_APP_URL or the app container's name. Users make hooks in the conversation's Hooks modal and manage them all on /admin/routines; bots never create or change hooks. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { NotifyWatchScript, NotifyWatchVersion } from "@/src/data/notify-watch-script";
|
||||
import { BotHookPath } from "@/src/dict/bot-hook-dict";
|
||||
import grabComputerAppUrl from "@/src/functions/backend/docker/grab-computer-app-url";
|
||||
import type { NotifyWatchSync } from "@/src/types";
|
||||
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
import notifyWatchEnsureCommand from "@/src/utils/notify-watch-ensure-command";
|
||||
import grabComputerMcpUrl from "../computers/grab-computer-mcp-url";
|
||||
import callComputerApi from "./call-computer-api";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives a computer's notification watcher the URLs of every hook that's on
|
||||
* and wants that computer's browser notifications, and keeps it running:
|
||||
* writes the watcher (`NotifyWatchScript`) and its URL list
|
||||
* (`~/.cobble/notify-hooks`, owner only), then starts it and restarts
|
||||
* Chrome when needed (`notifyWatchEnsureCommand`), but never while a task
|
||||
* works on the computer. With no such hooks the list is emptied and the
|
||||
* watcher, if there, forwards nothing. Never throws: run after every
|
||||
* change to those hooks and by the `sync-computers` cron, so a recreated
|
||||
* container gets its watcher back.
|
||||
*/
|
||||
export default async function syncComputerNotifyWatch({ computer_id }: Params): Promise<NotifyWatchSync> {
|
||||
try {
|
||||
const hooks =
|
||||
(
|
||||
await BunSQLite.sql<{ id: number; watch_token: string | null }>({
|
||||
sql: `SELECT id, watch_token FROM bot_hooks
|
||||
WHERE source = 'browser_notifications' AND watch_computer_id = ? AND enabled = 1
|
||||
ORDER BY id`,
|
||||
values: [computer_id],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
const address = await grabComputerMcpUrl({ computer_id });
|
||||
|
||||
if (!address) {
|
||||
return { ok: false, watching: false, hooks: hooks.length, error: `The computer has no container.` };
|
||||
}
|
||||
|
||||
if (!address.ready) {
|
||||
return { ok: false, watching: false, hooks: hooks.length, error: computerNotReadyMessage(address) };
|
||||
}
|
||||
|
||||
const base_url = address.base_url;
|
||||
const context = await callComputerApi({ base_url, path: `/v1/sandbox` });
|
||||
const dir = `${String(context.home_dir || "/home/gem").replace(/\/+$/, "")}/.cobble`;
|
||||
|
||||
if (!hooks.length) {
|
||||
await callComputerApi({
|
||||
base_url,
|
||||
path: `/v1/shell/exec`,
|
||||
body: { command: `[ -f ${dir}/notify-hooks ] && : > ${dir}/notify-hooks; true` },
|
||||
});
|
||||
|
||||
return { ok: true, watching: false, hooks: 0 };
|
||||
}
|
||||
|
||||
const app_url = await grabComputerAppUrl();
|
||||
|
||||
if (!app_url) {
|
||||
return {
|
||||
ok: false,
|
||||
watching: false,
|
||||
hooks: hooks.length,
|
||||
error: `Computers can't reach the app: set COMPUTER_APP_URL in Settings, under Environment.`,
|
||||
};
|
||||
}
|
||||
|
||||
const urls = await Promise.all(
|
||||
hooks
|
||||
.filter((hook) => hook.watch_token)
|
||||
.map(async (hook) => `${app_url}${BotHookPath}/${await decrypt(hook.watch_token!)}`),
|
||||
);
|
||||
|
||||
await callComputerApi({
|
||||
base_url,
|
||||
path: `/v1/shell/exec`,
|
||||
body: { command: `mkdir -p ${dir} && chmod 700 ${dir} && touch ${dir}/notify-hooks && chmod 600 ${dir}/notify-hooks` },
|
||||
});
|
||||
|
||||
await callComputerApi({
|
||||
base_url,
|
||||
path: `/v1/file/write`,
|
||||
body: { file: `${dir}/notify-watch.py`, content: NotifyWatchScript },
|
||||
});
|
||||
|
||||
await callComputerApi({
|
||||
base_url,
|
||||
path: `/v1/file/write`,
|
||||
body: { file: `${dir}/notify-hooks`, content: `${urls.join("\n")}\n` },
|
||||
});
|
||||
|
||||
const busy = Boolean(
|
||||
(
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `SELECT id FROM bots_queue WHERE computer_id = ? AND status = 'processing' LIMIT 1`,
|
||||
values: [computer_id],
|
||||
})
|
||||
).payload?.[0],
|
||||
);
|
||||
|
||||
const res = await callComputerApi({
|
||||
base_url,
|
||||
path: `/v1/shell/exec`,
|
||||
body: { command: notifyWatchEnsureCommand({ version: NotifyWatchVersion, restart_browser: !busy }) },
|
||||
timeout_ms: 30_000,
|
||||
});
|
||||
|
||||
const output = String(res?.data?.output ?? "").trim();
|
||||
const result = JSON.parse(output.split("\n").filter(Boolean).pop() || "{}");
|
||||
|
||||
return {
|
||||
ok: Boolean(result.ok),
|
||||
watching: Boolean(result.watching),
|
||||
browser: result.browser,
|
||||
hooks: urls.length,
|
||||
error: result.error ? String(result.error) : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
watching: false,
|
||||
hooks: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user