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,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>
|
||||
);
|
||||
}
|
||||
@@ -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 } };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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" } : {}) } },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user