import os, json from fastapi import FastAPI, Request, HTTPException, Query from fastapi.responses import PlainTextResponse, JSONResponse import httpx import base64 import asyncio app = FastAPI() VERIFY_TOKEN = os.getenv("VERIFY_TOKEN", "") # set in HF Secrets PHONE_NUMBER_ID = os.getenv("PHONE_NUMBER_ID", "") WHATSAPP_API_TOKEN = os.getenv("WHATSAPP_API_TOKEN", "") CLIENT_API_URL = os.getenv("CLIENT_API_URL", "") # Add task tracking at the top active_tasks = {} # Persistent deduplication using a file DEDUP_FILE = "/tmp/processed_message_ids.txt" def load_processed_ids(): try: with open(DEDUP_FILE, "r") as f: return set(line.strip() for line in f if line.strip()) except FileNotFoundError: return set() def save_processed_id(msg_id): try: with open(DEDUP_FILE, "a") as f: f.write(msg_id + "\n") except Exception as e: print(f"Error saving msg_id {msg_id}: {e}") processed_message_ids = load_processed_ids() async def forward_to_client(b64_str: str, wa_id: str, task_id: str): """Fire-and-forget call to CLIENT /api/process_id.""" payload = { "data": [b64_str], "configurations": { "iddetectionconfidence": "80", "smartidconfidence": "50", "greenidconfidence": "50", "binaryString": "1111111011110011" } } short = b64_str[:40] # first 40 chars for reference try: print(f"[CLIENT] ➜ sending image ({len(b64_str)} bytes) " f"preview='{short}…'") print("Payload to CLIENT:", json.dumps(payload)[:300], "…") async with httpx.AsyncClient(timeout=60) as client: resp = await client.post(CLIENT_API_URL, json=payload) resp.raise_for_status() # ── log the full JSON response (truncated to 300 chars for brevity) ── data = resp.json() print(f"[CLIENT] ✔ 200 {json.dumps(data)[:300]}…") body = data print(f"[CLIENT] ✔ {resp.status_code}") pretty_print_dict(body) # ── send parsed ID back to WhatsApp or error message ── if wa_id and task_id in active_tasks: if data.get("status") == "success": print(f"[TASK] Sending response for active task {task_id}") det = data["results"][0]["detections"][0] reply = ( "✅ *ID processed*\n" f"*Name*: {det.get('name','?')} {det.get('surname','')}\n" f"*ID No.*: {det.get('id_num','?')}\n" f"*DOB*: {det.get('date_of_birth','?')}\n" f"*Gender*: {det.get('gender','?')}\n" f"*ID Type*: {det.get('id_type','?')}\n" f"*Country of Birth*: {det.get('cob','?')}\n" f"*Citizen*: {det.get('citizen','?')}\n" f"*Citizen Status*: {det.get('citizen_status','?')}\n" f"*Date Issued*: {det.get('date_issued','?')}\n" f"*Age*: {det.get('age','?')}" ) send_whatsapp_message(wa_id, reply) print(f"[TASK] Completed and removed task {task_id}") elif "error" in data: error_msg = data["error"] print(f"[TASK] CLIENT error for task {task_id}: {error_msg}") send_whatsapp_message(wa_id, f"❌ {error_msg}") else: print(f"[TASK] CLIENT response not successful for task {task_id}") send_whatsapp_message(wa_id, "❌ Sorry, we could not process your ID. Please try again with a clearer image.") # Remove task from active list active_tasks.pop(task_id, None) elif task_id not in active_tasks: print(f"[TASK] Ignoring response for inactive task {task_id}") except Exception as e: print(f"[CLIENT] ✘ error while sending '{short}…' →", e) # Remove task from active list on error active_tasks.pop(task_id, None) def pretty_print_dict(d, indent=0): for k, v in d.items(): if isinstance(v, dict): print(' ' * indent + f"{k}:") pretty_print_dict(v, indent + 2) elif isinstance(v, list): print(' ' * indent + f"{k}: [") for item in v: if isinstance(item, dict): pretty_print_dict(item, indent + 2) else: print(' ' * (indent + 2) + str(item)) print(' ' * indent + "]") else: print(' ' * indent + f"{k}: {v}") @app.get("/webhook") # verification def verify( hub_mode: str = Query(None, alias="hub.mode"), hub_token: str = Query(None, alias="hub.verify_token"), hub_challenge: str = Query(None, alias="hub.challenge") ): if hub_mode == "subscribe" and hub_token == VERIFY_TOKEN: return PlainTextResponse(hub_challenge) raise HTTPException(status_code=403, detail="Invalid token") def send_whatsapp_message(wa_id, reply): url = f"https://graph.facebook.com/v23.0/{PHONE_NUMBER_ID}/messages" headers = { "Authorization": f"Bearer {WHATSAPP_API_TOKEN}", "Content-Type": "application/json" } payload = { "messaging_product": "whatsapp", "to": wa_id, "type": "text", "text": {"body": reply} } httpx.post(url, headers=headers, json=payload) def send_welcome_buttons(wa_id: str): """Send an in-session Interactive button message with 2 reply options.""" url = f"https://graph.facebook.com/v23.0/{PHONE_NUMBER_ID}/messages" headers = { "Authorization": f"Bearer {WHATSAPP_API_TOKEN}", "Content-Type": "application/json", } payload = { "messaging_product": "whatsapp", "to": wa_id, "type": "interactive", "interactive": { "type": "button", "body": { "text": "Welcome to CLIENT.\nPlease send an image of your South African ID document." }, "action": { "buttons": [ { "type": "reply", "reply": {"id": "open_gallery", "title": "📎"} }, { "type": "reply", "reply": {"id": "open_camera", "title": "📷"} } ] } } } resp = httpx.post(url, headers=headers, json=payload) # debug log print(f"[BUTTON] POST → {resp.status_code}, body: {resp.text}") @app.post("/webhook") # incoming WhatsApp updates async def receive_update(req: Request): data = await req.json() try: change = data["entry"][0]["changes"][0]["value"] contacts = change.get("contacts") messages = change.get("messages") if not contacts or not messages: # Missing expected payload—ignore print("Ignoring update, no contacts or messages field") return JSONResponse({"status": "ignored", "reason": "no contacts/messages"}) wa_id = contacts[0]["wa_id"] msg = messages[0] except (KeyError, IndexError, TypeError) as e: print("Malformed webhook payload:", e) return JSONResponse({"status": "ignored", "reason": "malformed payload"}) msg_id = msg.get("id") if msg_id in processed_message_ids: print(f"Duplicate message {msg_id}, skipping.") return JSONResponse({"status": "duplicate", "id": msg_id}) processed_message_ids.add(msg_id) save_processed_id(msg_id) # Save to file # TEXT: send welcome message if msg.get("type") == "text": send_whatsapp_message( wa_id, "Welcome to CLIENT! 📋\n\n" "Please send an image of your South African ID document for processing." ) return JSONResponse({"status": "welcome_sent"}) # IMAGE: if user sent an image, convert to Base64 preview if msg["type"] == "image": media_id = msg["image"]["id"] # 1) get media URL media_resp = httpx.get( f"https://graph.facebook.com/v23.0/{media_id}", headers={"Authorization": f"Bearer {WHATSAPP_API_TOKEN}"} ) media_resp.raise_for_status() media_url = media_resp.json()["url"] # 2) download bytes with auth media_bytes = httpx.get( media_url, headers={"Authorization": f"Bearer {WHATSAPP_API_TOKEN}"} ).content # 3) encode full & preview full_b64 = base64.b64encode(media_bytes).decode("utf-8") preview = full_b64[:10] # 4) forward full string to CLIENT (non-blocking) task_id = str(msg_id) # Use msg_id as task_id active_tasks[task_id] = True # Add task to active list print(f"[TASK] Created task {task_id} for message {msg_id}") asyncio.create_task(forward_to_client(full_b64, wa_id, task_id)) # 5) echo preview to the user return JSONResponse({"status": "image_preview_sent", "preview": preview}) # OTHER: ignore return JSONResponse({"status": "ignored", "type": msg["type"]}) # health-check endpoint for token @app.post("/send") async def check_token(): token = os.getenv("WHATSAPP_API_TOKEN", "") if token: return {"token_loaded": True} raise HTTPException(status_code=400, detail="Token not found") @app.get("/ping") # health check def ping(): return {"pong": True}