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
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod";
import { BotHookSignatureFormats, BotHookSources } from "@/src/dict/bot-hook-dict";
const HookIdField = z.coerce.number().int().positive("Pick a hook");
const TitleField = z.string().trim().min(1, "Give the hook a name").max(80, "Keep the name under 80 characters");
const InstructionsField = z.string().trim().min(1, "Tell the bot what to do with each call").max(8000);
const SignatureFormatField = z.enum(BotHookSignatureFormats.map((f) => f.value) as [string, ...string[]]);
/**
* An HTTP header name (RFC 7230 token characters).
*/
const SignatureHeaderField = z
.string()
.trim()
.max(100)
.regex(/^[A-Za-z0-9!#$%&'*+.^_`|~-]*$/, "That isn't a header name");
const SourceField = z.enum(BotHookSources.map((s) => s.value) as [string, ...string[]]);
const WatchFilterField = z.string().trim().max(300, "Keep the filter under 300 characters");
const SigningSecretField = z.string().trim().min(8, "Use a secret of at least 8 characters").max(500);
export const CreateBotHookRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot"),
workspace_id: z.coerce.number().int().positive().nullable().optional(),
title: TitleField,
instructions: InstructionsField,
signing_secret: SigningSecretField.nullable().optional(),
signature_header: SignatureHeaderField.optional(),
signature_format: SignatureFormatField.optional(),
source: SourceField.optional(),
watch_computer_id: z.coerce.number().int().positive().nullable().optional(),
watch_filter: WatchFilterField.nullable().optional(),
});
export type CreateBotHookRequest = z.infer<typeof CreateBotHookRequestSchema>;
export const UpdateBotHookRequestSchema = z.object({
hook_id: HookIdField,
title: TitleField.optional(),
instructions: InstructionsField.optional(),
enabled: z.boolean().optional(),
/**
* `null` stops checking signatures.
*/
signing_secret: SigningSecretField.nullable().optional(),
signature_header: SignatureHeaderField.optional(),
signature_format: SignatureFormatField.optional(),
watch_filter: WatchFilterField.optional(),
});
export type UpdateBotHookRequest = z.infer<typeof UpdateBotHookRequestSchema>;
export const BotHookIdRequestSchema = z.object({
hook_id: HookIdField,
});
export type BotHookIdRequest = z.infer<typeof BotHookIdRequestSchema>;