concierge_worker/
lib.rs

1//! # Concierge
2//!
3//! Messaging automation for small businesses — WhatsApp auto-replies,
4//! Instagram DM auto-replies, and embeddable lead capture forms.
5//!
6//! This is a Cloudflare Worker built with Rust + WebAssembly. It handles:
7//!
8//! - **WhatsApp webhooks** — incoming messages trigger auto-replies (static or AI)
9//! - **Instagram DM webhooks** — same auto-reply pattern via Facebook Pages API
10//! - **Lead capture forms** — embeddable phone number forms that send WhatsApp messages
11//! - **Admin dashboard** — HTMX-powered UI for managing accounts and forms
12//! - **OAuth** — Google and Facebook sign-in with multi-provider account linking
13//!
14//! ## Architecture
15//!
16//! - `types` — Core data structures (Tenant, WhatsAppAccount, InstagramAccount, LeadCaptureForm)
17//! - `storage` — Cloudflare KV and D1 operations
18//! - `ai` — Cloudflare Workers AI integration for auto-reply generation
19//! - `whatsapp` — Meta Graph API client for sending WhatsApp messages
20//! - `instagram` — Facebook Login OAuth and Instagram DM sending
21//! - `crypto` — AES-256-GCM encryption and HMAC-SHA256 verification
22//! - `helpers` — ID generation, HTML escaping, CORS, template interpolation
23
24use worker::*;
25
26mod ai;
27mod crypto;
28mod handlers;
29mod helpers;
30mod instagram;
31mod landing;
32mod scheduled;
33mod storage;
34mod templates;
35mod types;
36mod whatsapp;
37
38pub use types::*;
39
40// Static assets embedded at compile time
41const LOGO_SVG: &str = include_str!("../assets/logo.svg");
42const WEBMANIFEST: &str = include_str!("../assets/site.webmanifest");
43const BROWSERCONFIG: &str = include_str!("../assets/browserconfig.xml");
44const FAVICON_16: &[u8] = include_bytes!("../assets/favicon-16.png");
45const FAVICON_32: &[u8] = include_bytes!("../assets/favicon-32.png");
46const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../assets/apple-touch-icon.png");
47const LOGO_192: &[u8] = include_bytes!("../assets/logo-192.png");
48const LOGO_512: &[u8] = include_bytes!("../assets/logo-512.png");
49const MSTILE_150: &[u8] = include_bytes!("../assets/mstile-150x150.png");
50
51fn serve_text(body: &str, content_type: &str) -> Result<Response> {
52    let headers = Headers::new();
53    headers.set("Content-Type", content_type)?;
54    headers.set("Cache-Control", "public, max-age=31536000")?;
55    Ok(Response::ok(body)?.with_headers(headers))
56}
57
58fn serve_png(body: &[u8]) -> Result<Response> {
59    let headers = Headers::new();
60    headers.set("Content-Type", "image/png")?;
61    headers.set("Cache-Control", "public, max-age=31536000")?;
62    Ok(Response::from_bytes(body.to_vec())?.with_headers(headers))
63}
64
65#[event(fetch)]
66async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
67    console_error_panic_hook::set_once();
68
69    let url = req.url()?;
70    let path = url.path();
71    let method = req.method();
72
73    // Static assets
74    match path {
75        "/logo.svg" => return serve_text(LOGO_SVG, "image/svg+xml"),
76        "/site.webmanifest" => return serve_text(WEBMANIFEST, "application/manifest+json"),
77        "/browserconfig.xml" => return serve_text(BROWSERCONFIG, "application/xml"),
78        "/favicon-16.png" => return serve_png(FAVICON_16),
79        "/favicon-32.png" => return serve_png(FAVICON_32),
80        "/apple-touch-icon.png" => return serve_png(APPLE_TOUCH_ICON),
81        "/logo-192.png" => return serve_png(LOGO_192),
82        "/logo-512.png" => return serve_png(LOGO_512),
83        "/mstile-150x150.png" => return serve_png(MSTILE_150),
84        "/health" => return Response::ok("OK"),
85        _ => {}
86    }
87
88    // Terms of Service
89    if path == "/terms" {
90        let headers = Headers::new();
91        headers.set("Content-Type", "text/html; charset=utf-8")?;
92        headers.set("Cache-Control", "public, max-age=3600")?;
93        return Ok(Response::ok(landing::terms_of_service_html())?.with_headers(headers));
94    }
95
96    // Privacy Policy
97    if path == "/privacy" {
98        let headers = Headers::new();
99        headers.set("Content-Type", "text/html; charset=utf-8")?;
100        headers.set("Cache-Control", "public, max-age=3600")?;
101        return Ok(Response::ok(landing::privacy_policy_html())?.with_headers(headers));
102    }
103
104    // Data deletion callback (Facebook requirement)
105    if path == "/data-deletion" {
106        return handlers::handle_data_deletion(req, env, method).await;
107    }
108
109    // Auth routes (login, callback, logout)
110    if path.starts_with("/auth/") {
111        return handlers::handle_auth(req, env, path, method).await;
112    }
113
114    // WhatsApp Embedded Signup callback
115    if path.starts_with("/whatsapp/signup/") {
116        return handlers::handle_whatsapp_signup(req, env, path, method).await;
117    }
118
119    // Admin routes (session-protected)
120    if path.starts_with("/admin") {
121        return handlers::handle_admin(req, env, path, method).await;
122    }
123
124    // Lead capture form routes (public)
125    if path.starts_with("/lead/") {
126        return handlers::handle_lead_form(req, env, path, method).await;
127    }
128
129    // Instagram OAuth routes
130    if path.starts_with("/instagram/") {
131        return handlers::handle_instagram(req, env, path, method).await;
132    }
133
134    // Webhook routes (WhatsApp + Instagram incoming messages)
135    if path.starts_with("/webhook/") {
136        return handlers::handle_webhook(req, env, path, method).await;
137    }
138
139    // Landing page
140    if path == "/" || path == "/index.html" {
141        let headers = Headers::new();
142        headers.set("Content-Type", "text/html; charset=utf-8")?;
143        headers.set("Cache-Control", "public, max-age=3600")?;
144        return Ok(Response::ok(landing::landing_page_html())?.with_headers(headers));
145    }
146
147    Response::error("Not Found", 404)
148}
149
150#[event(scheduled)]
151async fn scheduled_handler(event: ScheduledEvent, env: Env, ctx: ScheduleContext) {
152    scheduled::handle_scheduled(event, env, ctx).await;
153}