diff --git a/AGENTS.md b/AGENTS.md index 1647a8b..8e0a00c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,7 @@ Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`), ### Hooks (`bot_hooks`) -A secret URL, `POST /api/hooks/` (`src/pages/api/hooks/[token].ts`, public, no session), that queues a hook's brief plus the raw body as a task, per user and bot like a routine (runs act as the owner, who must still reach the bot and not be read-only). Only the token's prefix and SHA-256 are stored (shown once; "New URL" rotates it); an optional HMAC-SHA256 signing secret is stored encrypted (`verifyHookSignature`). Order in the route: rate limit per IP, lookup (same 404 for every miss), rate limit per hook, signature, then `fireBotHook`. Calls are recorded in `bot_hook_events` (pruned daily) with idempotency on the sender's delivery header. A call while the last run is still pending and untried joins its brief (`mergeHookCallSql`) instead of queueing another. The payload is fenced data (`hookPayloadBlock`), and `HookRunNote` tells the bot never to take instructions from it. Failed runs retry like routines and keep why in `last_error`. Users make hooks in the conversation's Hooks modal and manage all of theirs on `/admin/routines`. A hook runs in one session (`bot_hooks.session_id`, open or closed; null follows the active session): the form picks the place (the bot's own chat or one of its workspaces) and a session there, or a new one of its own, made closed beside the active one (`startHookSession`) so the chat on screen stays; a bot's proposed hook gets the session it was proposed in; a missing session is replaced on the next call (`resolveHookSession`). Changing place or session is refused while a run is open; the computer is kept only if reachable there. **A bot never makes or changes a hook on its own:** `list_hooks`, `pause_hook` and `delete_hook` act directly (they only take away); a new hook, or a new name, brief or filter, or switching one back on, goes through `propose_hook` -> `hook_proposals` -> `[hook-proposal:ID]` card, described from the data and confirmed by that user (`checkHookProposal` runs again on confirm). What calls a hook, its computers and its signing are never proposed. A new hook's URL shows on the card only, never in the outcome the bot is told. Only chat replies propose; a hook call's own run gets no hook tools. +A secret URL, `POST /api/hooks/` (`src/pages/api/hooks/[token].ts`, public, no session), that queues a hook's brief plus the raw body as a task, per user and bot like a routine (runs act as the owner, who must still reach the bot and not be read-only). Only the token's prefix and SHA-256 are stored (shown once; "New URL" rotates it); an optional HMAC-SHA256 signing secret is stored encrypted (`verifyHookSignature`). Order in the route: rate limit per IP, lookup (same 404 for every miss), rate limit per hook, signature, then `fireBotHook`. Calls are recorded in `bot_hook_events` with idempotency on the sender's delivery header; when a run ends (completed, failed, lost, stopped, cancelled) every call it took in gets its `response` and turns `done`/`failed` (`recordHookCallResponse`, called from the job helpers, not the handler). Calls are searchable by payload/response (`searchHookCallsSql`; Calls view, bot tools `search_hook_calls`/`read_hook_call`) and pruned daily after the app setting `hook_events_keep_days` (default `AppData.BotHookEventsKeepDays`). The caller only ever gets `202 Queued`. A call while the last run is still pending and untried joins its brief (`mergeHookCallSql`) instead of queueing another. The payload is fenced data (`hookPayloadBlock`), and `HookRunNote` tells the bot never to take instructions from it. Failed runs retry like routines and keep why in `last_error`. Users make hooks in the conversation's Hooks modal and manage all of theirs on `/admin/routines`. A hook runs in one session (`bot_hooks.session_id`, open or closed; null follows the active session): the form picks the place (the bot's own chat or one of its workspaces) and a session there, or a new one of its own, made closed beside the active one (`startHookSession`) so the chat on screen stays; a bot's proposed hook gets the session it was proposed in; a missing session is replaced on the next call (`resolveHookSession`). Changing place or session is refused while a run is open; the computer is kept only if reachable there. **A bot never makes or changes a hook on its own:** `list_hooks`, `search_hook_calls`, `read_hook_call`, `pause_hook` and `delete_hook` act directly (they only read or take away); a new hook, or a new name, brief or filter, or switching one back on, goes through `propose_hook` -> `hook_proposals` -> `[hook-proposal:ID]` card, described from the data and confirmed by that user (`checkHookProposal` runs again on confirm). What calls a hook, its computers and its signing are never proposed. A new hook's URL shows on the card only, never in the outcome the bot is told. Only chat replies propose; a hook call's own run gets no hook tools. **Browser notifications:** a hook with `source = browser_notifications` is called by a watcher on `watch_computer_id` instead of an outside service. The watcher (`src/data/notify-watch-script.ts`, Python + GLib, run as `gem` from `~/.cobble/`) owns `org.freedesktop.Notifications` on the sandbox's session bus, so Chrome hands it every notification, push ones included, instead of its silent built-in message center, and it posts each as JSON (`origin`, `title`, `body`) to the URLs in `~/.cobble/notify-hooks`. Chrome only looks for that service when it starts, so `syncComputerNotifyWatch` restarts Chrome once after starting the watcher (its sandbox supervisor brings it back), never while a task works on that computer. The hook's token is kept encrypted (`watch_token`) to rewrite that list after a container is recreated; `sync-notify-watchers` (every 5 minutes) and every change to such a hook resync it. `watch_filter` keeps only matching notifications (others are recorded as `ignored`). Computers reach the app at `COMPUTER_APP_URL` (an app setting; needed for a dev server outside docker), else the app container's name on the docker network. Sites still need notification permission in that computer's browser (a `Browser.grantPermissions` over CDP lasts only for that CDP session). Anything on the computer can read the list and call those hooks, which is one more reason payloads are only data. diff --git a/db/schema.ts b/db/schema.ts index a9fb65b..a04f882 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1341,6 +1341,20 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { fieldName: "error", dataType: "TEXT", }, + { + fieldName: "response", + fieldDescription: `What its run answered (the result the bot posted), or why the run failed. Written when the run ends, on every call that run took in`, + dataType: "TEXT", + }, + { + fieldName: "response_message_id", + fieldDescription: `The chat message the run posted. Not a foreign key: messages can be deleted`, + dataType: "INTEGER", + }, + { + fieldName: "responded_at", + dataType: "INTEGER", + }, ], indexes: [ { @@ -1351,6 +1365,10 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { indexName: "idx_bot_hook_events_hook_id_idempotency_key", indexTableFields: ["hook_id", "idempotency_key"], }, + { + indexName: "idx_bot_hook_events_job_id", + indexTableFields: ["job_id"], + }, ], }, { diff --git a/db/types/db.ts b/db/types/db.ts index 6a9f74a..df18f32 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -832,8 +832,17 @@ export type BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS = { * The sender's delivery id, so a retried call runs once */ idempotency_key?: string; - status?: "queued" | "merged" | "rejected" | "duplicate" | "ignored" | ""; + status?: "queued" | "merged" | "done" | "failed" | "rejected" | "duplicate" | "ignored" | ""; error?: string; + /** + * What its run answered (the result the bot posted), or why the run failed. Written when the run ends, on every call that run took in + */ + response?: string; + /** + * The chat message the run posted. Not a foreign key: messages can be deleted + */ + response_message_id?: number | ""; + responded_at?: number | ""; } export type BUN_SQLITE_COBBLE_HOOK_PROPOSALS = { diff --git a/src/components/general/hook-call-text.tsx b/src/components/general/hook-call-text.tsx new file mode 100644 index 0000000..d8a2e51 --- /dev/null +++ b/src/components/general/hook-call-text.tsx @@ -0,0 +1,32 @@ +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import { twMerge } from "tailwind-merge"; + +type Props = { + label: string; + text: string; + /** + * A preview is kept short; the whole call shows everything. + */ + full?: boolean; +}; + +/** + * One side of a hook call, what it sent or what its run answered, in a + * sunken block. + */ +export default function HookCallText({ label, text, full }: Props) { + return ( + + {label} + + {text} + + + ); +} diff --git a/src/components/general/hook-calls-filters.tsx b/src/components/general/hook-calls-filters.tsx new file mode 100644 index 0000000..f41e9ea --- /dev/null +++ b/src/components/general/hook-calls-filters.tsx @@ -0,0 +1,50 @@ +import Input from "@moduletrace/twui/form/Input"; +import Select from "@moduletrace/twui/form/Select"; +import Row from "@moduletrace/twui/layout/Row"; +import { useState } from "react"; +import { BotHookEventStatuses, type BotHookEventStatus } from "@/src/dict/bot-hook-dict"; + +type Props = { + loading?: boolean; + onSearch: (query: string) => void; + onStatus: (status: BotHookEventStatus | "") => void; +}; + +/** + * Narrowing a hook's calls down: words in what a call sent or what its + * run answered (Enter, or clearing, runs it), and one status. + */ +export default function HookCallsFilters({ loading, onSearch, onStatus }: Props) { + const [value, set_value] = useState(""); + + return ( + + set_value(event.target.value)} + onKeyDown={(event) => { + if (event.key == "Enter") onSearch(event.currentTarget.value.trim()); + }} + clearInputProps={{ + onClick: () => { + set_value(""); + onSearch(""); + }, + }} + wrapperWrapperProps={{ className: "w-full grow" }} + /> + { + setKeepDays(event.target.value); + setDirty(true); + setStatus(undefined); + }} + suffix={`days`} + info={`Each call keeps its payload (up to ${AppData.BotHookMaxPayloadBytes / 1024} KB) and its run's response, so a long time on a busy hook grows the database. 0 uses the default (${overview?.default_days ?? ""}).`} + /> + + + ); +} diff --git a/src/pages/admin/settings/general/(sections)/hook-call-retention-section.tsx b/src/pages/admin/settings/general/(sections)/hook-call-retention-section.tsx new file mode 100644 index 0000000..efd6a0a --- /dev/null +++ b/src/pages/admin/settings/general/(sections)/hook-call-retention-section.tsx @@ -0,0 +1,25 @@ +import { useContext } from "react"; +import { AppContext } from "@/src/pages/__root"; +import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; +import SettingsGroup from "../../(partials)/settings-group"; +import HookCallRetentionForm from "../(partials)/hook-call-retention-form"; + +/** + * How long hook calls and their responses are kept. Only the super admin + * sees it, since it changes every user's hooks. + */ +export default function HookCallRetentionSection() { + const { pageProps } = useContext(AppContext); + + if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null; + + return ( + + + + ); +} diff --git a/src/pages/admin/settings/general/index.tsx b/src/pages/admin/settings/general/index.tsx index c69d2d1..0552c00 100644 --- a/src/pages/admin/settings/general/index.tsx +++ b/src/pages/admin/settings/general/index.tsx @@ -1,4 +1,5 @@ import AppearanceSection from "./(sections)/appearance-section"; +import HookCallRetentionSection from "./(sections)/hook-call-retention-section"; import InstallIconSection from "./(sections)/install-icon-section"; import InstallNameSection from "./(sections)/install-name-section"; import NotificationsSection from "./(sections)/notifications-section"; @@ -23,6 +24,7 @@ export default function AdminSettingsGeneralPage() { + diff --git a/src/pages/api/admin/hooks/hook-event.ts b/src/pages/api/admin/hooks/hook-event.ts new file mode 100644 index 0000000..d49db37 --- /dev/null +++ b/src/pages/api/admin/hooks/hook-event.ts @@ -0,0 +1,34 @@ +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabBotHookEvent from "@/src/functions/backend/db/bot-hooks/grab-bot-hook-event"; +import { HookCallIdRequestSchema } from "@/src/schemas/bot-hook-schema"; +import type { BotHookCall } from "@/src/types"; +import parseWithSchema from "@/src/utils/parse-with-schema"; +import safeErrorMessage from "@/src/utils/safe-error-message"; +import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types"; + +/** + * One call to one of the session user's hooks (`?event_id=`), whole: what + * it sent and what its run answered. + */ +export const handler: BunextAPIRouteHandler> = 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 { event_id } = parseWithSchema({ + schema: HookCallIdRequestSchema, + data: { event_id: new URL(req.url).searchParams.get("event_id") }, + }); + + return { success: true, singleRes: await grabBotHookEvent({ user_id: user.id, event_id }) }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +}; diff --git a/src/pages/api/admin/hooks/hook-events.ts b/src/pages/api/admin/hooks/hook-events.ts index 0ed3f8f..354d3c3 100644 --- a/src/pages/api/admin/hooks/hook-events.ts +++ b/src/pages/api/admin/hooks/hook-events.ts @@ -1,14 +1,19 @@ -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 type { BotHookEventStatus } from "@/src/dict/bot-hook-dict"; +import { SearchHookCallsRequestSchema } from "@/src/schemas/bot-hook-schema"; +import type { BotHookCall } from "@/src/types"; +import parseWithSchema from "@/src/utils/parse-with-schema"; 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. + * Calls to the session user's hooks, newest first: one hook's + * (`?hook_id=`) or all, found by words in what they sent or what their run + * answered (`?q=`), one status (`?status=`), older than a call + * (`?before_id=`). Payloads and responses are previews. */ -export const handler: BunextAPIRouteHandler> = async ({ req }) => { +export const handler: BunextAPIRouteHandler> = async ({ req }) => { try { if (req.method != "GET") { return { success: false, msg: `Method not allowed` }; @@ -20,13 +25,26 @@ export const handler: BunextAPIRouteHandler value != ""), + ); - if (!hook_id) { - return { success: false, msg: `Pick a hook.` }; - } + const { hook_id, q, status, before_id, limit } = parseWithSchema({ + schema: SearchHookCallsRequestSchema, + data: params, + }); - return { success: true, payload: await grabBotHookEvents({ user_id: user.id, hook_id }) }; + return { + success: true, + payload: await grabBotHookEvents({ + user_id: user.id, + hook_id, + query: q, + status: status as BotHookEventStatus | undefined, + before_id, + limit, + }), + }; } catch (error: unknown) { return { success: false, msg: safeErrorMessage({ error }) }; } diff --git a/src/pages/api/admin/settings/hook-call-retention.ts b/src/pages/api/admin/settings/hook-call-retention.ts new file mode 100644 index 0000000..19392b5 --- /dev/null +++ b/src/pages/api/admin/settings/hook-call-retention.ts @@ -0,0 +1,45 @@ +import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user"; +import grabHookCallRetentionOverview from "@/src/functions/backend/db/app-settings/grab-hook-events-keep-days"; +import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting"; +import logEvent, { grabRequestIP } from "@/src/functions/backend/logger"; +import { HookEventsKeepDaysSettingKey } from "@/src/dict/bot-hook-dict"; +import { SaveHookCallRetentionRequestSchema } from "@/src/schemas/bot-hook-schema"; +import type { HookCallRetentionOverview } from "@/src/types"; +import AppError from "@/src/utils/app-error"; +import parseWithSchema from "@/src/utils/parse-with-schema"; +import safeErrorMessage from "@/src/utils/safe-error-message"; +import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types"; + +/** + * Super admin only: how many days hook calls and their responses are kept + * before the daily prune deletes them. Zero puts the code's default back. + */ +export const handler: BunextAPIRouteHandler> = async ({ req, body }) => { + try { + if (req.method != "POST") throw new AppError(`Method not allowed`); + + const user = await grabSuperAdminUser({ req }); + + if (!user?.id) throw new AppError(`Only a super admin can change how long hook calls are kept.`); + + const { keep_days } = parseWithSchema({ schema: SaveHookCallRetentionRequestSchema, data: body }); + + await saveAppSetting({ + key: HookEventsKeepDaysSettingKey, + value: keep_days > 0 ? String(keep_days) : null, + }); + + await logEvent({ + title: "Hook call retention changed", + category: "system", + user_id: user.id, + plain_text: `User #${user.id} set hook calls to be kept for ${keep_days || "the default number of"} days.`, + metadata: { keep_days }, + ip_address: grabRequestIP(req), + }); + + return { success: true, singleRes: await grabHookCallRetentionOverview() }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +}; diff --git a/src/schemas/bot-hook-schema.ts b/src/schemas/bot-hook-schema.ts index 9cd83b8..4c2813a 100644 --- a/src/schemas/bot-hook-schema.ts +++ b/src/schemas/bot-hook-schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; -import { BotHookSignatureFormats, BotHookSources } from "@/src/dict/bot-hook-dict"; +import { AppData } from "@/src/data/app-data"; +import { BotHookEventStatuses, 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"); @@ -95,3 +96,30 @@ export const HookProposalIdRequestSchema = z.object({ }); export type HookProposalIdRequest = z.infer; + +const HookCallStatusField = z.enum(BotHookEventStatuses.map((s) => s.value) as [string, ...string[]]); + +/** + * A search of the user's hook calls (`api/admin/hooks/hook-events`, and + * the bots' `search_hook_calls`): one hook's or all, words found in what + * a call sent or what its run answered, one status, older than a call. + */ +export const SearchHookCallsRequestSchema = z.object({ + hook_id: z.coerce.number().int().positive().optional(), + q: z.string().trim().max(200, "Keep the search under 200 characters").optional(), + status: HookCallStatusField.optional(), + before_id: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), +}); + +export const HookCallIdRequestSchema = z.object({ + event_id: z.coerce.number().int().positive("Pick a call"), +}); + +/** + * How many days hook calls are kept (Settings > General). Zero puts the + * code's default back. + */ +export const SaveHookCallRetentionRequestSchema = z.object({ + keep_days: z.coerce.number().int().min(0).max(AppData.BotHookEventsKeepDaysMax), +}); diff --git a/src/types/index.ts b/src/types/index.ts index 629c1f2..7d2a612 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,7 @@ import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CRON_JOBS, BUN_SQLITE_COBBLE_BOT_HOOKS, + BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS, BUN_SQLITE_COBBLE_MEDIA, BUN_SQLITE_COBBLE_TASK_MEDIA, BUN_SQLITE_COBBLE_TASK_NOTES, @@ -136,6 +137,17 @@ export type BotHookWithNames = Omit General, + * `grab-hook-events-keep-days.ts`). + */ +export type HookCallRetentionOverview = { + keep_days: number; + default_days: number; + max: number; +}; + /** * The install's skill limits (Settings > General, `grab-skill-limits.ts`). */ @@ -1664,6 +1686,11 @@ export type PagePropsType = { * at once, with the code's defaults (the General settings page). */ skill_limits?: SkillLimitsOverview | null; + /** + * How many days hook calls are kept, with the code's default (the + * General settings page). + */ + hook_call_retention?: HookCallRetentionOverview | null; /** * The name the install goes by as an installed app (Settings > * General, and the root's head tags). Empty when never named. diff --git a/src/utils/describe-hook-call.test.ts b/src/utils/describe-hook-call.test.ts new file mode 100644 index 0000000..c7a56d7 --- /dev/null +++ b/src/utils/describe-hook-call.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import describeHookCall from "./describe-hook-call"; + +describe("describeHookCall", () => { + test("names the call and quotes what it sent as data", () => { + const text = describeHookCall({ + call: { + id: 7, + hook_id: 2, + hook_title: "Orders", + created_at: Date.UTC(2026, 9, 25, 12), + status: "done", + payload: '{"order":\n "A-100"}', + response: "Shipped\nA-100", + }, + }); + + expect(text).toBe( + [ + `Call #7 to hook #2 "Orders", 2026-10-25T12:00:00.000Z, done`, + ` sent (outside data): "{\\"order\\": \\"A-100\\"}"`, + ` your run answered: Shipped A-100`, + ].join("\n"), + ); + }); + + test("says why a call was refused or its run failed", () => { + expect(describeHookCall({ call: { id: 1, hook_id: 2, status: "rejected", error: "Bad signature" } })).toContain( + "rejected, error: Bad signature", + ); + expect(describeHookCall({ call: { id: 1, hook_id: 2, status: "failed", response: "Timed out" } })).toContain( + "run failed: Timed out", + ); + }); +}); diff --git a/src/utils/describe-hook-call.ts b/src/utils/describe-hook-call.ts new file mode 100644 index 0000000..7d4da39 --- /dev/null +++ b/src/utils/describe-hook-call.ts @@ -0,0 +1,31 @@ +import type { BotHookCall } from "@/src/types"; + +type Params = { + call: BotHookCall; +}; + +/** + * One hook call in a line, as `search_hook_calls` lists it for a bot: its + * id, hook, when (UTC), status, then the start of what it sent and what + * its run answered, on one line each. What it sent is marked as outside + * data. + */ +export default function describeHookCall({ call }: Params) { + const flat = (text?: string | null) => String(text || "").replace(/\s+/g, " ").trim(); + const when = call.created_at ? new Date(Number(call.created_at)).toISOString() : "unknown time"; + const head = [ + `Call #${call.id} to hook #${call.hook_id} "${call.hook_title || "Hook"}"`, + when, + call.status || "unknown", + call.error ? `error: ${flat(call.error)}` : "", + ] + .filter(Boolean) + .join(", "); + + const lines = [head]; + + if (call.payload) lines.push(` sent (outside data): ${JSON.stringify(flat(call.payload))}`); + if (call.response) lines.push(` ${call.status == "failed" ? "run failed" : "your run answered"}: ${flat(call.response)}`); + + return lines.join("\n"); +}