/** * Hono integration — `mountWebhook(agent, app, '/wa')` registers webhook routes. * * Hono is optional; the plain agent works with raw fetch handlers. */ import type { Agent } from './agent.js'; import type { CoexistenceHandler } from './webhook/coexistence.js'; import { extractEchoes, partitionCoexistenceWebhook } from './webhook/coexistence.js'; interface HonoLike { get(path: string, handler: (c: HonoContextLike) => unknown): unknown; post(path: string, handler: (c: HonoContextLike) => unknown): unknown; } interface HonoContextLike { req: { query(name: string): string | undefined; header(name: string): string | undefined; arrayBuffer(): Promise; }; executionCtx: { waitUntil(promise: Promise): void }; env: { DB: D1Database; [k: string]: unknown }; text(body: string): unknown; json(body: unknown, status?: number): unknown; } export interface MountWebhookOptions { /** * Handle coexistence changes (v0.19) — `smb_message_echoes`, `history`, * `smb_app_state_sync` — delivered when the number also runs the * WhatsApp Business app. Runs after signature verification; those * changes are stripped from what reaches `Agent.enqueue`. * * mountWebhook(agent, app, '/wa', { * onCoexistence: async ({ echoes }) => { await handoff.markFromEchoes(echoes); }, * }); * * Omit it and coexistence changes are dropped, which is what happened * before v0.19. Throws are caught and logged so Meta still gets its 200. */ onCoexistence?: CoexistenceHandler; } export function mountWebhook(agent: Agent, app: HonoLike, base: string = '/wa', opts: MountWebhookOptions = {}): void { const url = (p: string) => `${base}${p}`; app.get(url('/webhook'), (c) => { const result = agent.verifyChallenge({ mode: c.req.query('hub.mode'), token: c.req.query('hub.verify_token'), challenge: c.req.query('hub.challenge'), }); return result.ok && result.challenge ? c.text(result.challenge) : c.json({ error: 'Invalid' }, 403); }); app.post(url('/webhook'), async (c) => { const raw = await c.req.arrayBuffer(); const valid = await agent.verifySignature(raw, c.req.header('X-Hub-Signature-256')); if (!valid) return c.json({ error: 'Invalid signature' }, 403); const envelope = JSON.parse(new TextDecoder().decode(raw)); let toEnqueue = envelope; const partitioned = partitionCoexistenceWebhook(envelope); if (partitioned.hasCoexistence) { toEnqueue = partitioned.ordinary; if (opts.onCoexistence) { try { await opts.onCoexistence({ env: c.env, tenantId: null, changes: partitioned.coexistence, echoes: extractEchoes(partitioned.coexistence), envelope, }); } catch (e) { console.error('[mountWebhook] onCoexistence threw:', e instanceof Error ? e.message : e); } } } if (!toEnqueue) return c.text('OK'); const enqueued = await agent.enqueue(toEnqueue); if (enqueued) { c.executionCtx.waitUntil( (async () => { await new Promise((r) => setTimeout(r, agent.queue.debounceSeconds * 1000)); await agent.drain(); })() ); } return c.text('OK'); }); }