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,52 @@
|
||||
import type { BotHookSource } from "@/src/dict/bot-hook-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
import grabUserComputer from "../computers/grab-user-computer";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
source: BotHookSource;
|
||||
computer_id?: number | string | null;
|
||||
watch_computer_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the computers a new hook would use: its runs' one must be one of
|
||||
* the bot's there, and a browser-notification hook needs a computer to
|
||||
* watch that both the bot and the user reach. Returns the computer its
|
||||
* runs use (a notification hook's defaults to the watched one), or null
|
||||
* for the bot's current one. Throws why when it can't.
|
||||
*/
|
||||
export default async function assertHookComputers({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
source,
|
||||
computer_id,
|
||||
watch_computer_id,
|
||||
}: Params) {
|
||||
const watching = source == "browser_notifications";
|
||||
const run_computer_id = computer_id || (watching ? watch_computer_id : null) || null;
|
||||
|
||||
if (watching && !watch_computer_id) {
|
||||
throw new AppError(`Pick the computer whose notifications it gets.`);
|
||||
}
|
||||
|
||||
if (run_computer_id || watch_computer_id) {
|
||||
const computers = await grabBotComputers({ bot_id, workspace_id });
|
||||
|
||||
for (const id of [run_computer_id, watching ? watch_computer_id : null].filter(Boolean)) {
|
||||
if (!computers.some((c) => String(c.id) == String(id))) {
|
||||
throw new AppError(`This bot has no access to that computer.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (watching && !(await grabUserComputer({ user_id, computer_id: watch_computer_id! }))?.id) {
|
||||
throw new AppError(`You have no access to that computer.`);
|
||||
}
|
||||
|
||||
return run_computer_id;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
bot_name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws when the user's hooks for this bot are at
|
||||
* `AppData.BotHookMaxPerBot`.
|
||||
*/
|
||||
export default async function assertHookRoom({ user_id, bot_id, bot_name }: Params) {
|
||||
const count = Number(
|
||||
(
|
||||
await BunSQLite.sql<{ n: number }>({
|
||||
sql: `SELECT COUNT(*) AS n FROM bot_hooks WHERE bot_id = ? AND user_id = ?`,
|
||||
values: [bot_id, user_id],
|
||||
})
|
||||
).payload?.[0]?.n || 0,
|
||||
);
|
||||
|
||||
if (count >= AppData.BotHookMaxPerBot) {
|
||||
throw new AppError(`${bot_name || "This bot"} already has ${AppData.BotHookMaxPerBot} hooks. Delete one first.`);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_HOOKS, BunSQLiteTables } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotHookSignatureFormat, BotHookSource } from "@/src/dict/bot-hook-dict";
|
||||
import type { BotHookWithToken } 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 grabBotComputers from "../computers/grab-bot-computers";
|
||||
import grabUserComputer from "../computers/grab-user-computer";
|
||||
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";
|
||||
|
||||
@@ -69,44 +68,17 @@ export default async function createBotHook({
|
||||
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id });
|
||||
const bot_id_num = conversation.bot.id;
|
||||
|
||||
const count = Number(
|
||||
(
|
||||
await BunSQLite.sql<{ n: number }>({
|
||||
sql: `SELECT COUNT(*) AS n FROM bot_hooks WHERE bot_id = ? AND user_id = ?`,
|
||||
values: [bot_id_num, user_id],
|
||||
})
|
||||
).payload?.[0]?.n || 0,
|
||||
);
|
||||
|
||||
if (count >= AppData.BotHookMaxPerBot) {
|
||||
throw new AppError(
|
||||
`${conversation.bot.name || "This bot"} already has ${AppData.BotHookMaxPerBot} hooks. Delete one first.`,
|
||||
);
|
||||
}
|
||||
await assertHookRoom({ user_id, bot_id: bot_id_num, bot_name: conversation.bot.name });
|
||||
|
||||
const watching = source == "browser_notifications";
|
||||
const run_computer_id = computer_id || (watching ? watch_computer_id : null);
|
||||
|
||||
if (watching && !watch_computer_id) {
|
||||
throw new AppError(`Pick the computer whose notifications it gets.`);
|
||||
}
|
||||
|
||||
if (run_computer_id || watch_computer_id) {
|
||||
const computers = await grabBotComputers({
|
||||
bot_id: bot_id_num,
|
||||
workspace_id: conversation.workspace_id,
|
||||
});
|
||||
|
||||
for (const id of [run_computer_id, watching ? watch_computer_id : null].filter(Boolean)) {
|
||||
if (!computers.some((c) => String(c.id) == String(id))) {
|
||||
throw new AppError(`This bot has no access to that computer.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (watching && !(await grabUserComputer({ user_id, computer_id: watch_computer_id! }))?.id) {
|
||||
throw new AppError(`You have no access to that computer.`);
|
||||
}
|
||||
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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import type { HookProposalRecord } from "./parse-hook-proposal-row";
|
||||
|
||||
type Params = {
|
||||
proposal: HookProposalRecord;
|
||||
status: "confirmed" | "rejected";
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides a hook proposal exactly once: only a pending one moves, and
|
||||
* only one that hasn't expired can be confirmed, so two tabs or a double
|
||||
* click never make two hooks. Throws why when it can't.
|
||||
*/
|
||||
export default async function claimHookProposal({ proposal, status }: Params) {
|
||||
const now = Date.now();
|
||||
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||
sql: `UPDATE hook_proposals SET status = ?, decided_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'pending' ${status == "confirmed" ? "AND expires_at > ?" : ""} RETURNING id`,
|
||||
values: [status, now, now, proposal.id, ...(status == "confirmed" ? [now] : [])],
|
||||
});
|
||||
|
||||
if (claimed.payload?.[0]) return;
|
||||
|
||||
if (proposal.status != "pending") throw new AppError(`This proposal was already ${proposal.status}.`);
|
||||
|
||||
throw new AppError(`This proposal expired. Ask the bot to propose it again.`);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BotHookSource } from "@/src/dict/bot-hook-dict";
|
||||
import type { NotifyWatchSync } from "@/src/types";
|
||||
import createBotHook from "../bot-hooks/create-bot-hook";
|
||||
import updateBotHook from "../bot-hooks/update-bot-hook";
|
||||
import checkHookProposal from "./check-hook-proposal";
|
||||
import claimHookProposal from "./claim-hook-proposal";
|
||||
import grabHookProposal from "./grab-hook-proposal";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
proposal_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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`.
|
||||
*/
|
||||
export default async function confirmHookProposal({ user_id, proposal_id }: Params) {
|
||||
const proposal = await grabHookProposal({ proposal_id, user_id });
|
||||
|
||||
await claimHookProposal({ proposal, status: "confirmed" });
|
||||
|
||||
let token: string | undefined;
|
||||
|
||||
try {
|
||||
const checked = await checkHookProposal({
|
||||
user_id,
|
||||
bot_id: proposal.bot_id,
|
||||
workspace_id: proposal.workspace_id,
|
||||
hook_id: proposal.hook_id,
|
||||
data: proposal.data,
|
||||
});
|
||||
const { data } = checked;
|
||||
|
||||
let outcome = "";
|
||||
let result_id = 0;
|
||||
|
||||
if (checked.action == "create") {
|
||||
const created = await createBotHook({
|
||||
user_id,
|
||||
bot_id: proposal.bot_id,
|
||||
workspace_id: proposal.workspace_id,
|
||||
title: data.title!,
|
||||
instructions: data.instructions!,
|
||||
computer_id: data.computer_id,
|
||||
source: data.source as BotHookSource | undefined,
|
||||
watch_computer_id: data.watch_computer_id,
|
||||
watch_filter: data.watch_filter,
|
||||
});
|
||||
|
||||
token = created.token;
|
||||
result_id = Number(created.hook.id);
|
||||
outcome = `Saved as hook #${result_id}.${watchNote({ token, watch: created.watch })}`;
|
||||
} else {
|
||||
const hook = await updateBotHook({
|
||||
user_id,
|
||||
hook_id: checked.current!.id!,
|
||||
data: {
|
||||
title: data.title,
|
||||
instructions: data.instructions,
|
||||
watch_filter: data.watch_filter,
|
||||
enabled: data.enabled,
|
||||
},
|
||||
});
|
||||
const changed = [
|
||||
data.title !== undefined ? "its name" : "",
|
||||
data.instructions !== undefined ? "its brief" : "",
|
||||
data.watch_filter !== undefined ? "its filter" : "",
|
||||
data.enabled ? "switched back on" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
result_id = Number(hook.id);
|
||||
outcome = `Hook #${result_id} changed: ${changed.join(", ")}.`;
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE hook_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [outcome, result_id, Date.now(), proposal.id],
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = (error instanceof Error ? error.message : String(error)).slice(0, 2000);
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE hook_proposals SET status = 'failed', outcome = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [`Couldn't save it: ${message}`, Date.now(), proposal.id],
|
||||
});
|
||||
}
|
||||
|
||||
return { proposal, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a new hook's outcome adds: that its URL went to the user alone, or
|
||||
* whether its computer's watcher runs.
|
||||
*/
|
||||
function watchNote({ token, watch }: { token?: string; watch?: NotifyWatchSync }) {
|
||||
if (token) return ` Its URL was shown once, to the user only.`;
|
||||
|
||||
if (!watch) return "";
|
||||
|
||||
return watch.ok
|
||||
? ` It gets the computer's browser notifications.`
|
||||
: ` The computer's watcher isn't running yet: ${watch.error || "unknown error"}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { HookProposalView } from "@/src/types";
|
||||
import describeHookProposalDetails from "@/src/utils/describe-hook-proposal-details";
|
||||
import grabUserBotHook from "../bot-hooks/grab-user-bot-hook";
|
||||
import grabHookProposal from "./grab-hook-proposal";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook proposal as its chip and confirm card show it to the user it was
|
||||
* proposed to: its details worked out now from what Confirm would save
|
||||
* (and, for a change, the hook as it is), the bot's reason beside them.
|
||||
*/
|
||||
export default async function grabHookProposalView({ proposal_id, user_id }: Params): Promise<HookProposalView> {
|
||||
const proposal = await grabHookProposal({ proposal_id, user_id });
|
||||
const { data } = proposal;
|
||||
const current = proposal.hook_id
|
||||
? await grabUserBotHook({ user_id, hook_id: proposal.hook_id }).catch(() => null)
|
||||
: null;
|
||||
const computer_ids = [data.computer_id, data.watch_computer_id].filter(Boolean) as number[];
|
||||
|
||||
const [bot, workspace, computers] = await Promise.all([
|
||||
BunSQLite.sql<{ name: string }>({ sql: `SELECT name FROM bots WHERE id = ?`, values: [proposal.bot_id] }),
|
||||
proposal.workspace_id
|
||||
? BunSQLite.sql<{ name: string }>({ sql: `SELECT name FROM workspaces WHERE id = ?`, values: [proposal.workspace_id] })
|
||||
: null,
|
||||
computer_ids.length
|
||||
? BunSQLite.sql<{ id: number; name: string }>({
|
||||
sql: `SELECT id, name FROM computers WHERE id IN (${computer_ids.map(() => "?").join(", ")})`,
|
||||
values: computer_ids,
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
return {
|
||||
id: proposal.id,
|
||||
action: proposal.action,
|
||||
hook_id: proposal.hook_id,
|
||||
title: proposal.title,
|
||||
bot_id: proposal.bot_id,
|
||||
bot_name: bot.payload?.[0]?.name || "The bot",
|
||||
status: proposal.status,
|
||||
expired: proposal.status == "pending" && proposal.expires_at < Date.now(),
|
||||
expires_at: proposal.expires_at,
|
||||
outcome: proposal.outcome,
|
||||
result_id: proposal.result_id,
|
||||
makes_url: proposal.action == "create" && data.source != "browser_notifications",
|
||||
details: describeHookProposalDetails({
|
||||
action: proposal.action,
|
||||
data,
|
||||
current,
|
||||
workspace_name: workspace?.payload?.[0]?.name || null,
|
||||
computers: new Map((computers?.payload || []).map((c) => [Number(c.id), c.name || ""])),
|
||||
}),
|
||||
reason: proposal.reason,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HOOK_PROPOSALS } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseHookProposalRow from "./parse-hook-proposal-row";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number | string;
|
||||
/**
|
||||
* The user it was proposed to: nobody else reads it.
|
||||
*/
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One hook proposal made to this user, or an `AppError`.
|
||||
*/
|
||||
export default async function grabHookProposal({ proposal_id, user_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_HOOK_PROPOSALS>({
|
||||
sql: `SELECT * FROM hook_proposals WHERE id = ? AND user_id = ?`,
|
||||
values: [proposal_id, user_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row) throw new AppError(`That proposal doesn't exist.`);
|
||||
|
||||
return parseHookProposalRow({ row });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { BUN_SQLITE_COBBLE_HOOK_PROPOSALS } from "@/db/types/db";
|
||||
import type { HookProposalAction } from "@/src/dict/bot-hook-dict";
|
||||
import type { ExtensionProposalStatus } from "@/src/dict/extension-proposal-dict";
|
||||
import { HookProposalDataSchema, type HookProposalData } from "@/src/schemas/bot-hook-schema";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import parseJsonColumn from "../extensions/parse-json-column";
|
||||
|
||||
type Params = {
|
||||
row: BUN_SQLITE_COBBLE_HOOK_PROPOSALS;
|
||||
};
|
||||
|
||||
export type HookProposalRecord = {
|
||||
id: number;
|
||||
action: HookProposalAction;
|
||||
hook_id: number | null;
|
||||
title: string;
|
||||
data: HookProposalData;
|
||||
reason: string;
|
||||
status: ExtensionProposalStatus;
|
||||
outcome: string;
|
||||
result_id: number | null;
|
||||
expires_at: number;
|
||||
user_id: number;
|
||||
bot_id: number;
|
||||
session_id: number;
|
||||
workspace_id: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A `hook_proposals` row with what it would save read and checked again
|
||||
* by its schema.
|
||||
*/
|
||||
export default function parseHookProposalRow({ row }: Params): HookProposalRecord {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
action: row.action == "change" ? "change" : "create",
|
||||
hook_id: row.hook_id ? Number(row.hook_id) : null,
|
||||
title: row.title || "",
|
||||
data: parseWithSchema({
|
||||
schema: HookProposalDataSchema,
|
||||
data: parseJsonColumn<Record<string, unknown>>({ text: row.data, fallback: {} }),
|
||||
}),
|
||||
reason: row.reason || "",
|
||||
status: (row.status || "pending") as ExtensionProposalStatus,
|
||||
outcome: row.outcome || "",
|
||||
result_id: row.result_id ? Number(row.result_id) : null,
|
||||
expires_at: Number(row.expires_at) || 0,
|
||||
user_id: Number(row.user_id),
|
||||
bot_id: Number(row.bot_id),
|
||||
session_id: Number(row.session_id),
|
||||
workspace_id: row.workspace_id ? Number(row.workspace_id) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import claimHookProposal from "./claim-hook-proposal";
|
||||
import grabHookProposal from "./grab-hook-proposal";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
proposal_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user rejected a hook proposal from its card: nothing is saved.
|
||||
*/
|
||||
export default async function rejectHookProposal({ user_id, proposal_id }: Params) {
|
||||
const proposal = await grabHookProposal({ proposal_id, user_id });
|
||||
|
||||
await claimHookProposal({ proposal, status: "rejected" });
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE hook_proposals SET outcome = 'Rejected. Nothing was saved.', updated_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), proposal.id],
|
||||
});
|
||||
|
||||
return proposal;
|
||||
}
|
||||
Reference in New Issue
Block a user