/** * Multi-tenant Agent routing. * * Wraps the "envelope → tenantId → Agent" routing pattern that BSP-style * apps already write by hand. The single-tenant `Agent` + `mountWebhook` * API is unchanged; this is a sibling, opt-in. * * const registry = new MultiTenantAgentRegistry({ * resolveTenantId: async (env, envelope) => { * const phoneNumberId = envelope.entry?.[0]?.changes?.[0]?.value?.metadata?.phone_number_id; * if (!phoneNumberId) return null; * return await env.KV.get(`wa:phone:${phoneNumberId}`); * }, * buildAgent: async (env, tenantId) => { * const tenant = await loadTenantConfig(env, tenantId); * return new Agent({ * whatsapp: { endpoint: tenant.metaEndpoint, token: tenant.metaToken, appSecret: tenant.appSecret }, * db: env.DB, * ai: buildAI(tenant), * mode: tenant.agentMode, * escalationStore: tenant.escalationStore, * tenantId, * }); * }, * }); * * mountMultiTenantWebhook(registry, app, '/wa'); * * Per-isolate `Map` agent cache by default (no TTL — isolate lifetime * covers the cache lifetime, and tenant config rarely changes mid-isolate). * Pass a custom `agentCache` for LRU / TTL semantics. * * Queue table is shared across tenants — `message_queue.message_id` is * globally unique because Meta's wamids are globally unique. No * per-tenant schema migration needed. */ import type { Agent } from '../agent.js'; import type { InboundEnvelope } from '../types.js'; import type { AgentReviewQueue } from '../review/review_queue.js'; import type { CoexistenceHandler } from '../webhook/coexistence.js'; import { extractEchoes, partitionCoexistenceWebhook } from '../webhook/coexistence.js'; export interface AgentCache { get(tenantId: string): Agent | null; set(tenantId: string, agent: Agent): void; /** * Drop a cached Agent so the next turn rebuilds it (v0.19). Optional for * backwards compatibility with caches written against the v0.6 shape — * `registry.invalidate` reports `false` when the cache can't evict. */ delete?(tenantId: string): boolean; } export interface MultiTenantAgentRegistryOptions { /** * Resolve the inbound envelope to a tenantId. Return `null` when the * envelope is for an unknown tenant (typically: a `phone_number_id` * not present in your tenant store). `onUnknownTenant` fires in that * case so apps can log / alert / emit. * * Receives the bot env so KV/D1 lookups are easy. */ resolveTenantId: (env: unknown, envelope: InboundEnvelope) => Promise | string | null; /** * Build a fully-configured Agent for `tenantId`. Called once per cache * miss; the result is stashed in `agentCache`. Synchronous returns are * accepted for in-memory configs. */ buildAgent: (env: unknown, tenantId: string) => Promise | Agent; /** * Per-isolate Agent cache. Default: in-memory Map, no TTL — the isolate * lifetime covers the lifetime of any practical tenant config. Pass * `null` to disable caching (rebuild every turn — useful for tests). */ agentCache?: AgentCache | null; /** * Called when `resolveTenantId` returns null. Default: console.warn. * Use to emit telemetry, count unknown-number hits, etc. */ onUnknownTenant?: (env: unknown, envelope: InboundEnvelope) => void | Promise; /** * Enumerate every tenantId the bot serves. Used by `drainAll` (v0.7+) * to schedule per-tenant queue drains from a single cron trigger. The * scheduled cron handler typically delegates to this so the registry * can iterate without each app re-implementing the lookup. * * enumerateTenants: async (env) => { * const r = await env.DB.prepare('SELECT id FROM tenants').all<{ id: string }>(); * return (r.results ?? []).map(t => t.id); * } * * Apps that paginate (hundreds of tenants) extend this signature later * — v0.7 ships the simple version. */ enumerateTenants?: (env: unknown) => Promise | string[]; } /** * In-memory `AgentCache` — the default. Cache is per-isolate and never * evicts; Cloudflare's isolate-recycling policy bounds the size. */ export class MemoryAgentCache implements AgentCache { private readonly map = new Map(); get(tenantId: string): Agent | null { return this.map.get(tenantId) ?? null; } set(tenantId: string, agent: Agent): void { this.map.set(tenantId, agent); } /** * Evict one tenant (v0.19). Returns true when something was cached. * Call after the tenant's config changes — policy edit, mode switch, * reconnected number — otherwise the stale Agent lives as long as the * isolate does. */ delete(tenantId: string): boolean { return this.map.delete(tenantId); } /** Test helper — clears all cached agents. */ clear(): void { this.map.clear(); } /** Number of cached agents. */ get size(): number { return this.map.size; } } export class MultiTenantAgentRegistry { readonly resolveTenantId: MultiTenantAgentRegistryOptions['resolveTenantId']; readonly buildAgent: MultiTenantAgentRegistryOptions['buildAgent']; readonly agentCache: AgentCache | null; readonly onUnknownTenant: (env: unknown, envelope: InboundEnvelope) => void | Promise; readonly enumerateTenants: ((env: unknown) => Promise | string[]) | null; constructor(opts: MultiTenantAgentRegistryOptions) { if (!opts.resolveTenantId) throw new Error('MultiTenantAgentRegistry: resolveTenantId required'); if (!opts.buildAgent) throw new Error('MultiTenantAgentRegistry: buildAgent required'); this.resolveTenantId = opts.resolveTenantId; this.buildAgent = opts.buildAgent; // `null` opts out of caching entirely; `undefined` falls back to the // memory default. Tests use the null path to assert build cost. this.agentCache = opts.agentCache === null ? null : opts.agentCache ?? new MemoryAgentCache(); this.onUnknownTenant = opts.onUnknownTenant ?? defaultOnUnknownTenant; this.enumerateTenants = opts.enumerateTenants ?? null; } /** * Iterate every tenant from `enumerateTenants`, resolve each Agent * (cache-friendly), and schedule `drain() + queue.cleanup()` via * `waitUntil`. Designed for `Worker.scheduled(event, env, ctx)`: * * scheduled: (event, env, ctx) => * registry.drainAll(env, (p) => ctx.waitUntil(p)) * * `enumerateTenants` MUST be configured at construction or this throws — * the registry has no other way to know which tenants exist. Apps that * paginate (hundreds of tenants) should override per-batch in v0.8. * * Per-tenant failures are caught and logged so one bad tenant doesn't * stop the cron from draining the rest. The return value reports how * many drains were scheduled, useful for cron-handler logs / metrics. */ async drainAll(env: unknown, waitUntil: (p: Promise) => void): Promise<{ scheduled: number }> { const { scheduled } = await this.forEachTenant(env, waitUntil, async (agent) => { await agent.drain(); await agent.queue.cleanup(); }); return { scheduled }; } /** * Generalization of `drainAll`: iterate every tenant from * `enumerateTenants`, resolve each Agent (cache-friendly), and schedule * the user-supplied `fn(agent, tenantId)` via `waitUntil`. Use for any * per-tenant cron — Broadcast, ReEngagement, content generation, slot * delivery — that the framework doesn't already wrap. * * await registry.forEachTenant(env, ctx.waitUntil, async (agent, tid) => { * await new Broadcast({ client: agent.client, db: agent.db, channel: 'devotional' }) * .run({ send: ... }); * }); * * Like `drainAll`, this requires `enumerateTenants` at construction and * catches per-tenant failures so one bad tenant can't stop the cron. * * Returns `{ scheduled, errored }` — `scheduled` counts agents the * waitUntil callback was invoked for; `errored` counts tenants where * `agentFor` failed before the fn was reached. The fn's own throws are * caught inside the waitUntil closure and counted toward neither — they * surface in the console log. */ async forEachTenant( env: unknown, waitUntil: (p: Promise) => void, fn: (agent: Agent, tenantId: string) => Promise | void, ): Promise<{ scheduled: number; errored: number }> { if (!this.enumerateTenants) { throw new Error( 'MultiTenantAgentRegistry.forEachTenant: enumerateTenants must be configured at construction', ); } let tenantIds: string[]; try { tenantIds = await this.enumerateTenants(env); } catch (e) { console.error('[MultiTenantAgentRegistry] enumerateTenants threw:', e instanceof Error ? e.message : e); return { scheduled: 0, errored: 0 }; } let scheduled = 0; let errored = 0; for (const tenantId of tenantIds) { let agent: Agent; try { agent = await this.agentFor(env, tenantId); } catch (e) { console.error(`[MultiTenantAgentRegistry] agentFor ${tenantId} failed:`, e instanceof Error ? e.message : e); errored += 1; continue; } waitUntil( (async () => { try { await fn(agent, tenantId); } catch (e) { console.error(`[MultiTenantAgentRegistry] forEachTenant ${tenantId} fn failed:`, e instanceof Error ? e.message : e); } })(), ); scheduled += 1; } return { scheduled, errored }; } /** * Cron helper for `assisted` mode: pick up every approved-but-not-yet-sent * row from the review queue and dispatch via the right tenant's Agent. * The reviewQueue is single (shared across tenants) — its `tenant_id` * column scopes rows, and this helper groups approved rows per tenant * before scheduling the per-tenant sends. * * scheduled: async (_event, env, ctx) => { * await registry.dispatchApprovedReviews(env, reviewQueue, (p) => ctx.waitUntil(p)); * } * * Per-row send failures are caught + logged so one stuck row doesn't * block the cron. Returns `{ scheduled }` for handler-side metrics. * * Apps without multi-tenant routing can use the same pattern via a * single-Agent cron — call `queue.list({ status: 'approved' })` and * dispatch via `agent.client.sendText` directly. */ async dispatchApprovedReviews( env: unknown, reviewQueue: AgentReviewQueue, waitUntil: (p: Promise) => void, ): Promise<{ scheduled: number }> { const approved = await reviewQueue.list({ status: 'approved', limit: 500 }); let scheduled = 0; for (const row of approved) { const tenantId = row.tenantId; if (!tenantId) { // Single-tenant row; this multi-tenant helper can't dispatch it. // Apps without multi-tenant routing should handle that path // themselves (see the docstring). console.warn(`[MultiTenantAgentRegistry] approved review ${row.id} has no tenantId — skipping`); continue; } let agent: Agent; try { agent = await this.agentFor(env, tenantId); } catch (e) { console.error(`[MultiTenantAgentRegistry] dispatch ${row.id}: agentFor ${tenantId} failed:`, e instanceof Error ? e.message : e); continue; } waitUntil( (async () => { try { const body = row.editedText ?? row.aiText; const ok = await agent.client.sendText(row.whatsapp, body); if (ok) await reviewQueue.markSent(row.id); } catch (e) { console.error(`[MultiTenantAgentRegistry] dispatch ${row.id} send failed:`, e instanceof Error ? e.message : e); } })(), ); scheduled += 1; } return { scheduled }; } /** * Resolve + cache the Agent for the given tenantId. * * Returns the freshly-built or cached Agent. Failures from `buildAgent` * propagate — callers should catch + log them. */ async agentFor(env: unknown, tenantId: string): Promise { if (!tenantId) throw new Error('MultiTenantAgentRegistry.agentFor: tenantId required'); if (this.agentCache) { const cached = this.agentCache.get(tenantId); if (cached) return cached; } const built = await this.buildAgent(env, tenantId); if (this.agentCache) this.agentCache.set(tenantId, built); return built; } /** * Evict a tenant's cached Agent (v0.19) so the next turn rebuilds it from * current config. Call this whenever tenant config changes underneath the * isolate — policy edits from your dashboard, an agent-mode switch, a * reconnected WhatsApp number. * * Returns false when nothing was evicted: no cache configured, the cache * predates the optional `delete` method, or the tenant wasn't cached. */ invalidate(tenantId: string): boolean { if (!this.agentCache?.delete) return false; return this.agentCache.delete(tenantId); } /** * Resolve the tenant from an envelope and return the corresponding Agent. * Returns `null` when the tenant can't be resolved — caller can decide * whether to reject the request, queue it for later, or silently drop. */ async agentForEnvelope(env: unknown, envelope: InboundEnvelope): Promise { const tenantId = await this.resolveTenantId(env, envelope); if (!tenantId) { try { await this.onUnknownTenant(env, envelope); } catch (e) { console.error('[MultiTenantAgentRegistry] onUnknownTenant threw:', e instanceof Error ? e.message : e); } return null; } return await this.agentFor(env, tenantId); } /** * Full inbound flow: resolve tenant → build Agent → enqueue + schedule * a drain. Returns `{ ok, tenantId, enqueued }` for the caller's * response. * * Use the Hono helper `mountMultiTenantWebhook` instead when possible; * this is the bare-fetch escape hatch. */ async handleEnvelope( env: unknown, envelope: InboundEnvelope, waitUntil: (p: Promise) => void, ): Promise<{ ok: boolean; tenantId: string | null; enqueued: boolean }> { const tenantId = await this.resolveTenantId(env, envelope); if (!tenantId) { try { await this.onUnknownTenant(env, envelope); } catch (e) { console.error('[MultiTenantAgentRegistry] onUnknownTenant threw:', e instanceof Error ? e.message : e); } return { ok: false, tenantId: null, enqueued: false }; } const agent = await this.agentFor(env, tenantId); const enqueued = await agent.enqueue(envelope); if (enqueued) { waitUntil( (async () => { await new Promise((r) => setTimeout(r, agent.queue.debounceSeconds * 1000)); await agent.drain(); })(), ); } return { ok: true, tenantId, enqueued }; } } function defaultOnUnknownTenant(_env: unknown, envelope: InboundEnvelope): void { // `metadata` isn't on the typed envelope shape (Meta sends it but we // don't model it because most code paths don't care). Cast to read it // for diagnostic logging only. const value = envelope?.entry?.[0]?.changes?.[0]?.value as | { metadata?: { phone_number_id?: string } } | undefined; const phoneNumberId = value?.metadata?.phone_number_id; console.warn('[MultiTenantAgentRegistry] unknown tenant for phone_number_id:', phoneNumberId ?? '(missing)'); } // ---- Hono integration ---- 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 MountMultiTenantWebhookOptions { /** * Resolve any tenant whose Agent's `verifyChallenge` should answer the * GET handshake from Meta. Meta's verify token is App-global by default, * so any tenant's Agent suffices — pick the first one you can find. * * Receives the env so KV/D1 lookups work. Return `null` when the app * has no tenants yet (returns 503). */ anyTenantForVerify: (env: unknown) => Promise | string | null; /** * Handle coexistence changes (v0.19) — `smb_message_echoes`, `history`, * `smb_app_state_sync`. Runs after signature verification and before the * ordinary messages are enqueued; coexistence changes are stripped from * what reaches `Agent.enqueue`. * * onCoexistence: async ({ echoes }) => { await handoff.markFromEchoes(echoes); } * * Without this option coexistence changes are dropped (the previous * behavior, since `extractInbound` can't read them). Throws are caught * and logged — Meta retries webhooks it doesn't get a 200 for, and a * failed echo isn't worth replaying the whole envelope. */ onCoexistence?: CoexistenceHandler; } /** * Hono integration — multi-tenant variant of `mountWebhook`. * * mountMultiTenantWebhook(registry, app, '/wa', { * anyTenantForVerify: async (env) => firstTenantId(env), * }); * * - `GET /webhook` resolves *any* onboarded tenant (the verify token is * App-global) and delegates challenge-answer to its Agent. Apps with no * tenants yet get a 503. * - `POST /webhook` resolves the tenant from the envelope, verifies the * signature against THAT tenant's Agent (per-tenant `appSecret`), then * enqueues. Unknown tenants return 404 + fire `onUnknownTenant`. * * **Layer `honoRateLimit` BEFORE this** so unknown-number floods don't burn * tenant-resolution KV lookups. */ export function mountMultiTenantWebhook( registry: MultiTenantAgentRegistry, app: HonoLike, base: string = '/wa', opts: MountMultiTenantWebhookOptions, ): void { const url = (p: string) => `${base}${p}`; app.get(url('/webhook'), async (c) => { const tenantId = await opts.anyTenantForVerify(c.env); if (!tenantId) return c.json({ error: 'no_tenant_configured' }, 503); const agent = await registry.agentFor(c.env, tenantId); 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(); let envelope: InboundEnvelope; try { envelope = JSON.parse(new TextDecoder().decode(raw)); } catch { return c.json({ error: 'bad_json' }, 400); } const tenantId = await registry.resolveTenantId(c.env, envelope); if (!tenantId) { try { await registry.onUnknownTenant(c.env, envelope); } catch (e) { console.error('[MultiTenantAgentRegistry] onUnknownTenant threw:', e instanceof Error ? e.message : e); } return c.json({ error: 'unknown_tenant' }, 404); } const agent = await registry.agentFor(c.env, tenantId); // Signature verification AFTER tenant resolution because appSecret may // be per-tenant. For App-global signing this is the same outcome but // one extra KV lookup; the precedence (rate-limit before lookup) // makes that cost negligible at scale. const valid = await agent.verifySignature(raw, c.req.header('X-Hub-Signature-256')); if (!valid) return c.json({ error: 'Invalid signature' }, 403); // v0.19: coexistence changes can't be enqueued (no contacts+messages // pair), so split them out and hand them to the app. Envelopes without // them take the original path untouched. let toEnqueue: InboundEnvelope | null = envelope; const partitioned = partitionCoexistenceWebhook(envelope); if (partitioned.hasCoexistence) { toEnqueue = partitioned.ordinary; if (opts.onCoexistence) { try { await opts.onCoexistence({ env: c.env, tenantId, changes: partitioned.coexistence, echoes: extractEchoes(partitioned.coexistence), envelope, }); } catch (e) { console.error('[MultiTenantAgentRegistry] 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'); }); }