Files
TREK/server/src/nest/plugins/plugin-hooks.service.ts
T
Maurice 2a8071f642 feat(plugins): search plugins can answer the place search as you type
A searchProvider plugin can now implement an optional suggest() beside
search(). Its places then show up in the place search's typed-ahead list,
after the core suggestions and marked with the plugin's name, on desktop
and phone. An index that cannot take a request per keystroke leaves it
out and keeps answering the full search only.

The child reports which functions each hook object carries at load, so
GET /api/plugin-search/suggest only asks the plugins that implement
suggest: from the second character, three rows at most across all of
them, 800 ms to answer. The feed marks plugins holding
hook:search-provider and the client only asks the route while one is
installed. A plugin row carries its whole place, so picking it skips the
details lookup, which knows no plugin id. The phone's search results now
name their source as the desktop ones do, and the desktop dropdown
scrolls instead of running past the dialog.

Addresses discussion #2221.
2026-09-25 09:19:11 +02:00

255 lines
13 KiB
TypeScript

import { PluginController, PluginHook } from './host/rpc-kit/decorators';
import { PluginRuntimeService } from './plugin-runtime.service';
/** What a notification channel is handed for one message. */
export interface HookChannelMessage {
event: string;
title: string;
body: string;
url?: string;
tripName?: string;
}
/** One MCP tool call, as the plugin that published the tool receives it. */
export interface HookMcpToolCall {
/** The plugin-local tool name, without the `plugin_<id>_` advertisement prefix. */
name: string;
/** Whatever the assistant passed. Validated against the declared schema first. */
args: unknown;
}
/**
* What a search provider is asked to look for.
*
* `near` is the same bias the core search uses, and it is what decides which of the
* four places called "Hase-dera" the answer is about. Optional, because a search from
* a trip with no places yet has nothing to bias toward.
*/
export interface HookSearchRequest {
query: string;
limit: number;
/** The caller's language tag, for providers that return localized names. */
lang?: string;
near?: { lat: number; lng: number };
/** Category search within a Roadtrip search rectangle. Older hosts omit these fields. */
category?: string;
bounds?: { south: number; west: number; north: number; east: number };
}
/**
* What a POI category provider is asked for (#1781): the places of ONE of its own
* declared categories inside the map area the user is looking at.
*
* `category` is the plugin-local id from `capabilities.poiCategories`, never a pill
* key, and the host only ever sends an id the manifest declared. `bounds` is the
* viewport after the host narrowed it to a search window, so a provider never has to
* answer for a whole continent.
*/
export interface HookPoiRequest {
category: string;
bounds: { south: number; west: number; north: number; east: number };
lang?: string;
limit: number;
}
/** The waypoint request a route provider is asked to solve. */
export interface HookRouteRequest {
tripId: number;
dayId: number | null;
profile: string;
waypoints: unknown[];
}
/**
* Every host-to-plugin hook call, in one place.
*
* The hooks the consent screen offers (one per HOOK_PERMISSION key, 18 of them now)
* used to be invoked straight from the controllers, with the fn name and the timeout
* written out at each call site. That
* made three things impossible to check: that a granted `hook:*` permission actually
* has a consumer (a dead grant on the consent screen looks exactly like a live one),
* that the fn name the host asks for matches the one the SDK documents, and that two
* call sites for the same hook agree on the budget.
*
* Each method here carries a `@PluginHook` declaration, which the registry validates
* against HOOK_PERMISSION at boot and, with requireTotalCoverage on, fails the app for
* any hook that no longer has a consumer. The declaration and the invocation are the
* same method on purpose, so the two cannot drift apart.
*
* Timeouts are the plugin's budget to answer. They are deliberately not uniform:
* a name lookup is cheap, a route solve over a full day is not, and a notification
* channel is talking to somebody else's API.
*/
@PluginController()
export class PluginHooks {
constructor(private readonly runtime: PluginRuntimeService) {}
/** The plugins that declared `hook`, in manifest order; with `fn`, only those whose hook also has that function. */
providersOf(hook: string, fn?: string): string[] {
return this.runtime.providersOf(hook, fn);
}
@PluginHook('photoProvider', { permission: 'hook:photo-provider', fn: 'search', timeoutMs: 5000 })
searchPhotos(pluginId: string, query: string, page: number, limit: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'photoProvider', 'search', [query, { page, limit }], userId, 5000);
}
@PluginHook('photoProvider', { permission: 'hook:photo-provider', fn: 'getById', timeoutMs: 5000 })
getPhoto(pluginId: string, photoId: string, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'photoProvider', 'getById', [photoId], userId, 5000);
}
// A display name is a one-field read, so it gets a shorter leash than the feed it
// labels; the caller falls back to the plugin id when it times out.
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 })
calendarName(pluginId: string, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'calendarSource', 'getName', [], userId, 3000);
}
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents', timeoutMs: 5000 })
calendarEvents(pluginId: string, userId: number, start: string, end: string): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'calendarSource', 'getEvents', [userId, start, end], userId, 5000);
}
@PluginHook('placeDetailProvider', { permission: 'hook:place-detail-provider', fn: 'getDetails', timeoutMs: 5000 })
placeDetails(pluginId: string, placeId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000);
}
/**
* A short leash, because a person is waiting on a list.
*
* The core search and this one run side by side, so the wait is the slower of the
* two rather than their sum, and the client stops waiting at two and a half seconds
* whatever happens. Two seconds is therefore the whole of what a provider can
* usefully spend: past it the list is already drawn and its answer is discarded, so
* a longer budget would only hold an IPC call open for nothing.
*/
@PluginHook('searchProvider', { permission: 'hook:search-provider', fn: 'search', timeoutMs: 2000 })
searchPlaces(pluginId: string, request: HookSearchRequest, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'searchProvider', 'search', [request], userId, 2000);
}
/**
* The same question asked while it is still being typed (#2221), and the shortest
* leash of any hook here.
*
* Only for a provider that implements `suggest`, which says its index can take a
* request per keystroke. The typed-ahead list waits for it beside the core
* suggestions, which arrive in a few hundred milliseconds, and the next keystroke
* replaces the question anyway, so an answer later than this is one nobody reads.
*/
@PluginHook('searchProvider', { permission: 'hook:search-provider', fn: 'suggest', timeoutMs: 800 })
suggestPlaces(pluginId: string, request: HookSearchRequest, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'searchProvider', 'suggest', [request], userId, 800);
}
/**
* A chip in the explore pill, answered by the plugin that declared it.
*
* Longer than the search budget because nobody is typing: the person picked a
* category and waits for markers, the way they wait for the core categories, whose
* Overpass path is allowed far more. It is still well short of that, because a
* provider behind an external API that has not answered in eight seconds is not
* going to, and the chip should show its error dot rather than spin.
*/
@PluginHook('poiCategoryProvider', { permission: 'hook:poi-category-provider', fn: 'getPois', timeoutMs: 8000 })
categoryPois(pluginId: string, request: HookPoiRequest, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'poiCategoryProvider', 'getPois', [request], userId, 8000);
}
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings', timeoutMs: 5000 })
tripWarnings(pluginId: string, tripId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'warningProvider', 'getWarnings', [tripId], userId, 5000);
}
@PluginHook('tableContributor', { permission: 'hook:table-contributor', fn: 'getContributions', timeoutMs: 5000 })
tableContributions(pluginId: string, view: string, tripId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'tableContributor', 'getContributions', [view, tripId], userId, 5000);
}
@PluginHook('mapMarkerProvider', { permission: 'hook:map-marker-provider', fn: 'getMarkers', timeoutMs: 5000 })
mapMarkers(pluginId: string, tripId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'mapMarkerProvider', 'getMarkers', [tripId], userId, 5000);
}
@PluginHook('mapLayerProvider', { permission: 'hook:map-layer-provider', fn: 'getLayers', timeoutMs: 5000 })
mapLayers(pluginId: string, tripId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'mapLayerProvider', 'getLayers', [tripId], userId, 5000);
}
// A route solve runs an external routing engine over a whole day of waypoints, so it
// gets four times the budget of a plain read. The caller falls back to straight lines.
@PluginHook('routeProvider', { permission: 'hook:route-provider', fn: 'getRoute', timeoutMs: 20_000 })
route(pluginId: string, request: HookRouteRequest, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'routeProvider', 'getRoute', [request], userId, 20_000);
}
@PluginHook('dayScheduleProvider', { permission: 'hook:day-schedule-provider', fn: 'getSchedule', timeoutMs: 5000 })
daySchedule(pluginId: string, tripId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'dayScheduleProvider', 'getSchedule', [tripId], userId, 5000);
}
@PluginHook('dayTintProvider', { permission: 'hook:day-tint-provider', fn: 'getDayTints', timeoutMs: 5000 })
dayTints(pluginId: string, tripId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'dayTintProvider', 'getDayTints', [tripId], userId, 5000);
}
@PluginHook('pdfSectionProvider', { permission: 'hook:pdf-section-provider', fn: 'getSections', timeoutMs: 5000 })
pdfSections(pluginId: string, tripId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'pdfSectionProvider', 'getSections', [tripId], userId, 5000);
}
@PluginHook('atlasLayerProvider', { permission: 'hook:atlas-layer-provider', fn: 'getLayers', timeoutMs: 5000 })
atlasLayers(pluginId: string, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'atlasLayerProvider', 'getLayers', [], userId, 5000);
}
@PluginHook('journalEntryProvider', { permission: 'hook:journal-entry-provider', fn: 'getRows', timeoutMs: 5000 })
journalRows(pluginId: string, entryId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'journalEntryProvider', 'getRows', [entryId], userId, 5000);
}
@PluginHook('tripCardProvider', { permission: 'hook:trip-card-provider', fn: 'getCards', timeoutMs: 5000 })
tripCards(pluginId: string, tripIds: number[], userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'tripCardProvider', 'getCards', [tripIds], userId, 5000);
}
/**
* Both notification-channel calls run with NO acting user: a notification is
* host-initiated for an arbitrary recipient, so the hook gets that recipient's own
* decrypted config as an argument rather than the right to read anything as them.
* The budget is the longest of the read hooks because the channel is talking to a
* third-party API (Telegram, ntfy, a webhook) rather than to TREK.
*/
@PluginHook('notificationChannel', { permission: 'hook:notification-channel', fn: 'send', timeoutMs: 8000 })
sendNotification(pluginId: string, message: HookChannelMessage, userSettings: unknown): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'notificationChannel', 'send', [message, userSettings], undefined, 8000);
}
@PluginHook('notificationChannel', { permission: 'hook:notification-channel', fn: 'test', timeoutMs: 8000 })
testNotification(pluginId: string, userSettings: unknown): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'notificationChannel', 'test', [userSettings], undefined, 8000);
}
/**
* One MCP tool call the assistant made, dispatched into the plugin that
* published the tool.
*
* The longest budget of any read hook. Every other one backs a render that a
* user is waiting on, and falls back to something reasonable when it times
* out; this one backs a chat turn, and the plugin is likely talking to a
* third-party API of its own. Still well under the supervisor's 30s default,
* which no hook uses: 30s of a blocked MCP request is not a budget, it is a
* hung client.
*
* One fn for every tool, because tool names are runtime data and
* hookContracts() is keyed by (hook, fn). The name is checked against the
* advertised set before we get here.
*/
@PluginHook('mcpToolProvider', { permission: 'mcp:tools', fn: 'callTool', timeoutMs: 15_000 })
callMcpTool(pluginId: string, call: HookMcpToolCall, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'mcpToolProvider', 'callTool', [call], userId, 15_000);
}
}