// WAIA Connect → Supabase Edge Function // ───────────────────────────────────────────────────────────────────────────── // Receives WAIA Connect webhooks, verifies the signature, stores every WhatsApp // message in Postgres (once), and optionally auto-replies to incoming text. // // Deploy WITHOUT Supabase JWT verification — Connect does not send a Supabase token: // supabase functions deploy connect-webhook --no-verify-jwt // (or `verify_jwt = false` under [functions.connect-webhook] in supabase/config.toml) // The HMAC signature below is what proves the request comes from Connect. // // Secrets (supabase secrets set NAME=value): // CONNECT_WEBHOOK_SECRET whsec_… (Connect panel → Webhooks → your endpoint; shown once) // CONNECT_API_KEY wc_live_… (only needed to auto-reply) // AUTO_REPLY_TEXT optional; empty = store only, never reply // CONNECT_API_BASE optional; default https://api.waiaconnect.com // SUPABASE_URL and the secret key (SUPABASE_SECRET_KEYS, or the legacy SUPABASE_SERVICE_ROLE_KEY) // are provided by Supabase automatically — you don't set them. // // Tables: run migration.sql first (whatsapp_messages, whatsapp_errors, RLS on). // If you ask an AI to change this file, ask it to touch ONLY `buildReply`. import { createClient } from "npm:@supabase/supabase-js@2"; const MAX_SKEW_SECONDS = 300; // Connect puts the timestamp inside the HMAC: reject replays. const enc = new TextEncoder(); // ── Your reply logic (the only part you should need to change) ─────────────── // Return the text to send back, or null to send nothing. function buildReply(text: string, contactName: string | null): string | null { const fixed = (Deno.env.get("AUTO_REPLY_TEXT") ?? "").trim(); if (!fixed) return null; void text; void contactName; return fixed; } // ── Signature: sha256=HMAC_SHA256(secret, `${timestamp}.${rawBody}`) ─────────── async function hmacHex(secret: string, data: string): Promise { const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(data))); return Array.from(sig, b => b.toString(16).padStart(2, "0")).join(""); } // Constant time: the loop always walks the longer string, whatever matches. function safeEqual(a: string, b: string): boolean { const x = enc.encode(a); const y = enc.encode(b); let diff = x.length ^ y.length; for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0); return diff === 0; } async function verify(req: Request, raw: string): Promise { const secret = Deno.env.get("CONNECT_WEBHOOK_SECRET") ?? ""; if (!secret) return "CONNECT_WEBHOOK_SECRET is not set"; const got = req.headers.get("x-connect-signature-256") ?? ""; const ts = Number(req.headers.get("x-connect-timestamp")); if (!got || !Number.isFinite(ts)) return "missing signature headers"; if (Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) return "timestamp outside the 5-minute window"; const want = "sha256=" + (await hmacHex(secret, `${ts}.${raw}`)); return safeEqual(got, want) ? null : "bad signature"; } async function sha256Hex(s: string): Promise { const d = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(s))); return Array.from(d, b => b.toString(16).padStart(2, "0")).join(""); } // ── Postgres (secret key: bypasses RLS; the tables have no public policies) ── // New projects: SUPABASE_SECRET_KEYS is a JSON dictionary ({"default": "sb_secret_…"}). // Older projects: SUPABASE_SERVICE_ROLE_KEY (Supabase is retiring it by the end of 2026). function secretKey(): string { try { const keys = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS") ?? "{}"); if (typeof keys?.default === "string" && keys.default) return keys.default; } catch { /* not set or not JSON */ } return Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; } const db = () => createClient(Deno.env.get("SUPABASE_URL")!, secretKey(), { auth: { persistSession: false } }); // deno-lint-ignore no-explicit-any type Json = any; async function logError(kind: string, detail: string, extra: Record = {}) { const { error } = await db().from("whatsapp_errors").insert({ kind, detail: detail.slice(0, 1000), ...extra }); if (error) console.error("[connect] could not log error:", error.message, "|", kind, detail); } // Stores the message ONCE, keyed by the WhatsApp message id (unique index on wamid). // Returns true only the first time — a Connect retry or a Meta re-delivery returns false. async function storeOnce(row: Record): Promise { const { data, error } = await db() .from("whatsapp_messages") .upsert(row, { onConflict: "wamid", ignoreDuplicates: true }) .select("id"); if (error) throw new Error("store failed: " + error.message); return Array.isArray(data) && data.length === 1; } async function reply(env: Json, to: string, text: string, wamid: string) { const key = Deno.env.get("CONNECT_API_KEY") ?? ""; if (!key) return logError("config", "CONNECT_API_KEY is not set", { wamid }); const base = (Deno.env.get("CONNECT_API_BASE") ?? "https://api.waiaconnect.com").replace(/\/+$/, ""); const res = await fetch(`${base}/v1/messages`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", // Same key for the same incoming message: Connect never sends the reply twice. // (A hash of the wamid: the raw wamid carries the contact's number inside.) "Idempotency-Key": "supabase-reply-" + (await sha256Hex(wamid)).slice(0, 40) }, body: JSON.stringify({ connectionId: env?.connection?.id, to, type: "text", text: { body: text } }) }); const body = await res.text(); if (!res.ok) { let code = String(res.status); try { code = JSON.parse(body)?.error?.code ?? code; } catch { /* not JSON */ } return logError("reply_rejected", `Connect API ${res.status} ${code}`, { wamid, code }); } let id: string | null = null; try { id = JSON.parse(body)?.id ?? null; } catch { /* ignore */ } await db().from("whatsapp_messages").update({ replied_at: new Date().toISOString(), reply_message_id: id }).eq("wamid", wamid); } // ── The work, done AFTER answering 200 ──────────────────────────────────────── async function handle(env: Json) { const type = String(env?.type ?? ""); const data = env?.data ?? {}; if (type === "message.received" || type === "message.echo") { const m = data.message ?? {}; const wamid = String(m.id ?? ""); if (!wamid) return logError("bad_event", "message without id", { event_id: env?.id ?? null }); const echo = type === "message.echo"; const contact = Array.isArray(data.contacts) ? data.contacts[0] : null; const contactName = contact?.profile?.name ?? null; const text = m.type === "text" ? String(m.text?.body ?? "") : null; const first = await storeOnce({ wamid, event_id: env?.id ?? null, connection_id: env?.connection?.id ?? null, direction: echo ? "out" : "in", contact: echo ? String(m.to ?? "") : String(m.from ?? ""), contact_name: echo ? null : contactName, type: String(m.type ?? "unknown"), text, sent_at: m.timestamp ? new Date(Number(m.timestamp) * 1000).toISOString() : null }); if (!first) return; // already processed: no second row, no second reply if (echo || text === null) return; // never reply to an echo (you'd talk to yourself), nor to media const out = buildReply(text, contactName); if (out) await reply(env, String(m.from), out, wamid); return; } if (type === "message.status" && data.status === "failed") { const err = Array.isArray(data.errors) ? data.errors[0] : null; // stage "send": Connect's call to Meta was rejected — the message never left. // No stage: Meta accepted it and reported later that it wasn't delivered. const { error } = await db() .from("whatsapp_errors") .upsert( { kind: data.stage === "send" ? "send_failed" : "delivery_failed", message_id: String(data.messageId ?? ""), code: data.failureCode ?? (err?.code != null ? String(err.code) : null), detail: String(err?.title ?? "failed").slice(0, 1000) }, { onConflict: "kind,message_id", ignoreDuplicates: true } ); if (error) console.error("[connect] could not store failed status:", error.message); } // Anything else (webhook.test, sent/delivered/read, account events): nothing to do. } Deno.serve(async req => { if (req.method !== "POST") return new Response("method not allowed", { status: 405 }); const raw = await req.text(); // the exact bytes Connect signed const bad = await verify(req, raw); if (bad) return new Response(bad, { status: 401 }); let env: Json; try { env = JSON.parse(raw); } catch { return new Response("invalid JSON", { status: 400 }); } // Answer 200 now; do the rest in the background (Connect waits 10 s, then retries). const work = handle(env).catch(e => logError("handler", String(e?.message ?? e), { event_id: env?.id ?? null })); // deno-lint-ignore no-explicit-any const rt = (globalThis as any).EdgeRuntime; if (rt?.waitUntil) rt.waitUntil(work); else await work; // plain Deno (local tests): just finish before answering return new Response("ok", { status: 200 }); });