import { readFileSync, writeFileSync } from "node:fs"; import chalk from "chalk"; import { parseDocument } from "yaml"; import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; import { applyNotifierRoutingPreset, getNotifierRoutingState, promptNotifierRoutingPreset, resolveRoutingPresetOption, type ClackPrompts, type NotifierRoutingPreset, } from "./notifier-routing.js"; const DEFAULT_RETRIES = 2; const DEFAULT_RETRY_DELAY_MS = 1000; const SETUP_TIMEOUT_MS = 10_000; export interface WebhookSetupOptions { url?: string; authToken?: string; refresh?: boolean; status?: boolean; test?: boolean; force?: boolean; nonInteractive?: boolean; routingPreset?: string; } interface WebhookConfig { plugin: "webhook"; url: string; headers?: Record; retries: number; retryDelayMs: number; } interface ConfigContext { configPath: string; rawConfig: Record; } interface ResolvedWebhookSetup { url: string; headers?: Record; retries: number; retryDelayMs: number; shouldSendTest: boolean; routingPreset?: NotifierRoutingPreset; } export class WebhookSetupError extends Error { constructor( message: string, public readonly exitCode = 1, ) { super(message); this.name = "WebhookSetupError"; } } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } function validateWebhookUrl(url: string): void { let parsed: URL; try { parsed = new URL(url); } catch { throw new WebhookSetupError("Webhook URL is invalid."); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new WebhookSetupError("Webhook URL must start with http:// or https://."); } } function readConfigContext(): ConfigContext { const configPath = findConfigFile() ?? undefined; if (!configPath) { throw new WebhookSetupError( "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", ); } const rawYaml = readFileSync(configPath, "utf-8"); const doc = parseDocument(rawYaml); const rawConfig = (doc.toJS() as Record) ?? {}; return { configPath, rawConfig }; } function getExistingWebhook(rawConfig: Record): Record { const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; const existing = notifiers["webhook"]; return isRecord(existing) ? existing : {}; } function getBearerToken(headers: unknown): string | undefined { if (!isRecord(headers)) return undefined; const authorization = stringValue(headers["Authorization"] ?? headers["authorization"]); const match = authorization?.match(/^Bearer\s+(.+)$/i); return match?.[1]?.trim(); } function numberValue(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback; } function formatFetchError(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } function buildSetupPayload(): Record { return { type: "notification", event: { id: `webhook-setup-${Date.now()}`, type: "setup.webhook", priority: "info", sessionId: "setup", projectId: "ao", timestamp: new Date().toISOString(), message: "AO webhook notifications are ready.", data: { source: "ao-setup-webhook", }, }, }; } async function sendSetupProbe(resolved: ResolvedWebhookSetup): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS); try { const response = await fetch(resolved.url, { method: "POST", headers: { "Content-Type": "application/json", ...(resolved.headers ?? {}), }, body: JSON.stringify(buildSetupPayload()), signal: controller.signal, }); if (response.ok) return; const body = await response.text().catch(() => ""); throw new WebhookSetupError( `Webhook setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`, ); } catch (error) { if (error instanceof WebhookSetupError) throw error; throw new WebhookSetupError(`Webhook setup test failed: ${formatFetchError(error)}`); } finally { clearTimeout(timer); } } async function shouldReplaceConflictingWebhook( existingPlugin: unknown, force: boolean, nonInteractive: boolean, ): Promise { if (existingPlugin === undefined || existingPlugin === "webhook" || force) return true; if (nonInteractive) { throw new WebhookSetupError( `notifiers.webhook already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`, ); } const clack = await import("@clack/prompts"); const replace = await clack.confirm({ message: `notifiers.webhook already uses plugin "${String(existingPlugin)}". Replace it?`, initialValue: false, }); if (clack.isCancel(replace) || !replace) { console.log(chalk.dim("Keeping existing webhook notifier config.")); return false; } return true; } function cancelSetup(clack: ClackPrompts): never { clack.cancel("Setup cancelled."); throw new WebhookSetupError("Setup cancelled.", 0); } async function promptWebhookUrl( clack: ClackPrompts, initialValue: string | undefined, ): Promise { const urlInput = await clack.text({ message: "Webhook URL:", placeholder: "https://example.com/ao-events", initialValue, validate: (value) => { if (!value) return "URL is required"; try { validateWebhookUrl(String(value)); } catch (error) { return error instanceof Error ? error.message : String(error); } }, }); if (clack.isCancel(urlInput)) { cancelSetup(clack); } return String(urlInput); } async function promptChangeWebhookUrl( clack: ClackPrompts, existingUrl: string | undefined, ): Promise { const next = await clack.select({ message: "How do you want to configure the webhook URL?", options: [ { value: "enter-url", label: "Add new webhook URL", hint: "Paste a different HTTP endpoint", }, { value: "back", label: "Back", hint: "Return to the previous options", }, { value: "cancel", label: "Cancel setup", hint: "Do not change config", }, ], }); if (clack.isCancel(next) || next === "cancel") { cancelSetup(clack); } if (next === "back") return "back"; return promptWebhookUrl(clack, existingUrl); } async function resolveInteractiveUrl( clack: ClackPrompts, opts: WebhookSetupOptions, existingUrl: string | undefined, ): Promise { const providedUrl = stringValue(opts.url); if (providedUrl) return providedUrl; while (true) { if (!existingUrl) { return promptWebhookUrl(clack, undefined); } const source = await clack.select({ message: "Webhook notifier is already configured. What do you want to do?", options: [ { value: "use-existing", label: "Use existing webhook URL", hint: "Keep sending to the currently configured endpoint", }, { value: "add-new", label: "Add new webhook URL", hint: "Paste a different HTTP endpoint", }, { value: "cancel", label: "Cancel setup", hint: "Do not change config", }, ], }); if (clack.isCancel(source) || source === "cancel") { cancelSetup(clack); } if (source === "use-existing") { return existingUrl; } if (source === "add-new") { const result = await promptChangeWebhookUrl(clack, existingUrl); if (result === "back") continue; return result; } } } async function resolveInteractiveSetup( opts: WebhookSetupOptions, existingWebhook: Record, rawConfig: Record, ): Promise { const clack = await import("@clack/prompts"); const existingUrl = stringValue(existingWebhook["url"]); const existingToken = getBearerToken(existingWebhook["headers"]); const optionRoutingPreset = resolveWebhookRoutingPreset(opts.routingPreset); clack.intro(chalk.bgCyan(chalk.black(" ao setup webhook "))); while (true) { const resolvedUrl = await resolveInteractiveUrl(clack, opts, existingUrl); let authToken = stringValue(opts.authToken); if (!authToken && existingToken) { const keepExisting = await clack.confirm({ message: "Keep the existing Authorization bearer token?", initialValue: true, }); if (clack.isCancel(keepExisting)) { cancelSetup(clack); } if (keepExisting) authToken = existingToken; } if (!authToken) { const tokenInput = await clack.password({ message: "Auth token (optional; leave blank for none):", }); if (clack.isCancel(tokenInput)) { cancelSetup(clack); } authToken = stringValue(tokenInput); } const routingSelection = optionRoutingPreset ?? (await promptNotifierRoutingPreset(clack, rawConfig, "webhook", "webhook", () => cancelSetup(clack), )); if (routingSelection === "back") continue; const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES); const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS); return buildResolvedSetup( resolvedUrl, authToken, retries, retryDelayMs, routingSelection === "preserve" ? undefined : routingSelection, opts, ); } } function resolveNonInteractiveSetup( opts: WebhookSetupOptions, existingWebhook: Record, ): ResolvedWebhookSetup { const url = stringValue(opts.url) ?? (opts.refresh ? stringValue(existingWebhook["url"]) : undefined); if (!url) { throw new WebhookSetupError( "Webhook URL is required. Pass --url, or run `ao setup webhook --refresh` with an existing webhook config.", ); } const authToken = stringValue(opts.authToken) ?? getBearerToken(existingWebhook["headers"]); const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES); const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS); const routingPreset = resolveWebhookRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all"); return buildResolvedSetup(url, authToken, retries, retryDelayMs, routingPreset, opts); } function buildResolvedSetup( url: string, authToken: string | undefined, retries: number, retryDelayMs: number, routingPreset: NotifierRoutingPreset | undefined, opts: WebhookSetupOptions, ): ResolvedWebhookSetup { const normalizedUrl = url.trim(); validateWebhookUrl(normalizedUrl); const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined; return { url: normalizedUrl, headers, retries, retryDelayMs, shouldSendTest: opts.test !== false, routingPreset, }; } function resolveWebhookRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined { try { return resolveRoutingPresetOption(value, "webhook") as NotifierRoutingPreset | undefined; } catch (error) { throw new WebhookSetupError(error instanceof Error ? error.message : String(error)); } } function toWebhookConfig(resolved: ResolvedWebhookSetup): WebhookConfig { return { plugin: "webhook", url: resolved.url, ...(resolved.headers ? { headers: resolved.headers } : {}), retries: resolved.retries, retryDelayMs: resolved.retryDelayMs, }; } function writeWebhookConfig(configPath: string, resolved: ResolvedWebhookSetup): void { const rawYaml = readFileSync(configPath, "utf-8"); const doc = parseDocument(rawYaml); const rawConfig = (doc.toJS() as Record) ?? {}; const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; notifiers["webhook"] = toWebhookConfig(resolved); rawConfig["notifiers"] = notifiers; const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; rawConfig["defaults"] = defaults; applyNotifierRoutingPreset(rawConfig, "webhook", resolved.routingPreset); if (!isCanonicalGlobalConfigPath(configPath)) { const currentSchema = doc.get("$schema"); if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { doc.set("$schema", CONFIG_SCHEMA_URL); } } doc.setIn(["notifiers"], rawConfig["notifiers"]); doc.setIn(["defaults"], rawConfig["defaults"]); if (rawConfig["notificationRouting"] !== undefined) { doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); } writeFileSync(configPath, doc.toString({ indent: 2 })); } async function printStatus(): Promise { const context = readConfigContext(); const existingWebhook = getExistingWebhook(context.rawConfig); const plugin = stringValue(existingWebhook["plugin"]); const url = stringValue(existingWebhook["url"]); const hasAuth = Boolean(getBearerToken(existingWebhook["headers"])); const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES); const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS); console.log(chalk.bold("Webhook notifier status")); console.log(` Config: ${context.configPath}`); console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`); console.log(` URL: ${url ?? chalk.dim("not configured")}`); console.log(` Auth: ${hasAuth ? "Authorization bearer token configured" : "none"}`); console.log(` Retries: ${retries}`); console.log(` Retry delay: ${retryDelayMs}ms`); console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "webhook").label}`); if (plugin !== "webhook" || !url) return; try { await sendSetupProbe( buildResolvedSetup( url, getBearerToken(existingWebhook["headers"]), retries, retryDelayMs, undefined, { test: true }, ), ); console.log(chalk.green(" Probe: PASS")); } catch (error) { console.log( chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`), ); } } export async function runWebhookSetupAction(opts: WebhookSetupOptions): Promise { const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; const force = Boolean(opts.force); if (opts.status) { await printStatus(); return; } const context = readConfigContext(); const existingWebhook = getExistingWebhook(context.rawConfig); const shouldWire = await shouldReplaceConflictingWebhook( existingWebhook["plugin"], force, nonInteractive, ); if (!shouldWire) return; const resolved = nonInteractive ? resolveNonInteractiveSetup(opts, existingWebhook) : await resolveInteractiveSetup(opts, existingWebhook, context.rawConfig); if (resolved.shouldSendTest) { await sendSetupProbe(resolved); console.log(chalk.green("āœ“ Webhook setup test passed")); } else { console.log(chalk.dim("Skipped webhook setup test.")); } writeWebhookConfig(context.configPath, resolved); console.log(chalk.green(`āœ“ Config written to ${context.configPath}`)); if (!nonInteractive) { const clack = await import("@clack/prompts"); clack.outro( `${chalk.green("Webhook setup complete!")} AO will send notifications to ${resolved.url}.\n` + chalk.dim(" Test it with: ao notify test --to webhook --template basic"), ); } else { console.log(chalk.green("\nāœ“ Webhook setup complete.")); console.log(chalk.dim("Test it with: ao notify test --to webhook --template basic")); } }