Files
cobble/src/functions/backend/db/bot-hooks/create-bot-hook.ts
T
tbenandClaude Opus 5.5 fb4c89fca1 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]>
2026-10-25 17:08:12 +01:00

159 lines
5.8 KiB
TypeScript

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, 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";
import grabUserConversation from "../bot-sessions/grab-user-conversation";
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;
bot_id: number | string;
/**
* 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;
/**
* Must be one of the bot's computers there; none runs on its current
* one.
*/
computer_id?: number | string | null;
/**
* When given, every call must be signed with it (HMAC-SHA256 of the
* raw body) in `signature_header`.
*/
signing_secret?: string | null;
signature_header?: string | null;
signature_format?: BotHookSignatureFormat | null;
/**
* `browser_notifications`: called by the watcher on `watch_computer_id`
* for each of its browser notifications (matching `watch_filter`)
* instead of by an outside service. Its runs default to that computer.
*/
source?: BotHookSource;
watch_computer_id?: number | string | null;
watch_filter?: string | null;
};
/**
* 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`): 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
* (`syncComputerNotifyWatch`), and never shown.
*/
export default async function createBotHook({
user_id,
bot_id,
workspace_id,
session = "current",
session_missing_starts_new,
title,
instructions,
computer_id,
signing_secret,
signature_header,
signature_format,
source = "url",
watch_computer_id,
watch_filter,
}: Params): Promise<BotHookWithToken> {
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id });
const bot_id_num = conversation.bot.id;
await assertHookRoom({ user_id, bot_id: bot_id_num, bot_name: conversation.bot.name });
const watching = source == "browser_notifications";
const run_computer_id = await assertHookComputers({
user_id,
bot_id: bot_id_num,
workspace_id: conversation.workspace_id,
source,
computer_id,
watch_computer_id,
});
const secret = watching ? undefined : signing_secret?.trim();
if (secret && !signature_header?.trim()) {
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]>({
table: "bot_hooks",
data: [
{
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,
source,
watch_computer_id: watching ? Number(watch_computer_id) : undefined,
watch_filter: watching ? watch_filter?.trim() || undefined : undefined,
watch_token: watching ? await encrypt(token) : undefined,
token_prefix: prefix,
token_hash: hash,
signing_secret: secret ? await encrypt(secret) : undefined,
signature_header: secret ? signature_header?.trim() || undefined : undefined,
signature_format: secret ? signature_format || "sha256=hex" : undefined,
enabled: 1,
run_count: 0,
},
],
});
const hook_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !hook_id) {
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! });
return { hook: await grabUserBotHook({ user_id, hook_id }), watch };
}
return { hook: await grabUserBotHook({ user_id, hook_id }), token };
}