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,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 });
}