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:
2026-10-25 14:06:59 +01:00
co-authored by Claude Opus 5.5
parent a596722e59
commit bd9744043e
69 changed files with 3777 additions and 18 deletions
+15 -4
View File
@@ -10,6 +10,7 @@ import createKeyLock from "@/src/utils/create-key-lock";
import withTimeout from "@/src/utils/with-timeout";
import HarnessError from "@/src/harness/harness-error";
import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job";
import recordBotHookFailure from "@/src/functions/backend/db/bot-hooks/record-bot-hook-failure";
import recordCronJobFailure from "@/src/functions/backend/db/cron-jobs/record-cron-job-failure";
import scheduledRunRetryAt from "@/src/utils/scheduled-run-retry-at";
import {
@@ -62,7 +63,8 @@ type Params = {
* the user. If the job is cancelled or lost mid-way, its result is
* dropped. A failed scheduled run (a routine) is retried with longer gaps
* (`scheduledRunRetryAt`); once out of tries the user is told, and the
* schedule keeps why (`recordCronJobFailure`).
* schedule keeps why (`recordCronJobFailure`). A hook call's run is
* retried the same way and its hook keeps why (`recordBotHookFailure`).
*/
export default async function processBotJob({
job,
@@ -75,6 +77,7 @@ export default async function processBotJob({
const bot_id = job.bot_id;
const params = parseJobJson({ text: job.params }) || {};
const schedule_id = Number(params.schedule_id) || null;
const hook_id = Number(params.hook_id) || null;
/**
* Its face changes only for the user it works for.
@@ -282,7 +285,9 @@ export default async function processBotJob({
fatal: error instanceof BotJobFatalError,
failure_message: schedule_id
? `My scheduled run "${job.title || "Scheduled task"}" failed after ${tries} ${tries == 1 ? "try" : "tries"}: ${message}`
: undefined,
: hook_id
? `My run for the hook "${job.title || "Hook call"}" failed after ${tries} ${tries == 1 ? "try" : "tries"}: ${message}`
: undefined,
}).catch(async (fail_error) => {
await logError({
title: `Bot job #${job_id} couldn't be failed`,
@@ -317,6 +322,7 @@ export default async function processBotJob({
session_id: job.session_id,
workspace_id: job.workspace_id,
schedule_id,
hook_id,
fatal: error instanceof BotJobFatalError,
harness_code: error instanceof HarnessError ? error.code : undefined,
},
@@ -333,10 +339,15 @@ export default async function processBotJob({
);
}
if (hook_id && failed?.status == "failed") {
await recordBotHookFailure({ hook_id, job_id, error: message }).catch(() => {});
}
/**
* A routine's retry waits long enough for an outage to pass.
* A routine's or hook call's retry waits long enough for an outage
* to pass.
*/
if (schedule_id && failed?.status == "pending") {
if ((schedule_id || hook_id) && failed?.status == "pending") {
await deferBotJob({ job_id, worker, until: scheduledRunRetryAt({ attempts: failed.attempts }) });
}