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,22 @@
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import useHookEvents from "@/src/hooks/use-hook-events";
|
||||
import HookEventRow from "./hook-event-row";
|
||||
|
||||
type Props = {
|
||||
hook_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook's latest calls, newest first, loaded when shown.
|
||||
*/
|
||||
export default function HookCalls({ hook_id }: Props) {
|
||||
const { events, loading, error } = useHookEvents({ hook_id });
|
||||
|
||||
return (
|
||||
<SettingsList is_empty={!events.length} empty={loading ? "Loading calls..." : error || "No calls yet."}>
|
||||
{events.map((event) => (
|
||||
<HookEventRow key={event.id} event={event} />
|
||||
))}
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS } from "@/db/types/db";
|
||||
import { BotHookEventStatuses } from "@/src/dict/bot-hook-dict";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
|
||||
type Props = {
|
||||
event: BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS;
|
||||
};
|
||||
|
||||
/**
|
||||
* One call to a hook: what became of it and when, where it came from, why
|
||||
* it was refused, and the start of what it sent.
|
||||
*/
|
||||
export default function HookEventRow({ event }: Props) {
|
||||
const status = BotHookEventStatuses.find((s) => s.value == event.status)?.title || event.status || "";
|
||||
const meta = [
|
||||
status,
|
||||
event.created_at ? relativeTime({ at: Number(event.created_at) }) : "",
|
||||
event.source_ip || "",
|
||||
event.job_id ? `job #${event.job_id}` : "",
|
||||
event.error || "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-1! items-stretch px-3 py-2">
|
||||
<Row className="w-full gap-2! flex-nowrap">
|
||||
<Span className="text-xs font-bold leading-4 text-ink truncate">{meta}</Span>
|
||||
</Row>
|
||||
{event.payload ? (
|
||||
<Span className="block w-full max-h-24 overflow-hidden whitespace-pre-wrap break-all rounded-default-xs bg-sunken px-2 py-1 font-mono text-[12px] leading-4 text-ink-secondary">
|
||||
{event.payload}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState } from "react";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import {
|
||||
BotHookSignatureFormats,
|
||||
BotHookSources,
|
||||
type BotHookSignatureFormat,
|
||||
type BotHookSource,
|
||||
} from "@/src/dict/bot-hook-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import type {
|
||||
BotHookWithNames,
|
||||
BotHookWithToken,
|
||||
NotifyWatchSync,
|
||||
} from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The hook to edit; left out to make one for `bot_id` there.
|
||||
*/
|
||||
hook?: BotHookWithNames;
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* The bot's computers there, for a new browser-notification hook. Left
|
||||
* out (or empty), only URL hooks can be made.
|
||||
*/
|
||||
computers?: { id?: number | ""; name?: string }[];
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* `token` only for a new URL hook: its URL, shown once. `watch` for a
|
||||
* new notification hook: how setting up its computer went. `msg` is
|
||||
* what the save said.
|
||||
*/
|
||||
onSaved: (params: {
|
||||
hook: BotHookWithNames;
|
||||
token?: string;
|
||||
watch?: NotifyWatchSync;
|
||||
msg?: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook's name and brief, what calls it (its URL, or a computer's browser
|
||||
* notifications, chosen once when it's made), and then either whether its
|
||||
* calls must be signed (the secret, the header it comes in and how it's
|
||||
* written) or which notifications it takes. Makes a hook or edits one; a
|
||||
* saved secret is never shown again, so leaving it blank keeps it.
|
||||
*/
|
||||
export default function HookForm({
|
||||
hook,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
computers = [],
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: Props) {
|
||||
const [source, setSource] = useState<BotHookSource>(
|
||||
(hook?.source as BotHookSource) || "url",
|
||||
);
|
||||
const [watch_computer_id, setWatchComputerId] = useState(
|
||||
hook?.watch_computer_id
|
||||
? Number(hook.watch_computer_id)
|
||||
: Number(computers[0]?.id) || 0,
|
||||
);
|
||||
const [watch_filter, setWatchFilter] = useState(hook?.watch_filter || "");
|
||||
const [title, setTitle] = useState(hook?.title || "");
|
||||
const [instructions, setInstructions] = useState(hook?.instructions || "");
|
||||
const [signed, setSigned] = useState(Boolean(hook?.has_signing_secret));
|
||||
const [secret, setSecret] = useState("");
|
||||
const [header, setHeader] = useState(
|
||||
hook?.signature_header || "X-Hub-Signature-256",
|
||||
);
|
||||
const [format, setFormat] = useState<BotHookSignatureFormat>(
|
||||
(hook?.signature_format as BotHookSignatureFormat) || "sha256=hex",
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const watching = source == "browser_notifications";
|
||||
|
||||
const signing = (() => {
|
||||
if (watching) return {};
|
||||
if (!signed)
|
||||
return hook?.has_signing_secret ? { signing_secret: null } : {};
|
||||
|
||||
return {
|
||||
...(secret.trim() ? { signing_secret: secret } : {}),
|
||||
signature_header: header,
|
||||
signature_format: format,
|
||||
};
|
||||
})();
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
if (!window.confirm(`Save ${title || "this hook"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = hook
|
||||
? await postAdminApi<BotHookWithNames>({
|
||||
path: "hooks/update-hook",
|
||||
body: {
|
||||
hook_id: hook.id,
|
||||
title,
|
||||
instructions,
|
||||
...signing,
|
||||
...(watching ? { watch_filter } : {}),
|
||||
},
|
||||
})
|
||||
: await postAdminApi<BotHookWithToken>({
|
||||
path: "hooks/create-hook",
|
||||
body: {
|
||||
bot_id,
|
||||
workspace_id: workspace_id || null,
|
||||
title,
|
||||
instructions,
|
||||
source,
|
||||
...signing,
|
||||
...(watching ? { watch_computer_id, watch_filter } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the hook.");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("hook" in res.singleRes) {
|
||||
onSaved({
|
||||
hook: res.singleRes.hook,
|
||||
token: res.singleRes.token,
|
||||
watch: res.singleRes.watch,
|
||||
msg: res.msg,
|
||||
});
|
||||
} else {
|
||||
onSaved({ hook: res.singleRes, msg: res.msg });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="w-full gap-4! items-stretch pt-4 overflow-hidden px-1"
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Input
|
||||
id="hook-title"
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
placeholder="New GitHub issues"
|
||||
defaultValue={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-40"
|
||||
title="Brief"
|
||||
content={instructions}
|
||||
info="What the bot does with each call. What the caller sends is added below this as data, never as instructions."
|
||||
onChange={(value) => setInstructions(value)}
|
||||
/>
|
||||
{hook ? null : (
|
||||
<Select
|
||||
id="hook-source"
|
||||
title="What calls it"
|
||||
label="What calls it"
|
||||
showLabel
|
||||
options={BotHookSources.filter(
|
||||
(s) => s.value == "url" || computers.length,
|
||||
).map((s) => ({
|
||||
value: s.value,
|
||||
title: s.title,
|
||||
default: s.value == source,
|
||||
}))}
|
||||
changeHandler={(value) =>
|
||||
setSource(String(value) as BotHookSource)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watching && !hook ? (
|
||||
<Select
|
||||
id="hook-watch-computer"
|
||||
title="Computer"
|
||||
label="Computer"
|
||||
showLabel
|
||||
options={computers.map((c) => ({
|
||||
value: String(c.id),
|
||||
title: c.name || `Computer ${c.id}`,
|
||||
default: Number(c.id) == watch_computer_id,
|
||||
}))}
|
||||
changeHandler={(value) => setWatchComputerId(Number(value))}
|
||||
/>
|
||||
) : null}
|
||||
{watching ? (
|
||||
<Input
|
||||
id="hook-watch-filter"
|
||||
title="Only notifications with"
|
||||
label="Only notifications with"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
placeholder="mail.google.com, invoice"
|
||||
defaultValue={watch_filter}
|
||||
info="Words in the site, title or text, comma-separated: any one is enough. Empty takes every notification. The site has to be allowed to send notifications in that computer's browser."
|
||||
onChange={(e) => setWatchFilter(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{watching ? null : (
|
||||
<>
|
||||
<Checkbox
|
||||
title="Check signatures"
|
||||
label="Only take calls signed with a secret (HMAC-SHA256 of the body, as GitHub and Stripe send)"
|
||||
checked={signed}
|
||||
changeHandler={setSigned}
|
||||
labelProps={{
|
||||
className: "w-full line-clamp-1 overflow-ellipsis",
|
||||
}}
|
||||
/>
|
||||
{signed ? (
|
||||
<>
|
||||
<Input
|
||||
id="hook-secret"
|
||||
title="Signing secret"
|
||||
label="Signing secret"
|
||||
showLabel
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
hook?.has_signing_secret
|
||||
? "Leave blank to keep the current secret"
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="hook-signature-header"
|
||||
title="Signature header"
|
||||
label="Signature header"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
defaultValue={header}
|
||||
onChange={(e) => setHeader(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
id="hook-signature-format"
|
||||
title="Signature format"
|
||||
label="Signature format"
|
||||
showLabel
|
||||
options={BotHookSignatureFormats.map((f) => ({
|
||||
value: f.value,
|
||||
title: f.title,
|
||||
default: f.value == format,
|
||||
}))}
|
||||
changeHandler={(value) =>
|
||||
setFormat(
|
||||
String(value) as BotHookSignatureFormat,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<AddFormActions
|
||||
submit_label={hook ? "Save" : "Make hook"}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import type { BotHookWithNames } from "@/src/types";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import hookSubtitle from "@/src/utils/hook-subtitle";
|
||||
import BotAvatar from "./bot-avatar";
|
||||
|
||||
type Props = {
|
||||
hook: BotHookWithNames;
|
||||
busy?: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onCalls: () => void;
|
||||
onRotate: () => void;
|
||||
onDelete: () => void;
|
||||
/**
|
||||
* In a conversation's own modal the bot's and workspace's names are
|
||||
* already the ones on screen.
|
||||
*/
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One hook: its bot's avatar, its name (Paused when it is), what calls
|
||||
* it, how often it ran and when it was last called, then Pause or Resume,
|
||||
* Edit, Calls, New URL (not for notification hooks: their URL is never
|
||||
* shown) and Delete.
|
||||
*/
|
||||
export default function HookRow({ hook, busy, onToggle, onEdit, onCalls, onRotate, onDelete, compact }: Props) {
|
||||
const title = hook.title || `Hook ${hook.id}`;
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={hook.bot_image_media_id}
|
||||
{...botStoneProps({ color: hook.bot_color, stone: hook.bot_stone, hue: hook.bot_hue })}
|
||||
face={(hook.bot_face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
title={title}
|
||||
badge={hook.enabled ? undefined : "Paused"}
|
||||
subtitle={hookSubtitle({ hook, show_bot: !compact, show_workspace: !compact })}
|
||||
>
|
||||
<SettingsRowAction
|
||||
title={hook.enabled ? `Pause ${title}` : `Resume ${title}`}
|
||||
label={hook.enabled ? "Pause" : "Resume"}
|
||||
disabled={busy}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
<SettingsRowAction title={`Edit ${title}`} label="Edit" disabled={busy} onClick={onEdit} />
|
||||
<SettingsRowAction title={`Recent calls to ${title}`} label="Calls" disabled={busy} onClick={onCalls} />
|
||||
{hook.source == "browser_notifications" ? null : (
|
||||
<SettingsRowAction title={`Give ${title} a new URL`} label="New URL" disabled={busy} onClick={onRotate} />
|
||||
)}
|
||||
<SettingsRowAction title={`Delete ${title}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Copy } from "lucide-react";
|
||||
import { BotHookPath } from "@/src/dict/bot-hook-dict";
|
||||
import copyText from "@/src/utils/copy-text";
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
/**
|
||||
* Whether calls must be signed, so the example says so.
|
||||
*/
|
||||
signed?: boolean;
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook's URL, shown once, right after it was made or given a new one:
|
||||
* only a hash of its token is kept, so it can't be shown again. With a
|
||||
* copy button and an example call.
|
||||
*/
|
||||
export default function HookUrlNotice({ token, signed, onDone }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const url = `${window.location.origin}${BotHookPath}/${token}`;
|
||||
const example = `curl -X POST '${url}' -H 'Content-Type: application/json' -d '{"event":"test"}'`;
|
||||
|
||||
const copy = async () => {
|
||||
setCopied(await copyText({ text: url }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-3! items-stretch pt-4">
|
||||
<Span className="text-[13px] leading-[18px] text-ink">
|
||||
Copy this URL now: it is shown only once. Anyone who has it can start this hook, so keep it secret.
|
||||
</Span>
|
||||
<Row className="w-full gap-2! flex-nowrap items-end">
|
||||
<Input
|
||||
id="hook-url"
|
||||
title="Hook URL"
|
||||
label="URL"
|
||||
showLabel
|
||||
readOnly
|
||||
value={url}
|
||||
onFocus={(e) => e.target.select()}
|
||||
wrapperWrapperProps={{ className: "grow min-w-0" }}
|
||||
/>
|
||||
<Button title="Copy the URL" type="button" variant="outlined" color="gray" onClick={() => copy().catch(() => {})}>
|
||||
<Copy size={16} aria-hidden />
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</Row>
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||
{signed
|
||||
? `Calls must be signed with the secret (HMAC-SHA256 of the body) in the header you set.`
|
||||
: `Send any body (JSON or text, up to 64 KB) with a POST. For example:`}
|
||||
</Span>
|
||||
{signed ? null : (
|
||||
<Span className="block w-full rounded-default bg-sunken px-3 py-2 font-mono text-[12px] leading-4 text-ink break-all">
|
||||
{example}
|
||||
</Span>
|
||||
)}
|
||||
<Row className="w-full justify-end">
|
||||
<Button title="Done" type="button" onClick={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user