Let bots manage their hooks, with the user confirming what widens them

Bots get list_hooks, pause_hook and delete_hook, which act directly since
they only take away. A new hook, or a new name, brief or filter, or
switching a paused one back on, goes through propose_hook: it stores a
hook_proposals row and puts a [hook-proposal:ID] card in the reply, which
the user it was proposed to confirms or rejects. The card is read from
what Confirm saves, with the bot's reason labelled as its own; confirming
checks everything again and saves through the Hooks modal's helpers. A
new hook's URL is shown on the card once, to the user only, never in the
outcome the bot is told. What calls a hook, its computers and its signing
are never proposed. Only chat replies propose, and a hook call's own run
gets no hook tools, so a caller's payload can't reach them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 15:18:54 +01:00
co-authored by Claude Opus 5.5
parent 045167715d
commit 380d3ebb2f
54 changed files with 1983 additions and 55 deletions
@@ -0,0 +1,78 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_HOOK_PROPOSALS, BunSQLiteTables } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { HookProposalData } from "@/src/schemas/bot-hook-schema";
import checkHookProposal from "./check-hook-proposal";
type Params = {
user_id: number | string;
bot_id: number | string;
session_id: number | string;
workspace_id?: number | string | null;
/**
* The hook it changes; none for a new one.
*/
hook_id?: number | string | null;
data: HookProposalData;
reason: string;
};
/**
* Stores a hook a bot proposes making or changing, pending until the user
* confirms it from its card in the chat (`checkHookProposal` first).
* Nothing is saved here. A proposal it makes again for the same hook (or,
* for a new one, the same name) in the same conversation replaces the one
* before, so only the latest card can be confirmed. Returns its id.
*/
export default async function createHookProposal({
user_id,
bot_id,
session_id,
workspace_id,
hook_id,
data,
reason,
}: Params) {
const checked = await checkHookProposal({ user_id, bot_id, workspace_id, hook_id, data });
const now = Date.now();
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_HOOK_PROPOSALS, (typeof BunSQLiteTables)[number]>({
table: "hook_proposals",
data: [
{
action: checked.action,
hook_id: checked.current?.id ? Number(checked.current.id) : undefined,
title: checked.title,
data: JSON.stringify(checked.data),
reason,
status: "pending",
outcome: "",
expires_at: now + AppData.ExtensionProposalTtlMs,
user_id: Number(user_id),
bot_id: Number(bot_id),
session_id: Number(session_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
},
],
});
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
if (!res.success || !id) throw new Error(`Couldn't store the proposal: ${res.msg}`);
await BunSQLite.sql({
sql: `UPDATE hook_proposals SET status = 'replaced', outcome = ?, decided_at = ?, updated_at = ?
WHERE session_id = ? AND status = 'pending' AND id != ?
AND (${checked.current?.id ? "hook_id = ?" : "hook_id IS NULL AND title = ? COLLATE NOCASE"})`,
values: [
`Replaced by proposal #${id}.`,
now,
now,
Number(session_id),
id,
checked.current?.id || checked.title,
],
});
return id;
}