Keep every hook call's response, searchable by the user and the bot

A hook call used to record only how it was taken; its run's answer went
to the chat alone and was lost to the call once the job was pruned. Now
`bot_hook_events` keeps `response`, `response_message_id` and
`responded_at`, written on every call a run took in when that run ends
(completed, failed, lost, stopped or cancelled) by
`recordHookCallResponse` in the job helpers, and calls turn done or
failed. The Calls view searches what calls sent and what runs answered,
filters by status and pages; Show all loads one call whole. Bots get
`search_hook_calls` and `read_hook_call` for their own hooks (never in a
hook call's own run), with payloads always marked as outside data.
Calls are kept for the app setting `hook_events_keep_days` (Settings >
General, default now 30 days). The caller still only gets 202 Queued.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 17:47:28 +01:00
co-authored by Claude Opus 5.5
parent fb4c89fca1
commit 0e59559ea9
43 changed files with 1182 additions and 84 deletions
+62 -25
View File
@@ -1,48 +1,85 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useEffect, useState } from "react";
import type { BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { BotHookEventStatus } from "@/src/dict/bot-hook-dict";
import type { BotHookCall } from "@/src/types";
type Params = {
hook_id: number;
/**
* Words to find in what a call sent or what its run answered.
*/
query?: string;
status?: BotHookEventStatus | "";
};
function callsUrl({ hook_id, query, status, before_id }: Params & { before_id?: number }) {
const search = new URLSearchParams({ hook_id: String(hook_id), limit: String(AppData.BotHookCallsPageSize) });
if (query) search.set("q", query);
if (status) search.set("status", status);
if (before_id) search.set("before_id", String(before_id));
return `/api/admin/hooks/hook-events?${search}`;
}
/**
* One hook's latest calls, loaded when the view showing them opens.
* One hook's calls, newest first, loaded when the view showing them opens
* and again when the search or status changes, a page at a time.
*/
export default function useHookEvents({ hook_id }: Params) {
const [events, setEvents] = useState<BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS[]>([]);
export default function useHookEvents({ hook_id, query = "", status = "" }: Params) {
const [events, setEvents] = useState<BotHookCall[]>([]);
const [loading, setLoading] = useState(true);
const [more, setMore] = useState(false);
const [error, setError] = useState<string>();
const load = async ({ before_id }: { before_id?: number }) => {
setLoading(true);
setError(undefined);
const res = await fetchApi<{}, APIResponseObject<BotHookCall>>(
callsUrl({ hook_id, query, status, before_id }),
{ method: "GET" },
).catch(() => null);
setLoading(false);
if (!res?.success) {
setError(res?.msg || `Couldn't load the calls.`);
return null;
}
const page = res.payload || [];
setMore(page.length >= AppData.BotHookCallsPageSize);
return page;
};
useEffect(() => {
let live = true;
setLoading(true);
setError(undefined);
setEvents([]);
fetchApi<{}, APIResponseObject<BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS>>(
`/api/admin/hooks/hook-events?hook_id=${hook_id}`,
{ method: "GET" },
)
.catch(() => null)
.then((res) => {
if (!live) return;
setLoading(false);
if (!res?.success) {
setError(res?.msg || `Couldn't load the calls.`);
return;
}
setEvents(res.payload || []);
});
load({}).then((page) => {
if (live && page) setEvents(page);
});
return () => {
live = false;
};
}, [hook_id]);
}, [hook_id, query, status]);
return { events, loading, error };
const loadOlder = async () => {
const last = events[events.length - 1];
if (!last?.id) return;
const page = await load({ before_id: Number(last.id) });
if (page) setEvents((current) => [...current, ...page]);
};
return { events, loading, error, more, loadOlder };
}