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
@@ -0,0 +1,53 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useEffect, useState } from "react";
import type { BotHookWithNames } from "@/src/types";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Load only once the modal opens, so the conversation's page props stay
* small.
*/
enabled?: boolean;
};
/**
* A conversation's hooks of the user: a bot's own in its chat or the ones
* that run in the workspace. Loaded on open, never part of the page props.
*/
export default function useBotHooks({ bot_id, workspace_id, enabled = true }: Params) {
const [hooks, setHooks] = useState<BotHookWithNames[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
const query = new URLSearchParams({ bot_id: String(bot_id) });
if (workspace_id) query.set("workspace_id", String(workspace_id));
const res = await fetchApi<{}, APIResponseObject<BotHookWithNames>>(
`/api/admin/hooks/list-hooks?${query.toString()}`,
{ method: "GET" },
).catch(() => null);
setLoading(false);
if (!res?.success) {
setError(res?.msg || `Couldn't load the hooks.`);
return;
}
setHooks(res.payload || []);
}, [bot_id, workspace_id]);
useEffect(() => {
if (enabled) load().catch(() => {});
}, [enabled, load]);
return { hooks, setHooks, loading, error, reload: load };
}
@@ -0,0 +1,255 @@
import { useContext, useEffect, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Button from "@moduletrace/twui/layout/Button";
import { Plus, Webhook } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotHookWithNames, BotHookWithToken, BotWithLastMessage } from "@/src/types";
import HookCalls from "@/src/components/general/hook-calls";
import HookForm from "@/src/components/general/hook-form";
import HookRow from "@/src/components/general/hook-row";
import HookUrlNotice from "@/src/components/general/hook-url-notice";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import { AppContext } from "@/src/pages/__root";
import useBotHooks from "../(hooks)/use-bot-hooks";
import HeaderMenuItem from "./header-menu-item";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* What the modal shows besides the list: a form, a URL shown once, or a
* hook's recent calls.
*/
type View =
| { kind: "create" }
| { kind: "edit"; hook: BotHookWithNames }
| { kind: "url"; hook: BotHookWithNames; token: string }
| { kind: "calls"; hook: BotHookWithNames };
type Props = {
bot: BotWithLastMessage;
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
on_open?: () => void;
};
/**
* The user's hooks in this conversation: secret URLs an outside service
* (a webhook, a script) calls to give the bot a task, whose result lands
* here. Open it from the header menu; the list loads then, so the page's
* props stay small. The user makes, pauses, edits, re-keys and deletes
* their own; bots never make hooks.
*/
export default function BotHooksModal({ bot, workspace, on_open }: Props) {
const { pageProps } = useContext(AppContext);
const [open, set_open] = useState(false);
const [view, set_view] = useState<View>();
const [busy, set_busy] = useState(false);
const [status, set_status] = useState<Status>();
const { hooks, setHooks, loading, error } = useBotHooks({
bot_id: Number(bot.id),
workspace_id: workspace?.id ? Number(workspace.id) : null,
enabled: open,
});
const name = bot.name || "This bot";
const place = workspace?.name || "this workspace";
const title = workspace?.id ? `${place} hooks` : `${name}'s hooks`;
useEffect(() => {
if (open) on_open?.();
}, [open]);
const replace = ({ hook }: { hook: BotHookWithNames }) =>
setHooks((prev) => (prev.some((h) => h.id == hook.id) ? prev.map((h) => (h.id == hook.id ? hook : h)) : [hook, ...prev]));
const back = () => {
set_view(undefined);
set_status(undefined);
};
const toggle = async ({ hook }: { hook: BotHookWithNames }) => {
if (busy) return;
set_busy(true);
set_status(undefined);
const res = await postAdminApi<BotHookWithNames>({
path: "hooks/update-hook",
body: { hook_id: hook.id, enabled: !hook.enabled },
});
set_busy(false);
if (!res.success || !res.singleRes) {
set_status({ error: true, msg: res.msg || "That didn't work." });
return;
}
replace({ hook: res.singleRes });
set_status({ success: true, msg: hook.enabled ? "Paused." : "Resumed." });
};
const rotate = async ({ hook }: { hook: BotHookWithNames }) => {
if (busy) return;
if (!window.confirm(`Give "${hook.title}" a new URL? The current one stops working at once.`)) return;
set_busy(true);
set_status(undefined);
const res = await postAdminApi<BotHookWithToken>({ path: "hooks/rotate-hook", body: { hook_id: hook.id } });
set_busy(false);
if (!res.success || !res.singleRes) {
set_status({ error: true, msg: res.msg || "That didn't work." });
return;
}
replace({ hook: res.singleRes.hook });
if (res.singleRes.token) {
set_view({ kind: "url", hook: res.singleRes.hook, token: res.singleRes.token });
} else {
set_status({ success: true, msg: res.msg || "Done." });
}
};
const remove = async ({ hook }: { hook: BotHookWithNames }) => {
if (busy) return;
if (!window.confirm(`Delete the hook "${hook.title}"? Its URL stops working; a run already started carries on.`)) {
return;
}
set_busy(true);
set_status(undefined);
const res = await postAdminApi({ path: "hooks/delete-hook", body: { hook_id: hook.id } });
set_busy(false);
if (!res.success) {
set_status({ error: true, msg: res.msg || "That didn't work." });
return;
}
setHooks((prev) => prev.filter((h) => h.id != hook.id));
set_status({ success: true, msg: "Deleted." });
};
const heading =
view?.kind == "create"
? "New hook"
: view?.kind == "calls"
? `Calls to ${view.hook.title || "the hook"}`
: view
? view.hook.title || "Hook"
: title;
const intro =
view?.kind == "create" || view?.kind == "edit"
? `Every call to the hook's URL gives ${name} this brief as a task, with what the caller sent, and the result lands here.`
: view?.kind == "url"
? `The hook's URL is its key.`
: view?.kind == "calls"
? `The latest calls and what became of each.`
: `Ways for the outside to give ${name} work${workspace?.id ? ` in ${place}` : ""}: a URL a service (a webhook, a script) calls, or the browser notifications on one of its computers. Each call is a task, and its result lands here.`;
return (
<Modal
target={<HeaderMenuItem icon={Webhook} title="Hooks" description={title} loading={loading} />}
targetWrapperProps={{ className: "w-full" }}
open={open}
setOpen={(next) => {
set_open(next);
if (!next) back();
}}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">{heading}</H2>
{view && view.kind != "url" ? (
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={back}
className="px-2! text-[12px]"
>
Back
</Button>
) : !view ? (
<Button
title="Make a hook"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => set_view({ kind: "create" })}
className="gap-1! px-2! text-[12px]"
>
<Plus size={14} aria-hidden />
New hook
</Button>
) : null}
</Row>
<Span className="text-[13px] leading-[18px] text-ink-secondary">{intro}</Span>
</Stack>
<Stack className="mt-4 max-h-[55vh] w-full gap-0! items-stretch overflow-y-auto">
{view?.kind == "create" || view?.kind == "edit" ? (
<HookForm
key={view.kind == "edit" ? view.hook.id : "new"}
hook={view.kind == "edit" ? view.hook : undefined}
bot_id={Number(bot.id)}
workspace_id={workspace?.id ? Number(workspace.id) : null}
computers={pageProps?.bot_computers || []}
onCancel={back}
onSaved={({ hook, token, watch, msg }) => {
replace({ hook });
if (token) {
set_view({ kind: "url", hook, token });
return;
}
back();
set_status({ success: !watch || watch.ok, error: Boolean(watch && !watch.ok), msg: msg || "Saved." });
}}
/>
) : view?.kind == "url" ? (
<HookUrlNotice token={view.token} signed={Boolean(view.hook.has_signing_secret)} onDone={back} />
) : view?.kind == "calls" ? (
<HookCalls hook_id={Number(view.hook.id)} />
) : (
<SettingsList is_empty={!hooks.length} empty={loading ? "Loading hooks..." : error || "No hooks here yet."}>
{hooks.map((hook) => (
<HookRow
key={hook.id}
hook={hook}
compact
busy={busy}
onToggle={() => toggle({ hook }).catch(() => {})}
onEdit={() => set_view({ kind: "edit", hook })}
onCalls={() => set_view({ kind: "calls", hook })}
onRotate={() => rotate({ hook }).catch(() => {})}
onDelete={() => remove({ hook }).catch(() => {})}
/>
))}
</SettingsList>
)}
</Stack>
<Stack className="mt-3 gap-1! items-stretch">
<SettingsStatus status={status} />
</Stack>
</Modal>
);
}
@@ -9,10 +9,12 @@ import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import BotComputersModal from "./bot-computers-modal";
import BotFilesModal from "./bot-files-modal";
import BotTasksModal from "./bot-tasks-modal";
import BotRoutinesModal from "./bot-routines-modal";
import BotHooksModal from "./bot-hooks-modal";
import BotModelsModal from "./bot-models-modal";
import BotAdminMenuItem from "./bot-admin-menu-item";
import BotWorkspaceInstructionsModal from "./bot-workspace-instructions-modal";
@@ -57,7 +59,7 @@ type Props = {
/**
* The conversation header's menu: the sessions, a new session, copying
* the session, the bot's computer, its models, in a workspace its
* the session, its files, tasks, routines and hooks, the bot's computer, its models, in a workspace its
* instructions there and making it the admin there, its
* settings and, while it works, stopping it. A
* row that opens a modal closes the menu first, so the modal never sits
@@ -79,6 +81,10 @@ export default function ConversationHeaderMenu({
bot.kind == "coordinator"
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
/**
* Hooks start work, so a read-only user doesn't get them.
*/
const can_make_hooks = !isReadOnlyUser({ user_types: pageProps?.user_types });
/**
* A bot's instructions for one workspace, only there, for crew
* managers (the coordinator's too: only the super admin sees it).
@@ -150,6 +156,9 @@ export default function ConversationHeaderMenu({
workspace={workspace}
on_open={on_open}
/>
{can_make_hooks ? (
<BotHooksModal bot={bot} workspace={workspace} on_open={on_open} />
) : null}
<BotComputersModal
menu
bot={bot}
@@ -10,6 +10,7 @@ export const LogCategoryStyles: Record<
auth: { title: "Auth", color: "primary" },
websocket: { title: "WebSocket", color: "accent" },
cron: { title: "Cron", color: "success" },
hook: { title: "Hook", color: "secondary" },
system: { title: "System", color: "gray" },
error: { title: "Error", color: "error" },
};
@@ -0,0 +1,141 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { BotHookWithNames, BotHookWithToken } from "@/src/types";
import useSettingsList from "@/src/pages/admin/settings/(hooks)/use-settings-list";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import HookCalls from "@/src/components/general/hook-calls";
import HookForm from "@/src/components/general/hook-form";
import HookRow from "@/src/components/general/hook-row";
import HookUrlNotice from "@/src/components/general/hook-url-notice";
/**
* What the section's modal shows: a hook's form, its URL shown once, or
* its recent calls.
*/
type View =
| { kind: "edit"; hook: BotHookWithNames }
| { kind: "url"; hook: BotHookWithNames; token: string }
| { kind: "calls"; hook: BotHookWithNames };
/**
* Every hook the user gave their bots: pause or resume one, edit it, see
* its calls, give it a new URL, delete it. Hooks are made in a
* conversation's Hooks modal, where their results land.
*/
export default function HooksSection() {
const { pageProps } = useContext(AppContext);
const { items, setItems, busy, setBusy, status, setStatus, remove } = useSettingsList({
items: pageProps?.bot_hooks || [],
});
const [view, setView] = useState<View>();
const replace = ({ hook }: { hook: BotHookWithNames }) =>
setItems((prev) => prev.map((item) => (item.id == hook.id ? hook : item)));
const toggle = async ({ hook }: { hook: BotHookWithNames }) => {
setStatus(undefined);
const res = await postAdminApi<BotHookWithNames>({
path: "hooks/update-hook",
body: { hook_id: hook.id, enabled: !hook.enabled },
});
if (!res.success || !res.singleRes) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
replace({ hook: res.singleRes });
setStatus({ success: true, msg: res.msg });
};
const rotate = async ({ hook }: { hook: BotHookWithNames }) => {
if (busy) return;
if (!window.confirm(`Give "${hook.title}" a new URL? The current one stops working at once.`)) return;
setBusy(true);
setStatus(undefined);
const res = await postAdminApi<BotHookWithToken>({ path: "hooks/rotate-hook", body: { hook_id: hook.id } });
setBusy(false);
if (!res.success || !res.singleRes) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
replace({ hook: res.singleRes.hook });
if (res.singleRes.token) {
setView({ kind: "url", hook: res.singleRes.hook, token: res.singleRes.token });
} else {
setStatus({ success: true, msg: res.msg || "Done." });
}
};
const title =
view?.kind == "url"
? `${view.hook.title || "Hook"}: new URL`
: view?.kind == "calls"
? `Calls to ${view.hook.title || "the hook"}`
: view?.hook.title || "Hook";
return (
<SettingsGroup
title="Your hooks"
description="Ways for the outside to give a bot work: a secret URL a service (a webhook, a script) calls, or a computer's browser notifications. Make one from a conversation's menu, under Hooks. Each call is a task, and its result lands in that conversation."
>
<SettingsList is_empty={!items.length} empty="No hooks yet.">
{items.map((hook) => (
<HookRow
key={hook.id}
hook={hook}
busy={busy}
onToggle={() => toggle({ hook }).catch(() => {})}
onEdit={() => setView({ kind: "edit", hook })}
onCalls={() => setView({ kind: "calls", hook })}
onRotate={() => rotate({ hook }).catch(() => {})}
onDelete={() =>
remove({
id: hook.id,
path: "hooks/delete-hook",
body: { hook_id: hook.id },
confirm: `Delete the hook "${hook.title}"? Its URL stops working; a run already started carries on.`,
})
}
/>
))}
</SettingsList>
<SettingsStatus status={status} />
<AddModalShell open={Boolean(view)} setOpen={(open) => !open && setView(undefined)} title={title}>
{view?.kind == "edit" ? (
<HookForm
key={view.hook.id}
hook={view.hook}
bot_id={Number(view.hook.bot_id)}
workspace_id={view.hook.workspace_id ? Number(view.hook.workspace_id) : null}
onCancel={() => setView(undefined)}
onSaved={({ hook }) => {
replace({ hook });
setView(undefined);
setStatus({ success: true, msg: "Saved." });
}}
/>
) : view?.kind == "url" ? (
<HookUrlNotice
token={view.token}
signed={Boolean(view.hook.has_signing_secret)}
onDone={() => setView(undefined)}
/>
) : view?.kind == "calls" ? (
<HookCalls hook_id={Number(view.hook.id)} />
) : null}
</AddModalShell>
</SettingsGroup>
);
}
+8 -2
View File
@@ -1,10 +1,11 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabCronJobs from "@/src/functions/backend/db/cron-jobs/grab-cron-jobs";
import grabBotHooks from "@/src/functions/backend/db/bot-hooks/grab-bot-hooks";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* The routines page: every schedule the user's bots have.
* The routines page: every schedule and hook the user's bots have.
*/
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user } = await userAuth({ req });
@@ -13,5 +14,10 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
return { redirect: { destination: "/auth/login" } };
}
return { props: { cron_jobs: await grabCronJobs({ user_id: user.id }) } };
const [cron_jobs, bot_hooks] = await Promise.all([
grabCronJobs({ user_id: user.id }),
grabBotHooks({ user_id: user.id }),
]);
return { props: { cron_jobs, bot_hooks } };
};
+3 -1
View File
@@ -2,11 +2,13 @@ import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
import RoutinesSection from "./(sections)/routines-section";
import HooksSection from "./(sections)/hooks-section";
export default function AdminRoutinesPage() {
return (
<SettingsShell title="Routines" description="Work your bots do on a schedule, on their own.">
<SettingsShell title="Routines" description="Work your bots do on their own: on a schedule, or when an outside service calls a hook.">
<RoutinesSection />
<HooksSection />
</SettingsShell>
);
}
+57
View File
@@ -0,0 +1,57 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import createBotHook from "@/src/functions/backend/db/bot-hooks/create-bot-hook";
import type { BotHookSignatureFormat, BotHookSource } from "@/src/dict/bot-hook-dict";
import { CreateBotHookRequestSchema } from "@/src/schemas/bot-hook-schema";
import type { BotHookWithToken } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Gives a bot the session user can chat with a hook: a secret URL whose calls queue its brief as a task. Returns the hook and its token, shown this once.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotHookWithToken>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id, workspace_id, signature_format, source, ...rest } = parseWithSchema({
schema: CreateBotHookRequestSchema,
data: body,
});
const created = await createBotHook({
user_id: user.id,
bot_id,
workspace_id: workspace_id || null,
signature_format: signature_format as BotHookSignatureFormat | undefined,
source: source as BotHookSource | undefined,
...rest,
});
return {
success: true,
singleRes: created,
msg: created.token
? `Hook made. Copy its URL now: it is shown only once.`
: created.watch?.ok
? `Hook made. It gets this computer's browser notifications.`
: `Hook made, but the computer's watcher isn't running yet: ${created.watch?.error || "unknown error"}`,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+36
View File
@@ -0,0 +1,36 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deleteBotHook from "@/src/functions/backend/db/bot-hooks/delete-bot-hook";
import { BotHookIdRequestSchema } from "@/src/schemas/bot-hook-schema";
import type { BotHookWithNames } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Deletes one of the session user's hooks and its recorded calls. A run already queued carries on.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotHookWithNames>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { hook_id } = parseWithSchema({ schema: BotHookIdRequestSchema, data: body });
return { success: true, singleRes: await deleteBotHook({ user_id: user.id, hook_id }), msg: `Deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+33
View File
@@ -0,0 +1,33 @@
import type { BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS } from "@/db/types/db";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBotHookEvents from "@/src/functions/backend/db/bot-hooks/grab-bot-hook-events";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* The latest calls to one of the session user's hooks (`?hook_id=`), each
* payload cut to a preview.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS>> = async ({ req }) => {
try {
if (req.method != "GET") {
return { success: false, msg: `Method not allowed` };
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const hook_id = Number(new URL(req.url).searchParams.get("hook_id")) || undefined;
if (!hook_id) {
return { success: false, msg: `Pick a hook.` };
}
return { success: true, payload: await grabBotHookEvents({ user_id: user.id, hook_id }) };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+46
View File
@@ -0,0 +1,46 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
import grabBotHooks from "@/src/functions/backend/db/bot-hooks/grab-bot-hooks";
import type { BotHookWithNames } from "@/src/types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* One conversation's hooks of the session user: a bot's own in its chat
* (`?workspace_id=` left out) or the ones that run in that workspace. The
* modal loads them when it opens, so the page's props stay small.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotHookWithNames>> = async ({ req }) => {
try {
if (req.method != "GET") {
return { success: false, msg: `Method not allowed` };
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const url = new URL(req.url);
const bot_id = Number(url.searchParams.get("bot_id")) || undefined;
const workspace_id = Number(url.searchParams.get("workspace_id")) || undefined;
if (!bot_id) {
return { success: false, msg: `Pick a bot.` };
}
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
return {
success: true,
payload: await grabBotHooks({
user_id: user.id,
bot_id: conversation.bot.id,
workspace_id: conversation.workspace_id,
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+40
View File
@@ -0,0 +1,40 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import rotateBotHookToken from "@/src/functions/backend/db/bot-hooks/rotate-bot-hook-token";
import { BotHookIdRequestSchema } from "@/src/schemas/bot-hook-schema";
import type { BotHookWithToken } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Gives one of the session user's hooks a new URL; the old one stops working at once. Returns the hook and its new token, shown this once.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotHookWithToken>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { hook_id } = parseWithSchema({ schema: BotHookIdRequestSchema, data: body });
return {
success: true,
singleRes: await rotateBotHookToken({ user_id: user.id, hook_id }),
msg: `New URL made. The old one no longer works.`,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+47
View File
@@ -0,0 +1,47 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import updateBotHook from "@/src/functions/backend/db/bot-hooks/update-bot-hook";
import type { BotHookSignatureFormat } from "@/src/dict/bot-hook-dict";
import { UpdateBotHookRequestSchema } from "@/src/schemas/bot-hook-schema";
import type { BotHookWithNames } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Changes one of the session user's hooks: its name, brief, whether it's on, or how its calls are signed. Returns the hook.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotHookWithNames>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { hook_id, signature_format, ...data } = parseWithSchema({ schema: UpdateBotHookRequestSchema, data: body });
const hook = await updateBotHook({
user_id: user.id,
hook_id,
data: { ...data, signature_format: signature_format as BotHookSignatureFormat | undefined },
});
return {
success: true,
singleRes: hook,
msg: data.enabled === undefined ? `Saved.` : data.enabled ? `${hook.title} is back on.` : `${hook.title} is paused.`,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+106
View File
@@ -0,0 +1,106 @@
import type { BunextAPIRouteHandler, BunextServerRouteConfig } from "@moduletrace/bunext/types";
import { AppData } from "@/src/data/app-data";
import { BotHookIdempotencyHeaders, type BotHookSignatureFormat } from "@/src/dict/bot-hook-dict";
import findBotHookByToken from "@/src/functions/backend/db/bot-hooks/find-bot-hook-by-token";
import fireBotHook from "@/src/functions/backend/db/bot-hooks/fire-bot-hook";
import insertBotHookEvent from "@/src/functions/backend/db/bot-hooks/insert-bot-hook-event";
import { grabRequestIP } from "@/src/functions/backend/logger";
import { decrypt } from "@/src/utils/crypt";
import rateLimit from "@/src/utils/rate-limit";
import safeErrorMessage from "@/src/utils/safe-error-message";
import verifyHookSignature from "@/src/utils/verify-hook-signature";
/**
* Bodies past this are refused (413) before the handler runs.
*/
export const config: BunextServerRouteConfig = {
max_request_body_mb: AppData.BotHookMaxPayloadBytes / (1024 * 1024),
};
/**
* The public end of a bot's hook: `POST /api/hooks/<token>` queues the
* hook's brief, with the raw body as data, as a task for its bot
* (`fireBotHook`). There's no session: the token is the key, checked
* against its hash in constant time, and, when the hook has a signing
* secret, the HMAC-SHA256 of the body too. Calls are rate limited per
* address before any lookup and per hook after. Every unknown token gets
* the same 404. Answers 202 once the run is queued (or the call joined one
* still waiting), 200 for a delivery already taken or a notification the
* hook's filter leaves out. Computers' notification watchers call it too.
*/
export const handler: BunextAPIRouteHandler = async ({ req, query }) => {
try {
if (req.method != "POST") {
return reply({ status: 405, msg: `Use POST.` });
}
const ip = grabRequestIP(req) || "unknown";
const minute = 60 * 1000;
if (!rateLimit({ key: `bot-hook-ip:${ip}`, limit: AppData.BotHookIpRateLimit, window_ms: minute }).allowed) {
return reply({ status: 429, msg: `Too many calls. Try again in a minute.` });
}
const token = String(query?.token || "");
const hook = token ? await findBotHookByToken({ token }) : null;
if (!hook?.id) {
return reply({ status: 404, msg: `Not found.` });
}
if (!rateLimit({ key: `bot-hook:${hook.id}`, limit: AppData.BotHookRateLimit, window_ms: minute }).allowed) {
return reply({ status: 429, msg: `Too many calls to this hook. Try again in a minute.` });
}
const payload = await req.text();
const content_type = req.headers.get("content-type");
if (hook.signing_secret) {
const secret = await decrypt(hook.signing_secret).catch(() => "");
const signed = verifyHookSignature({
body: payload,
secret,
signature: hook.signature_header ? req.headers.get(hook.signature_header) : null,
format: (hook.signature_format || "sha256=hex") as BotHookSignatureFormat,
});
if (!signed) {
await insertBotHookEvent({
hook_id: hook.id,
payload: payload.slice(0, 2000),
content_type,
source_ip: ip,
status: "rejected",
error: `Bad or missing signature`,
});
return reply({ status: 401, msg: `Bad or missing signature.` });
}
}
const idempotency_key =
BotHookIdempotencyHeaders.map((name) => req.headers.get(name)).find(Boolean) || null;
const result = await fireBotHook({ hook, payload, content_type, source_ip: ip, idempotency_key });
if (result.status == "rejected") {
return reply({ status: 409, msg: result.error || `This hook can't run now.` });
}
return reply({
status: result.status == "duplicate" || result.status == "ignored" ? 200 : 202,
ok: true,
msg: result.status == "duplicate" ? `Already taken.` : result.status == "ignored" ? `Ignored.` : `Queued.`,
event_id: result.event_id,
});
} catch (error: unknown) {
return reply({ status: 500, msg: safeErrorMessage({ error }) });
}
};
function reply({ status, ok = false, msg, event_id }: { status: number; ok?: boolean; msg: string; event_id?: number }) {
return Response.json(
{ ok, msg, event_id },
{ status, headers: { "Cache-Control": "no-store", ...(status == 429 ? { "Retry-After": "60" } : {}) } },
);
}