// Wires app-event automations into the server: the manager over the managed connected-apps service, the routes the // app and the bot tools use, and the chat receipts. index.ts builds it once and hands it what it needs. import type { ServerResponse } from "node:http"; import { botMayWatchApp, type AppEventCatalog } from "../shared/app-triggers.ts"; import type { ConnectorToolGrant } from "../shared/wire.ts"; import { appEventCatalog, appLabel } from "./app-event-catalog.ts"; import { brokerClient, type AppTriggerBroker, type BrokerAccess } from "./app-trigger-broker.ts"; import { AppTriggerRequestService, type AppTriggerRequestStore } from "./app-trigger-requests.ts"; import { AppTriggerError, AppTriggerManager, type AppTriggerPatch } from "./app-triggers.ts"; import { managedComposioBrokerOff } from "./composio-broker-access.ts"; import type { RosterMember } from "./peer-roster.ts"; import type { RequestAuth } from "./request-auth.ts"; import type { Routine, RoutineManager } from "./routines.ts"; import { PASS, type RouteContext, type RouteHandler } from "./routes/table.ts"; import { listTriggers, triggerTarget } from "./trigger-tools.ts"; import { addressFor } from "./webhook-address.ts"; import type { WebhookManager } from "./webhooks.ts"; type Bot = RosterMember & { color: string; composio?: boolean; connectorTools?: Record; connectorScopes?: { apps: Record }; }; type Connected = Record }>; export interface AppTriggerHostDeps { file?: string; store: AppTriggerRequestStore & { bot(id: string): Bot | null | undefined }; /** "self-hosted" when the owner saved their own Composio key, which app events do not support. */ connectionMode: () => "managed" | "self-hosted" | "unavailable"; access: () => BrokerAccess | null; connectedApps: () => Promise; routines: () => RoutineManager; webhooks: WebhookManager; /** Where webhook addresses start: this computer's receiver, or the workspace's public address. */ webhookBase: () => string; botState: (botId: string) => "ready" | "busy" | "missing"; timeZone: () => string; visibleBot: (auth: RequestAuth, botId: string) => boolean; emit: (frame: Record) => void; audit: (row: { threadId: string; requestId: string; botId: string; botName: string; tool: string; summary: string }) => void; broker?: AppTriggerBroker; } export interface InternalTriggerContext { from: Bot; threadId: string; ownsThread: (botId: string, threadId: string) => boolean; /** A turn on a Cloud its owner did not drive. */ guestDriven: boolean; /** A run a webhook or app event started, or work one handed on. */ outsideDriven: () => boolean; canCommit: () => boolean; /** Whether this turn may know of the routine: on a Team Cloud one that lives in someone's private chat only in theirs. */ routineShown?: (routine: Routine) => boolean; } const GUEST_REFUSAL = "Only the Cloud's owner can make a bot watch their connected apps. Tell the person to ask from their own conversation."; function status(error: unknown): number { return error instanceof AppTriggerError ? error.status : typeof (error as { status?: unknown }).status === "number" ? (error as { status: number }).status : 400; } export function createAppTriggerHost(deps: AppTriggerHostDeps) { const broker = deps.broker ?? brokerClient(deps.access); const service = () => { const mode = deps.connectionMode(); if (mode === "self-hosted") return { available: false as const, reason: "self-hosted" as const }; if (managedComposioBrokerOff()) return { available: false as const, reason: "off" as const }; return mode === "managed" && deps.access() ? { available: true as const } : { available: false as const, reason: "unavailable" as const }; }; const manager = new AppTriggerManager({ file: deps.file, broker, service, connectedApps: deps.connectedApps, botState: deps.botState, runs: { enqueue: (input) => deps.routines().enqueueWebhook(input), merge: (triggerId, eventId, merge) => deps.routines().mergeIntoQueuedRun(triggerId, eventId, merge), receipt: (triggerId, eventId) => deps.routines().webhookRunReceipt(triggerId, eventId), pending: (triggerId) => deps.routines().activeWebhookRunCount(triggerId), cancelQueued: (triggerId, message) => deps.routines().cancelQueuedWebhook(triggerId, message), }, emit: deps.emit, }); const requests = new AppTriggerRequestService({ store: deps.store, triggers: manager, botName: (botId) => deps.store.bot(botId)?.name ?? "the bot" }); const unavailableText = (reason: "self-hosted" | "unavailable" | "off") => reason === "self-hosted" ? "App events need OpenMausBot's connected apps. This server uses its own Composio key, so a bot cannot watch apps here yet: use a schedule that checks the app, or a webhook." : "Connected apps are not set up here, so a bot cannot watch apps yet."; async function catalog(bot: Bot, app?: string, search?: string): Promise { const current = service(); if (!current.available) return { apps: [], notConnected: [], unavailable: unavailableText(current.reason) }; const connected = await deps.connectedApps(); const result = await appEventCatalog({ broker, connected, bot, app, search }); const wanted = app?.trim().toLowerCase(); if (wanted && !result.apps.length && connected[wanted]?.connected && !botMayWatchApp(bot, wanted)) { return { ...result, unavailable: `${bot.name} has not been given access to ${appLabel(wanted)}. Ask the person to allow it under the bot's Access settings.` }; } return result; } /** The person's side: /api/app-triggers. Reads follow what this viewer may see; changes need the owner. */ const routes: RouteHandler = async ({ req, res, url, path, method, auth, json, readBody }: RouteContext) => { if (!path.startsWith("/api/app-triggers")) return PASS; try { if (path === "/api/app-triggers" && method === "GET") { const snapshot = manager.snapshot(); const shown = snapshot.triggers.filter((trigger) => deps.visibleBot(auth, trigger.botId)); const ids = new Set(shown.map((trigger) => trigger.id)); return json(res, 200, { ...snapshot, triggers: shown, attempts: snapshot.attempts.filter((attempt) => ids.has(attempt.triggerId)) }); } if (path === "/api/app-triggers/events" && method === "GET") { const bot = deps.store.bot(url.searchParams.get("botId") ?? ""); if (!bot) return json(res, 404, { error: "no such bot" }); return json(res, 200, await catalog(bot)); } if (path === "/api/app-triggers/sync" && method === "POST") { await manager.reconcile(); return json(res, 200, manager.snapshot()); } if (path === "/api/app-triggers" && method === "POST") { const body = (await readBody(req)) as Record; const bot = deps.store.bot(typeof body.botId === "string" ? body.botId : ""); if (!bot) return json(res, 404, { error: "no such bot" }); const app = typeof body.app === "string" ? body.app.trim().toLowerCase() : ""; if (!botMayWatchApp(bot, app)) return json(res, 403, { error: `${bot.name} has not been given access to ${appLabel(app)}. Allow it in the bot's Access settings first.` }); const trigger = await manager.create({ botId: bot.id, name: String(body.name ?? ""), prompt: String(body.prompt ?? ""), app, event: String(body.event ?? ""), ...(typeof body.account === "string" ? { account: body.account } : {}), ...(body.filter && typeof body.filter === "object" ? { filter: body.filter as never } : {}), ...(body.config && typeof body.config === "object" ? { config: body.config as Record } : {}), ...(body.runOn === "cloud" ? { runOn: "cloud" as const } : {}), }); return json(res, 201, { trigger }); } const match = path.match(/^\/api\/app-triggers\/([\w-]+)(?:\/(test))?$/); if (!match) return PASS; const id = match[1]!; if (match[2] === "test" && method === "POST") { const result = await manager.runNow(id); return result ? json(res, 202, result) : json(res, 404, { error: "no such automation" }); } if (!match[2] && method === "PATCH") { const body = (await readBody(req)) as Record; const patch: AppTriggerPatch = { ...(typeof body.name === "string" ? { name: body.name } : {}), ...(typeof body.prompt === "string" ? { prompt: body.prompt } : {}), ...(body.runOn === "maus" || body.runOn === "cloud" ? { runOn: body.runOn } : {}), ...(body.filter === null || (body.filter && typeof body.filter === "object") ? { filter: body.filter as never } : {}), ...(body.config && typeof body.config === "object" ? { config: body.config as Record } : {}), ...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}), }; const trigger = await manager.update(id, patch); return trigger ? json(res, 200, { trigger }) : json(res, 404, { error: "no such automation" }); } if (!match[2] && method === "DELETE") { return await manager.remove(id) ? json(res, 200, { ok: true }) : json(res, 404, { error: "no such automation" }); } return PASS; } catch (error) { return json(res, status(error), { error: error instanceof Error ? error.message : String(error) }); } }; /** The bot's side: trigger_info list and events, for the bot whose turn holds the capability. */ const internalRoutes = (context: InternalTriggerContext): RouteHandler => async ({ res, url, path, method, json }: RouteContext) => { if (method !== "GET" || (path !== "/api/internal/triggers" && path !== "/api/internal/trigger-events")) return PASS; if (!context.ownsThread(context.from.id, context.threadId)) return json(res, 403, { error: "source conversation does not belong to sender" }); if (path === "/api/internal/trigger-events") { if (context.guestDriven) return json(res, 403, { error: GUEST_REFUSAL }); try { return json(res, 200, await catalog(context.from, url.searchParams.get("app") ?? undefined, url.searchParams.get("search") ?? undefined)); } catch (error) { return json(res, 200, { error: `The events could not be listed: ${error instanceof Error ? error.message : String(error)}` }); } } const target = triggerTarget(context.from, url.searchParams.get("forBotId") ?? undefined, (id) => deps.store.bot(id)); if ("error" in target) return json(res, target.status, { error: target.error }); const ownerId = target.forBot?.botId ?? context.from.id; const listing = listTriggers({ botId: ownerId, triggerId: url.searchParams.get("triggerId") ?? undefined, routines: deps.routines().listRoutines().filter((routine) => context.routineShown?.(routine) ?? true), webhooks: deps.webhooks.list(), appTriggers: manager.list(), runs: deps.routines().listRuns(), timeZone: deps.timeZone(), now: Date.now(), }); const shown = listing.trigger; if (url.searchParams.get("includeAddress") !== "1" || !shown) return json(res, 200, listing); if (shown.kind !== "webhook") return json(res, 200, { ...listing, trigger: { ...shown, addressRefused: "Only an automation another app sends to (a webhook) has an address." } }); const owner = deps.store.bot(ownerId); const address = owner ? addressFor({ from: context.from, owner, guestDriven: context.guestDriven, outsideDriven: context.outsideDriven() }, deps.webhooks, shown.id, deps.webhookBase()) : {}; return json(res, 200, { ...listing, trigger: { ...shown, ...address } }); }; /** trigger create (when=app_event) and every change to one: applies at once and leaves the receipt card. */ async function submit(res: ServerResponse, json: RouteContext["json"], input: InternalTriggerContext & { operation: { action: string; trigger?: unknown; triggerId?: string; changes?: unknown }; forBot?: { botId: string; name: string }; sender?: { botId: string; name: string; color: string }; }): Promise { // Its events would stream the owner's app data into a conversation someone else drives. if (input.guestDriven) return json(res, 403, { error: GUEST_REFUSAL }); const owner = deps.store.bot(input.forBot?.botId ?? input.from.id); const app = input.operation.action === "create" && input.operation.trigger && typeof input.operation.trigger === "object" ? String((input.operation.trigger as { app?: unknown }).app ?? "").toLowerCase() : ""; if (owner && app && !botMayWatchApp(owner, app)) { return json(res, 403, { error: `${owner.name} has not been given access to ${appLabel(app)}. Ask the person to allow it under the bot's Access settings; do not retry until they have.` }); } try { const applied = await requests.submit({ botId: input.from.id, threadId: input.threadId, operation: { ...input.operation, forBot: input.forBot }, from: input.sender, canCommit: input.canCommit }); deps.audit({ threadId: input.threadId, requestId: applied.requestId, botId: input.from.id, botName: input.from.name, tool: applied.action === "create" ? "create_trigger" : "manage_trigger", summary: applied.summary }); return json(res, 201, applied); } catch (error) { return json(res, status(error), { error: error instanceof Error ? error.message : String(error) }); } } return { manager, routes, internalRoutes, submit, undo: (args: { botId: string; threadId: string; requestId: string }) => requests.undo(args), isApp: (id: string) => manager.get(id) !== null, start: () => manager.start(), stop: () => manager.stop(), /** Access to the service changed (the desktop handed its token over, or a Cloud registered). */ wake: () => manager.wake(), }; }