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:
@@ -0,0 +1,137 @@
|
||||
import type { HookProposalAction } from "@/src/dict/bot-hook-dict";
|
||||
import type { HookProposalData } from "@/src/schemas/bot-hook-schema";
|
||||
import type { BotHookWithNames } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserConversation from "../bot-sessions/grab-user-conversation";
|
||||
import assertHookComputers from "../bot-hooks/assert-hook-computers";
|
||||
import assertHookRoom from "../bot-hooks/assert-hook-room";
|
||||
import grabBotHooks from "../bot-hooks/grab-bot-hooks";
|
||||
import grabUserBotHook from "../bot-hooks/grab-user-bot-hook";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The conversation's workspace, where a new hook runs; null for the
|
||||
* bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The hook it changes; none for a new one.
|
||||
*/
|
||||
hook_id?: number | string | null;
|
||||
data: HookProposalData;
|
||||
};
|
||||
|
||||
export type CheckedHookProposal = {
|
||||
action: HookProposalAction;
|
||||
title: string;
|
||||
/**
|
||||
* For a new hook all of it; for a change only what differs from the
|
||||
* hook now.
|
||||
*/
|
||||
data: HookProposalData;
|
||||
current: BotHookWithNames | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks a hook a bot proposes, when it proposes and again on confirm. A
|
||||
* change is to one of this user's hooks of this bot, and only to its
|
||||
* name, brief, filter, or switching it back on: what calls it and where
|
||||
* it runs stay. A new one runs in the conversation it's proposed in, has
|
||||
* a name and brief, isn't a second one of the same name there, fits the
|
||||
* bot's limit, and uses only computers the bot reaches there (and, to
|
||||
* watch one, the user too). Throws why when it can't.
|
||||
*/
|
||||
export default async function checkHookProposal({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
hook_id,
|
||||
data,
|
||||
}: Params): Promise<CheckedHookProposal> {
|
||||
if (hook_id) {
|
||||
const current = await grabUserBotHook({ user_id, hook_id });
|
||||
|
||||
if (String(current.bot_id) != String(bot_id)) {
|
||||
throw new AppError(`Hook #${hook_id} isn't one of yours.`);
|
||||
}
|
||||
|
||||
if (data.source !== undefined || data.computer_id !== undefined || data.watch_computer_id !== undefined) {
|
||||
throw new AppError(
|
||||
`What calls a hook and where it runs can't be changed. Propose a new hook instead, then delete this one once the user confirmed it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const watching = current.source == "browser_notifications";
|
||||
|
||||
if (data.watch_filter !== undefined && !watching) {
|
||||
throw new AppError(`Only a browser-notification hook has a filter.`);
|
||||
}
|
||||
|
||||
const changes: HookProposalData = {};
|
||||
|
||||
if (data.title !== undefined && data.title != current.title) changes.title = data.title;
|
||||
if (data.instructions !== undefined && data.instructions != current.instructions) {
|
||||
changes.instructions = data.instructions;
|
||||
}
|
||||
if (data.watch_filter !== undefined && data.watch_filter.trim() != (current.watch_filter || "")) {
|
||||
changes.watch_filter = data.watch_filter.trim();
|
||||
}
|
||||
if (data.enabled && !current.enabled) changes.enabled = true;
|
||||
|
||||
if (!Object.keys(changes).length) {
|
||||
throw new AppError(
|
||||
data.enabled && current.enabled
|
||||
? `Hook #${current.id} is already on, and nothing else would change.`
|
||||
: `That changes nothing on hook #${current.id}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { action: "change", title: changes.title || current.title || "", data: changes, current };
|
||||
}
|
||||
|
||||
if (!data.title || !data.instructions) {
|
||||
throw new AppError(`A new hook needs a title and instructions (the brief each call runs).`);
|
||||
}
|
||||
|
||||
const source = data.source == "browser_notifications" ? "browser_notifications" : "url";
|
||||
|
||||
if (source == "url" && (data.watch_computer_id || data.watch_filter)) {
|
||||
throw new AppError(`Only a browser-notification hook watches a computer or has a filter.`);
|
||||
}
|
||||
|
||||
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id });
|
||||
|
||||
await assertHookRoom({ user_id, bot_id: conversation.bot.id, bot_name: conversation.bot.name });
|
||||
|
||||
const here = await grabBotHooks({ user_id, bot_id: conversation.bot.id, workspace_id: conversation.workspace_id });
|
||||
const same = here.find((hook) => (hook.title || "").toLowerCase() == data.title!.toLowerCase());
|
||||
|
||||
if (same) {
|
||||
throw new AppError(`There's already hook #${same.id} "${same.title}" here. Propose a change to it instead.`);
|
||||
}
|
||||
|
||||
await assertHookComputers({
|
||||
user_id,
|
||||
bot_id: conversation.bot.id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
source,
|
||||
computer_id: data.computer_id,
|
||||
watch_computer_id: data.watch_computer_id,
|
||||
});
|
||||
|
||||
return {
|
||||
action: "create",
|
||||
title: data.title,
|
||||
data: {
|
||||
title: data.title,
|
||||
instructions: data.instructions,
|
||||
source,
|
||||
computer_id: data.computer_id || null,
|
||||
watch_computer_id: source == "browser_notifications" ? data.watch_computer_id || null : null,
|
||||
watch_filter: source == "browser_notifications" ? data.watch_filter?.trim() || "" : undefined,
|
||||
},
|
||||
current: null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user