Tie each hook to a session, picked in the hook form

A hook's calls used to run in whatever session was active at the time.
Now `bot_hooks.session_id` pins them to one session, open or closed
(null still follows the active one). The hook form picks where it runs
(the bot's own chat or one of its workspaces) and a session there, or a
new one of its own started beside the active one so the chat on screen
stays. A bot's proposed hook gets the session it was proposed in, and a
missing session is replaced on the next call. Changing place or session
is refused while a run is open; the computer stays only if reachable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 17:08:12 +01:00
co-authored by Claude Opus 5.5
parent 69988e08fc
commit fb4c89fca1
26 changed files with 630 additions and 19 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`),
### Hooks (`bot_hooks`)
A secret URL, `POST /api/hooks/<token>` (`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 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/<token>` (`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.
**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.
+5
View File
@@ -1186,6 +1186,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
foreignKeyName: "bot_hooks_workspace_fk",
},
},
{
fieldName: "session_id",
fieldDescription: `The session (of this user, bot and place) its runs and results land in, open or closed; null follows the conversation's active session. A bot's proposed hook gets the session it was proposed in. Not a foreign key: a missing one is replaced by a new session on the next call`,
dataType: "INTEGER",
},
{
fieldName: "title",
dataType: "TEXT",
+4
View File
@@ -742,6 +742,10 @@ export type BUN_SQLITE_COBBLE_BOT_HOOKS = {
* The workspace it runs in (context, computers, models, and the conversation its results go to); null for the bot's own chat
*/
workspace_id?: number | "";
/**
* The session (of this user, bot and place) its runs and results land in, open or closed; null follows the conversation's active session. A bot's proposed hook gets the session it was proposed in. Not a foreign key: a missing one is replaced by a new session on the next call
*/
session_id?: number | "";
title?: string;
/**
* The brief each run gets (markdown); the call's payload is added below it as data
+40 -2
View File
@@ -4,6 +4,7 @@ import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import CodeEditor from "@/src/components/general/code-editor";
import HookPlaceFields from "@/src/components/general/hook-place-fields";
import {
BotHookSignatureFormats,
BotHookSources,
@@ -15,6 +16,7 @@ import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import type {
BotHookWithNames,
BotHookWithToken,
HookSessionChoice,
NotifyWatchSync,
} from "@/src/types";
@@ -24,6 +26,10 @@ type Props = {
*/
hook?: BotHookWithNames;
bot_id: number;
/**
* Where a new hook runs at first (the conversation it's made from);
* the form can pick another place, and another session there.
*/
workspace_id?: number | null;
/**
* The bot's computers there, for a new browser-notification hook. Left
@@ -45,7 +51,8 @@ type Props = {
};
/**
* A hook's name and brief, what calls it (its URL, or a computer's browser
* A hook's name and brief, where it runs (the bot's own chat or one of
* its workspaces, and which session there), what calls it (its URL, or a computer's browser
* notifications, chosen once when it's made), and then either whether its
* calls must be signed (the secret, the header it comes in and how it's
* written) or which notifications it takes. Makes a hook or edits one; a
@@ -78,6 +85,19 @@ export default function HookForm({
const [format, setFormat] = useState<BotHookSignatureFormat>(
(hook?.signature_format as BotHookSignatureFormat) || "sha256=hex",
);
const [place, setPlace] = useState<{
workspace_id: number | null;
session: HookSessionChoice;
}>({
workspace_id: hook
? Number(hook.workspace_id) || null
: workspace_id || null,
session: hook
? hook.session_id
? Number(hook.session_id)
: "current"
: "new",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
@@ -109,6 +129,8 @@ export default function HookForm({
hook_id: hook.id,
title,
instructions,
workspace_id: place.workspace_id,
session: place.session,
...signing,
...(watching ? { watch_filter } : {}),
},
@@ -117,7 +139,8 @@ export default function HookForm({
path: "hooks/create-hook",
body: {
bot_id,
workspace_id: workspace_id || null,
workspace_id: place.workspace_id,
session: place.session,
title,
instructions,
source,
@@ -168,6 +191,21 @@ export default function HookForm({
info="What the bot does with each call. What the caller sends is added below this as data, never as instructions."
onChange={(value) => setInstructions(value)}
/>
<HookPlaceFields
bot_id={bot_id}
workspace_id={place.workspace_id}
session={place.session}
saved_session={
hook?.session_id
? {
id: Number(hook.session_id),
title: hook.session_title,
}
: null
}
disabled={loading}
onChange={setPlace}
/>
{hook ? null : (
<Select
id="hook-source"
@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import Select from "@moduletrace/twui/form/Select";
import Span from "@moduletrace/twui/layout/Span";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { BotSessionListItem, HookPlace, HookSessionChoice } from "@/src/types";
type Props = {
bot_id: number;
workspace_id: number | null;
session: HookSessionChoice;
/**
* The hook's own session as saved, so it's offered even when it's
* older than the sessions listed.
*/
saved_session?: { id: number; title?: string | null } | null;
disabled?: boolean;
onChange: (params: { workspace_id: number | null; session: HookSessionChoice }) => void;
};
/**
* Where a hook's calls run: the bot's own chat or one of its workspaces
* (loaded when the form opens), then, there, which session they land in:
* the active one whichever it is, a new one of its own, or one of the
* sessions already there (loaded for the place picked). Picking another
* place starts on a new session.
*/
export default function HookPlaceFields({ bot_id, workspace_id, session, saved_session, disabled, onChange }: Props) {
const [places, setPlaces] = useState<HookPlace[]>();
const [sessions, setSessions] = useState<BotSessionListItem[]>();
const [error, setError] = useState<string>();
useEffect(() => {
postAdminApi<HookPlace>({ path: "hooks/hook-places", body: { bot_id } }).then((res) => {
if (!res.success) setError(res.msg || "Couldn't load where it can run.");
setPlaces((res.payload || []) as HookPlace[]);
});
}, [bot_id]);
useEffect(() => {
setSessions(undefined);
postAdminApi<BotSessionListItem>({ path: "bots/list-sessions", body: { bot_id, workspace_id } }).then((res) => {
setSessions((res.payload || []) as BotSessionListItem[]);
});
}, [bot_id, workspace_id]);
const listed = sessions || [];
const extra =
saved_session?.id && !listed.some((it) => it.id == saved_session.id) ? [{ ...saved_session, status: "closed" }] : [];
const session_options = [
{ value: "current", title: "The active session, whichever it is" },
{ value: "new", title: "A new session of its own" },
...[...extra, ...listed].map((it) => ({
value: String(it.id),
title: `${it.title || `Session ${it.id}`}${it.status == "active" ? " (active now)" : ""}`,
})),
];
return (
<>
{places?.length ? (
<Select
key={`place-${places.length}`}
id="hook-place"
title="Runs in"
label="Runs in"
showLabel
disabled={disabled}
options={places.map((it) => ({
value: String(it.workspace_id ?? "own"),
title: it.name,
default: it.workspace_id == workspace_id,
}))}
changeHandler={(value) => onChange({ workspace_id: value == "own" ? null : Number(value), session: "new" })}
/>
) : null}
{sessions ? (
<Select
key={`session-${workspace_id ?? "own"}-${listed.length}`}
id="hook-session"
title="Session"
label="Session"
showLabel
disabled={disabled}
info="Where each call's run and its result land. A session of its own keeps the chat on screen as it is; it shows under the conversation's sessions."
options={session_options.map((it) => ({ ...it, default: it.value == String(session) }))}
changeHandler={(value) =>
onChange({
workspace_id,
session: value == "current" || value == "new" ? value : Number(value),
})
}
/>
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">Loading the sessions...</Span>
)}
{error ? <Span className="text-[13px] leading-[18px] text-ink-secondary">{error}</Span> : null}
</>
);
}
@@ -1,7 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_HOOKS, BunSQLiteTables } from "@/db/types/db";
import type { BotHookSignatureFormat, BotHookSource } from "@/src/dict/bot-hook-dict";
import type { BotHookWithToken } from "@/src/types";
import type { BotHookWithToken, HookSessionChoice } from "@/src/types";
import AppError from "@/src/utils/app-error";
import { encrypt } from "@/src/utils/crypt";
import generateHookToken from "@/src/utils/generate-hook-token";
@@ -10,6 +10,8 @@ import assertHookComputers from "./assert-hook-computers";
import assertHookRoom from "./assert-hook-room";
import syncComputerNotifyWatch from "./sync-computer-notify-watch";
import grabUserBotHook from "./grab-user-bot-hook";
import noteHookSession from "./note-hook-session";
import pickHookSession from "./pick-hook-session";
type Params = {
user_id: number | string;
@@ -18,6 +20,16 @@ type Params = {
* The workspace it runs in; none for the bot's own chat.
*/
workspace_id?: number | string | null;
/**
* The session its runs land in (`pickHookSession`); left out, the
* conversation's active one, whichever that is at the time.
*/
session?: HookSessionChoice;
/**
* A chosen session that's gone starts a new one instead (a bot's
* proposal, pinned to the session it was made in).
*/
session_missing_starts_new?: boolean;
title: string;
instructions: string;
/**
@@ -45,7 +57,8 @@ type Params = {
/**
* Gives one of the user's bots a hook: a secret URL whose calls each queue
* the brief, with the call's payload, as a task in the bot's conversation
* there (`fireBotHook`). Returns the hook and its token, which is shown
* there (`fireBotHook`): in a session of its own, a chosen one, or the
* active one at the time. Returns the hook and its token, which is shown
* this once: only its hash is kept. A browser-notification hook needs a
* computer both the user and the bot reach; its token is kept encrypted
* for that computer's watcher, set up before this returns
@@ -55,6 +68,8 @@ export default async function createBotHook({
user_id,
bot_id,
workspace_id,
session = "current",
session_missing_starts_new,
title,
instructions,
computer_id,
@@ -86,6 +101,15 @@ export default async function createBotHook({
throw new AppError(`Say which header carries the signature.`);
}
const session_id = await pickHookSession({
user_id,
bot_id: bot_id_num,
workspace_id: conversation.workspace_id,
choice: session,
title,
missing_starts_new: session_missing_starts_new,
});
const { token, prefix, hash } = generateHookToken();
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOT_HOOKS, (typeof BunSQLiteTables)[number]>({
@@ -95,6 +119,7 @@ export default async function createBotHook({
user_id: Number(user_id),
bot_id: bot_id_num,
workspace_id: conversation.workspace_id || undefined,
session_id: session_id || undefined,
title,
instructions,
computer_id: run_computer_id ? Number(run_computer_id) : undefined,
@@ -119,6 +144,10 @@ export default async function createBotHook({
throw new Error(`Couldn't save the hook: ${res.msg}`);
}
if (session_id) {
await noteHookSession({ bot_id: bot_id_num, session_id, workspace_id: conversation.workspace_id, title });
}
if (watching) {
const watch = await syncComputerNotifyWatch({ computer_id: watch_computer_id! });
@@ -12,8 +12,8 @@ import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import insertBotHookEvent from "./insert-bot-hook-event";
import mergeHookCallSql from "./merge-hook-call-sql";
import resolveHookSession from "./resolve-hook-session";
import seenHookCallSql from "./seen-hook-call-sql";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
import grabUserConversation from "../bot-sessions/grab-user-conversation";
import enqueueBotJob from "../bots-queue/enqueue-bot-job";
import grabBotComputers from "../computers/grab-bot-computers";
@@ -49,7 +49,8 @@ export type FireBotHookResult = {
/**
* Takes one call to a hook: records it, then gives it to its bot as a task
* in the owner's conversation there. A call while the hook's last run is
* in the hook's session (`resolveHookSession`: its own, or the owner's
* active one there). A call while the hook's last run is
* still waiting (queued, never tried) joins that run's brief instead, up
* to `AppData.BotHookMaxEventsPerRun` calls, so a burst makes one run and
* none is lost; once a run has started, the next call queues a new one.
@@ -130,8 +131,8 @@ export default async function fireBotHook({
return { status: "merged", event_id, job_id: merged_into };
}
const session = await ensureBotSession({
user_id: hook.user_id!,
const session_id = await resolveHookSession({
hook,
bot_id: conversation.bot.id,
workspace_id: conversation.workspace_id,
});
@@ -144,7 +145,7 @@ export default async function fireBotHook({
await enqueueBotJob({
user_id: hook.user_id!,
bot_id: conversation.bot.id,
session_id: session.id!,
session_id,
title: hook.title || "Hook call",
instructions: `${hook.instructions || ""}\n\n${
watching
@@ -57,6 +57,7 @@ export default async function grabBotHooks({
h.instructions, h.computer_id, h.token_prefix, h.signature_header, h.signature_format,
h.enabled, h.run_count, h.last_called_at, h.last_job_id, h.last_error,
h.source, h.watch_computer_id, h.watch_filter, wc.name AS watch_computer_name,
h.session_id, s.title AS session_title,
(COALESCE(h.signing_secret, '') != '') AS has_signing_secret,
b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone, ${face.sql},
b.image_media_id AS bot_image_media_id, w.name AS workspace_name
@@ -64,6 +65,7 @@ export default async function grabBotHooks({
JOIN bots b ON b.id = h.bot_id
LEFT JOIN workspaces w ON w.id = h.workspace_id
LEFT JOIN computers wc ON wc.id = h.watch_computer_id
LEFT JOIN bot_sessions s ON s.id = h.session_id
WHERE ${where.join(" AND ")}
ORDER BY h.enabled DESC, h.last_called_at IS NULL, h.last_called_at DESC, h.id DESC`,
values: [...face.values, ...values],
@@ -0,0 +1,34 @@
import type { HookPlace } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabChatBot from "../bots/grab-chat-bot";
import grabBotById from "../bots/grab-bot-by-id";
import grabBotWorkspaces from "../workspaces/grab-bot-workspaces";
type Params = {
user_id: number | string;
bot_id: number | string;
};
/**
* Where the user can give a bot a hook, for the hook form: its own chat,
* when they chat with it there, then its workspaces they're in. Throws
* when they reach it nowhere.
*/
export default async function grabHookPlaces({ user_id, bot_id }: Params): Promise<HookPlace[]> {
const bot = await grabBotById({ bot_id });
const places: HookPlace[] = [];
if (bot?.id && (await grabChatBot({ bot_id: bot.id, user_id }))?.id) {
places.push({ workspace_id: null, name: `${bot.name || "The bot"}'s own chat` });
}
for (const workspace of bot?.id ? await grabBotWorkspaces({ user_id, bot }) : []) {
places.push({ workspace_id: Number(workspace.id), name: workspace.name || `Workspace ${workspace.id}` });
}
if (!places.length) {
throw new AppError(`Bot not found`);
}
return places;
}
@@ -0,0 +1,25 @@
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import insertBotMessage from "../bots/insert-bot-message";
type Params = {
bot_id: number | string;
session_id: number;
workspace_id?: number | null;
title: string;
};
/**
* Says in a hook's own session that its calls land there, so a new one
* shows in the conversation's sessions right away.
*/
export default async function noteHookSession({ bot_id, session_id, workspace_id, title }: Params) {
await pushBotNotice({
bot_id: Number(bot_id),
message: await insertBotMessage({
bot_id: Number(bot_id),
session_id,
workspace_id: workspace_id || null,
text: `Calls to the hook **${title}** run in this session from now on.`,
}),
});
}
@@ -0,0 +1,53 @@
import type { HookSessionChoice } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabUserBotSession from "../bot-sessions/grab-user-bot-session";
import startHookSession from "./start-hook-session";
type Params = {
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
choice: HookSessionChoice;
/**
* The hook's title, which names a new session.
*/
title: string;
/**
* A chosen session that's gone (or no longer in this conversation)
* starts a new one instead of being refused: a hook's call, or a bot's
* proposal confirmed after its session went.
*/
missing_starts_new?: boolean;
};
/**
* The session a hook saved with `choice` lands in: null to follow the
* conversation's active one, a new one of its own, or one of the user's
* sessions in that same conversation (refused otherwise).
*/
export default async function pickHookSession({
user_id,
bot_id,
workspace_id,
choice,
title,
missing_starts_new,
}: Params): Promise<number | null> {
if (choice == "current") return null;
if (choice != "new") {
const session = await grabUserBotSession({ session_id: choice, user_id });
const here =
session?.id &&
Number(session.bot_id) == Number(bot_id) &&
(Number(session.workspace_id) || null) == (Number(workspace_id) || null);
if (here) return Number(session.id);
if (!missing_starts_new) {
throw new AppError(`That session isn't in this conversation.`);
}
}
return await startHookSession({ user_id, bot_id, workspace_id, title });
}
@@ -0,0 +1,40 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_HOOKS } from "@/db/types/db";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
import pickHookSession from "./pick-hook-session";
type Params = {
hook: BUN_SQLITE_COBBLE_BOT_HOOKS;
bot_id: number;
workspace_id: number | null;
};
/**
* The session a hook's call runs in: its own (`session_id`), open or
* closed, or the conversation's active one when it follows that. When its
* session is gone, a new one of its own takes its place and the hook is
* linked to it.
*/
export default async function resolveHookSession({ hook, bot_id, workspace_id }: Params): Promise<number> {
if (!hook.session_id) {
return Number((await ensureBotSession({ user_id: hook.user_id!, bot_id, workspace_id })).id);
}
const session_id = (await pickHookSession({
user_id: hook.user_id!,
bot_id,
workspace_id,
choice: Number(hook.session_id),
title: hook.title || `Hook ${hook.id}`,
missing_starts_new: true,
}))!;
if (session_id != Number(hook.session_id)) {
await BunSQLite.sql({
sql: `UPDATE bot_hooks SET session_id = ?, updated_at = ? WHERE id = ?`,
values: [session_id, Date.now(), hook.id!],
});
}
return session_id;
}
@@ -0,0 +1,29 @@
import { AppData } from "@/src/data/app-data";
import createBotSession from "../bot-sessions/create-bot-session";
type Params = {
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
* The hook's title, which names the session.
*/
title: string;
};
/**
* Starts a hook's own session in the conversation, beside the active one
* (closed, so the chat on screen stays as it is). It shows in the
* conversation's sessions once something lands in it. Returns its id.
*/
export default async function startHookSession({ user_id, bot_id, workspace_id, title }: Params) {
const session = await createBotSession({
user_id,
bot_id,
workspace_id,
status: "closed",
title: `Hook: ${title}`.slice(0, AppData.BotSessionTitleMaxChars),
});
return Number(session.id);
}
@@ -2,7 +2,14 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotHookSignatureFormat } from "@/src/dict/bot-hook-dict";
import AppError from "@/src/utils/app-error";
import { encrypt } from "@/src/utils/crypt";
import hookMoveComputers from "@/src/utils/hook-move-computers";
import type { HookSessionChoice } from "@/src/types";
import grabUserConversation from "../bot-sessions/grab-user-conversation";
import isBotJobOpen from "../bots-queue/is-bot-job-open";
import grabBotComputers from "../computers/grab-bot-computers";
import grabUserBotHook from "./grab-user-bot-hook";
import noteHookSession from "./note-hook-session";
import pickHookSession from "./pick-hook-session";
import syncComputerNotifyWatch from "./sync-computer-notify-watch";
type Params = {
@@ -23,13 +30,27 @@ type Params = {
* A browser-notification hook's filter; empty takes them all.
*/
watch_filter?: string;
/**
* Where it runs from now, with the same bot: one of its
* workspaces, or null for its own chat.
*/
workspace_id?: number | null;
/**
* The session its runs land in there (`pickHookSession`). Left
* out when the place changes: the active one there.
*/
session?: HookSessionChoice;
};
};
/**
* Changes one of the user's hooks: its name and brief, pauses or resumes
* it, sets how its calls are signed or, for browser notifications, which
* ones it takes. Its token stays (rotate it with `rotateBotHookToken`).
* ones it takes, or moves it to another of its bot's conversations or
* sessions. Its token stays (rotate it with `rotateBotHookToken`). A move
* is refused while its last run is open (a call could still join it
* there); its computer stays only if the bot reaches it in the new place,
* and a notification hook can't go where its watched computer isn't.
* Pausing or resuming a notification hook updates its computer's watcher.
* Returns the hook.
*/
@@ -98,6 +119,50 @@ export default async function updateBotHook({ user_id, hook_id, data }: Params)
}
}
let noted_session_id: number | null = null;
const to_workspace_id = data.workspace_id === undefined ? Number(hook.workspace_id) || null : data.workspace_id || null;
const moving = to_workspace_id != (Number(hook.workspace_id) || null);
const choice: HookSessionChoice | undefined = data.session ?? (moving ? "current" : undefined);
const resessioning =
choice !== undefined &&
(choice == "new" || (choice == "current" ? Boolean(hook.session_id) : choice != Number(hook.session_id)));
if (moving || resessioning) {
const title = data.title || hook.title || `Hook ${hook.id}`;
if (hook.last_job_id && (await isBotJobOpen({ job_id: hook.last_job_id }))) {
throw new AppError(`"${title}" has a run going. Change where it runs once that's done.`);
}
if (moving) {
await grabUserConversation({ user_id, bot_id: hook.bot_id!, workspace_id: to_workspace_id });
const computers = await grabBotComputers({ bot_id: hook.bot_id!, workspace_id: to_workspace_id });
const { computer_id } = hookMoveComputers({
title,
watching,
computer_id: hook.computer_id,
watch_computer_id: hook.watch_computer_id,
reachable_ids: computers.map((computer) => computer.id!),
});
set("workspace_id", to_workspace_id);
set("computer_id", computer_id);
}
const session_id = await pickHookSession({
user_id,
bot_id: hook.bot_id!,
workspace_id: to_workspace_id,
choice: choice!,
title,
});
set("session_id", session_id);
noted_session_id = session_id;
}
if (!sets.length) return hook;
set("updated_at", Date.now());
@@ -111,6 +176,15 @@ export default async function updateBotHook({ user_id, hook_id, data }: Params)
throw new Error(`Couldn't update the hook: ${res.error}`);
}
if (noted_session_id) {
await noteHookSession({
bot_id: hook.bot_id!,
session_id: noted_session_id,
workspace_id: to_workspace_id,
title: data.title || hook.title || `Hook ${hook.id}`,
});
}
if (watching && data.enabled !== undefined && hook.watch_computer_id) {
await syncComputerNotifyWatch({ computer_id: hook.watch_computer_id });
}
@@ -15,17 +15,25 @@ type Params = {
* The session this one carries on from.
*/
previous_session_id?: number | null;
/**
* `closed` starts it in the background (a hook's own session), leaving
* the conversation's active one as it is. Active by default.
*/
status?: "active" | "closed";
title?: string | null;
};
/**
* Starts an active session in a user's conversation. Callers close the old one
* first (`startBotSession`).
* Starts a session in a user's conversation: an active one (callers close
* the old one first, `startBotSession`) or, `closed`, one beside it.
*/
export default async function createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id,
status = "active",
title,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const now = Date.now();
@@ -34,7 +42,8 @@ export default async function createBotSession({
bot_id: Number(bot_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
previous_session_id: previous_session_id || undefined,
status: "active",
status,
title: title || undefined,
unread: 0,
last_activity_at: now,
};
@@ -16,7 +16,8 @@ type Params = {
* The user confirmed a hook proposal from its card: claims it (once, and
* only while it's pending and not expired), checks it again against the
* hook and the bot's reach now, then saves it with the Hooks modal's own
* helpers, as that user. Returns the proposal and, for a new hook called
* helpers, as that user. A new hook runs in the session it was proposed
* in (a new one of its own if that's gone). Returns the proposal and, for a new hook called
* at a URL, its token: the card shows it this once, and it's never
* stored in the outcome, which the bot is told. Whatever happens is
* recorded on the proposal as its `outcome`.
@@ -46,6 +47,8 @@ export default async function confirmHookProposal({ user_id, proposal_id }: Para
user_id,
bot_id: proposal.bot_id,
workspace_id: proposal.workspace_id,
session: proposal.session_id ? Number(proposal.session_id) : "current",
session_missing_starts_new: true,
title: data.title!,
instructions: data.instructions!,
computer_id: data.computer_id,
@@ -61,7 +61,7 @@ export default function proposeHookTool({
name: "propose_hook",
description: `Propose a hook for the user to confirm: a secret URL an outside service (a webhook from GitHub, Stripe, a form, a script) calls to give you a task, or a watcher that hands you each browser notification on one of your computers (source browser_notifications, with watch_computer_id and an optional watch_filter, text the notification's site, title or body must contain). Every call queues your instructions (the brief) as a task for you, ${
workspace_id ? "in this workspace" : "in your own chat"
}, with what the caller sent attached as data, and the result is posted here. Runs carry the brief out without asking the user, so write it as a full brief for a future you with no memory of this chat, saying exactly what to do with each call. ${
}, with what the caller sent attached as data, in this session, where the result is posted (the hook stays tied to this session even after the user starts another). Runs carry the brief out without asking the user, so write it as a full brief for a future you with no memory of this chat, saying exactly what to do with each call. ${
computers.length ? `Your computers here: ${listed}.` : `You have no computer here, so it can't watch one.`
} To change one of your hooks (ids from list_hooks), give hook_id and only what changes: title, instructions, watch_filter, or enabled true to switch a paused one back on; what calls it and where it runs can't change. Nothing is saved until the user presses Confirm on the card. A new hook's URL is shown to the user on the card, once: you never see it, never ask for it, and never set a signing secret (the user adds one from the Hooks menu). Check list_hooks first: if a hook already does the job, propose a change to it instead.`,
schema: z.object({
@@ -65,8 +65,18 @@ export default function BotHooksModal({ bot, workspace, on_open }: Props) {
if (open) on_open?.();
}, [open]);
/**
* A hook saved to run elsewhere (another workspace, or the bot's own
* chat) leaves this conversation's list.
*/
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]));
setHooks((prev) => {
const rest = prev.filter((h) => h.id != hook.id);
if ((Number(hook.workspace_id) || null) != (Number(workspace?.id) || null)) return rest;
return prev.some((h) => h.id == hook.id) ? prev.map((h) => (h.id == hook.id ? hook : h)) : [hook, ...prev];
});
const back = () => {
set_view(undefined);
+36
View File
@@ -0,0 +1,36 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabHookPlaces from "@/src/functions/backend/db/bot-hooks/grab-hook-places";
import { HookPlacesRequestSchema } from "@/src/schemas/bot-hook-schema";
import type { HookPlace } 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";
/**
* Where the session user can give a bot a hook: its own chat and its workspaces they're in.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<HookPlace>> = 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 } = parseWithSchema({ schema: HookPlacesRequestSchema, data: body });
return { success: true, payload: await grabHookPlaces({ user_id: user.id, bot_id }) };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+1 -1
View File
@@ -10,7 +10,7 @@ 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.
* Changes one of the session user's hooks: its name, brief, whether it's on, how its calls are signed, or where and in which session it runs. Returns the hook.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotHookWithNames>> = async ({ req, body }) => {
try {
+17
View File
@@ -16,10 +16,16 @@ const SignatureHeaderField = z
const SourceField = z.enum(BotHookSources.map((s) => s.value) as [string, ...string[]]);
const WatchFilterField = z.string().trim().max(300, "Keep the filter under 300 characters");
const SigningSecretField = z.string().trim().min(8, "Use a secret of at least 8 characters").max(500);
/**
* `current`: the conversation's active session; `new`: one of its own;
* a number: one of the conversation's sessions.
*/
const SessionField = z.union([z.literal("current"), z.literal("new"), z.coerce.number().int().positive()]);
export const CreateBotHookRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot"),
workspace_id: z.coerce.number().int().positive().nullable().optional(),
session: SessionField.optional(),
title: TitleField,
instructions: InstructionsField,
signing_secret: SigningSecretField.nullable().optional(),
@@ -44,10 +50,21 @@ export const UpdateBotHookRequestSchema = z.object({
signature_header: SignatureHeaderField.optional(),
signature_format: SignatureFormatField.optional(),
watch_filter: WatchFilterField.optional(),
/**
* Where it runs from now (same bot); `null` for the bot's own chat.
*/
workspace_id: z.coerce.number().int().positive().nullable().optional(),
session: SessionField.optional(),
});
export type UpdateBotHookRequest = z.infer<typeof UpdateBotHookRequestSchema>;
export const HookPlacesRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot"),
});
export type HookPlacesRequest = z.infer<typeof HookPlacesRequestSchema>;
export const BotHookIdRequestSchema = z.object({
hook_id: HookIdField,
});
+21
View File
@@ -121,6 +121,11 @@ export type BotHookWithNames = Omit<BUN_SQLITE_COBBLE_BOT_HOOKS, "token_hash" |
* For browser notifications: the watched computer's name.
*/
watch_computer_name?: string | null;
/**
* The title of the session its runs land in, when it has one of its
* own (`session_id`).
*/
session_title?: string | null;
bot_name?: string | null;
bot_kind?: string | null;
bot_hue?: string | null;
@@ -162,6 +167,22 @@ export type BotHookWithToken = {
watch?: NotifyWatchSync;
};
/**
* Which session a hook's runs land in: `current` follows the
* conversation's active session, `new` starts one of its own beside it,
* a number is one of the conversation's sessions.
*/
export type HookSessionChoice = "current" | "new" | number;
/**
* Where a hook can run, for the picker: its bot's own chat
* (`workspace_id` null) or one of the bot's workspaces.
*/
export type HookPlace = {
workspace_id: number | null;
name: string;
};
/**
* A task with what a list shows beside it: the bot and workspace it belongs
* to, and its latest note.
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import AppError from "./app-error";
import hookMoveComputers from "./hook-move-computers";
describe("hookMoveComputers", () => {
test("keeps the run computer the bot reaches there", () => {
expect(hookMoveComputers({ title: "H", watching: false, computer_id: 3, reachable_ids: [1, 3] })).toEqual({
computer_id: 3,
computer_dropped: false,
});
});
test("drops one it doesn't reach", () => {
expect(hookMoveComputers({ title: "H", watching: false, computer_id: 3, reachable_ids: [1] })).toEqual({
computer_id: null,
computer_dropped: true,
});
});
test("nothing to drop without a run computer", () => {
expect(hookMoveComputers({ title: "H", watching: false, computer_id: null, reachable_ids: [] })).toEqual({
computer_id: null,
computer_dropped: false,
});
});
test("a notification hook needs its watched computer there", () => {
expect(() =>
hookMoveComputers({ title: "H", watching: true, computer_id: 2, watch_computer_id: 2, reachable_ids: [1] }),
).toThrow(AppError);
expect(
hookMoveComputers({ title: "H", watching: true, computer_id: 2, watch_computer_id: 2, reachable_ids: ["2"] }),
).toEqual({ computer_id: 2, computer_dropped: false });
});
});
+35
View File
@@ -0,0 +1,35 @@
import AppError from "./app-error";
type Params = {
/**
* The hook's title, for the refusal.
*/
title: string;
watching: boolean;
computer_id?: number | string | null;
watch_computer_id?: number | string | null;
/**
* The ids of the computers its bot reaches where the hook goes.
*/
reachable_ids: (number | string)[];
};
/**
* The computer a moved hook's runs use: kept only when its bot reaches it
* in the new place, else null (the bot's current one). A
* browser-notification hook can't move where its bot doesn't reach the
* computer it watches, since that computer's watcher is what calls it.
*/
export default function hookMoveComputers({ title, watching, computer_id, watch_computer_id, reachable_ids }: Params) {
const reaches = (id?: number | string | null) => Boolean(id) && reachable_ids.some((it) => String(it) == String(id));
if (watching && !reaches(watch_computer_id)) {
throw new AppError(
`"${title}" takes the browser notifications of computer #${watch_computer_id}, which the bot doesn't reach there.`,
);
}
const kept = reaches(computer_id) ? Number(computer_id) : null;
return { computer_id: kept, computer_dropped: Boolean(computer_id && !kept) };
}
+6
View File
@@ -39,4 +39,10 @@ describe("hookSubtitle", () => {
"1 run · called 5m · signed · This hook is paused.",
);
});
test("a hook with a session of its own names it", () => {
const hook = { bot_name: "Scout", session_id: 9, session_title: "Hook: Issues", run_count: 1 } as BotHookWithNames;
expect(hookSubtitle({ hook, now })).toBe('Scout · in "Hook: Issues" · 1 run · never called');
});
});
+3 -1
View File
@@ -13,7 +13,8 @@ type Params = {
};
/**
* The line under a hook's name: whose it is, where it runs, which
* The line under a hook's name: whose it is, where it runs (and in which
* session, when it has one of its own), which
* computer's notifications it gets (and its filter), how many runs
* it started and when it was last called, whether calls must be signed,
* then why its last call or run failed.
@@ -24,6 +25,7 @@ export default function hookSubtitle({ hook, now = Date.now(), show_bot = true,
return [
show_bot ? hook.bot_name || "A bot" : "",
show_workspace ? hook.workspace_name || "" : "",
hook.session_id ? `in "${hook.session_title || `session ${hook.session_id}`}"` : "",
hook.source == "browser_notifications"
? `notifications on ${hook.watch_computer_name || "a computer"}${hook.watch_filter ? ` with "${hook.watch_filter}"` : ""}`
: "",