| """ |
| WhatsApp Bot - Powered by Hugging Face π¬ |
| A FastAPI webhook handler for WhatsApp Cloud API with AI-powered responses. |
| """ |
|
|
| import os |
| import json |
| import asyncio |
| import logging |
| from datetime import datetime, timezone |
| from collections import deque |
|
|
| import httpx |
| from contextlib import asynccontextmanager |
| from fastapi import FastAPI, Request, Response, HTTPException |
| from fastapi.responses import PlainTextResponse, HTMLResponse |
|
|
| |
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger("whatsapp-bot") |
|
|
| |
| |
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| logger.info("π WhatsApp Bot starting up...") |
| logger.info(f" Verify Token: {'β
Set' if VERIFY_TOKEN else 'β Not set'}") |
| logger.info(f" WhatsApp Token: {'β
Set' if WHATSAPP_TOKEN else 'β Not set'}") |
| logger.info(f" Phone Number ID: {'β
Set' if PHONE_NUMBER_ID else 'β Not set'}") |
| logger.info(f" HF Token: {'β
Set' if HF_TOKEN else 'β Not set'}") |
| logger.info(f" Webhook URL: https://miftahulkhairim-whatsapp-bot.hf.space/webhook") |
| logger.info("β
Bot is ready to receive messages!") |
| yield |
|
|
| app = FastAPI(title="WhatsApp Bot", version="1.0.0", lifespan=lifespan) |
|
|
| |
| VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN", "") |
| WHATSAPP_TOKEN = os.environ.get("WHATSAPP_TOKEN", "") |
| PHONE_NUMBER_ID = os.environ.get("PHONE_NUMBER_ID", "") |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") |
|
|
| WHATSAPP_API_URL = f"https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages" |
|
|
| |
| message_log = deque(maxlen=100) |
| bot_stats = {"messages_received": 0, "messages_sent": 0, "errors": 0, "started_at": datetime.now(timezone.utc).isoformat()} |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/webhook") |
| async def verify_webhook(request: Request): |
| """ |
| Meta webhook verification endpoint. |
| When you set the webhook URL in Meta Developer Portal, it sends a GET request |
| with hub.mode, hub.verify_token, and hub.challenge to verify ownership. |
| """ |
| params = dict(request.query_params) |
| mode = params.get("hub.mode") |
| token = params.get("hub.verify_token") |
| challenge = params.get("hub.challenge") |
|
|
| logger.info(f"Webhook verification: mode={mode}, token={'***' if token else 'none'}") |
|
|
| if mode == "subscribe" and token == VERIFY_TOKEN: |
| logger.info("β
Webhook verified successfully!") |
| return PlainTextResponse(content=challenge, status_code=200) |
|
|
| logger.warning("β Webhook verification failed") |
| raise HTTPException(status_code=403, detail="Verification failed") |
|
|
|
|
| @app.post("/webhook") |
| async def receive_message(request: Request): |
| """ |
| Receive incoming WhatsApp messages via webhook. |
| CRITICAL: Always return 200 quickly - WhatsApp retries on timeout. |
| Heavy processing (LLM calls) runs in background. |
| """ |
| try: |
| body = await request.json() |
| except Exception: |
| return Response(status_code=200) |
|
|
| |
| try: |
| entry = body["entry"][0] |
| changes = entry["changes"][0] |
| value = changes["value"] |
|
|
| |
| if "messages" not in value: |
| return Response(status_code=200) |
|
|
| message = value["messages"][0] |
| msg_type = message["type"] |
| from_number = message["from"] |
|
|
| |
| contact_name = "Unknown" |
| if "contacts" in value and value["contacts"]: |
| contact_name = value["contacts"][0].get("profile", {}).get("name", "Unknown") |
|
|
| if msg_type == "text": |
| text_body = message["text"]["body"] |
| else: |
| text_body = f"[{msg_type} message - not supported yet]" |
|
|
| logger.info(f"π© Message from {contact_name} ({from_number}): {text_body[:100]}") |
|
|
| bot_stats["messages_received"] += 1 |
| message_log.append({ |
| "timestamp": datetime.now(timezone.utc).isoformat(), |
| "from": from_number, |
| "name": contact_name, |
| "message": text_body, |
| "direction": "incoming" |
| }) |
|
|
| |
| if msg_type == "text": |
| asyncio.create_task(process_and_reply(from_number, contact_name, text_body)) |
|
|
| except (KeyError, IndexError) as e: |
| logger.debug(f"Non-message event received: {e}") |
|
|
| return Response(status_code=200) |
|
|
|
|
| |
| |
| |
|
|
| async def process_and_reply(to_number: str, contact_name: str, user_message: str): |
| """Generate AI reply and send it back via WhatsApp.""" |
| try: |
| reply = await generate_reply(user_message, contact_name) |
| await send_whatsapp_message(to_number, reply) |
|
|
| bot_stats["messages_sent"] += 1 |
| message_log.append({ |
| "timestamp": datetime.now(timezone.utc).isoformat(), |
| "from": "bot", |
| "name": "Bot", |
| "message": reply[:200], |
| "direction": "outgoing" |
| }) |
| logger.info(f"π€ Reply sent to {to_number}: {reply[:100]}") |
|
|
| except Exception as e: |
| bot_stats["errors"] += 1 |
| logger.error(f"β Error processing message: {e}") |
| |
| try: |
| await send_whatsapp_message( |
| to_number, |
| "Sorry, I'm having trouble processing your message right now. Please try again later! π" |
| ) |
| except Exception: |
| logger.error("β Failed to send fallback message") |
|
|
|
|
| async def generate_reply(user_message: str, contact_name: str = "User") -> str: |
| """ |
| Generate an AI response using Hugging Face Inference API. |
| Uses Meta Llama model for high-quality conversational responses. |
| """ |
| if not HF_TOKEN: |
| return "β οΈ Bot is not fully configured yet. Please set up the HF_TOKEN in Space secrets." |
|
|
| try: |
| from huggingface_hub import InferenceClient |
|
|
| client = InferenceClient( |
| provider="hf-inference", |
| api_key=HF_TOKEN, |
| ) |
|
|
| system_prompt = ( |
| "You are a helpful, friendly WhatsApp assistant. " |
| "Keep responses concise and conversational (under 300 words). " |
| "Use emojis occasionally to be friendly. " |
| "If asked about your capabilities, mention you're an AI assistant " |
| "powered by Hugging Face." |
| ) |
|
|
| messages = [ |
| {"role": "system", "content": system_prompt}, |
| {"role": "user", "content": user_message} |
| ] |
|
|
| response = client.chat_completion( |
| model="meta-llama/Llama-3.1-8B-Instruct", |
| messages=messages, |
| max_tokens=512, |
| temperature=0.7, |
| ) |
|
|
| return response.choices[0].message.content |
|
|
| except Exception as e: |
| logger.error(f"LLM error: {e}") |
| return f"I received your message but had trouble generating a response. Error: {str(e)[:100]}" |
|
|
|
|
| async def send_whatsapp_message(to: str, text: str): |
| """Send a text message via WhatsApp Cloud API.""" |
| if not WHATSAPP_TOKEN or not PHONE_NUMBER_ID: |
| logger.warning("WhatsApp credentials not configured - skipping send") |
| return |
|
|
| headers = { |
| "Authorization": f"Bearer {WHATSAPP_TOKEN}", |
| "Content-Type": "application/json", |
| } |
|
|
| |
| chunks = [text[i:i+4000] for i in range(0, len(text), 4000)] |
|
|
| async with httpx.AsyncClient(timeout=30.0) as client: |
| for chunk in chunks: |
| payload = { |
| "messaging_product": "whatsapp", |
| "recipient_type": "individual", |
| "to": to, |
| "type": "text", |
| "text": {"preview_url": False, "body": chunk}, |
| } |
| resp = await client.post(WHATSAPP_API_URL, json=payload, headers=headers) |
| if resp.status_code != 200: |
| logger.error(f"WhatsApp API error: {resp.status_code} - {resp.text}") |
| resp.raise_for_status() |
|
|
|
|
| |
| |
| |
|
|
| DASHBOARD_HTML = """ |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>WhatsApp Bot Dashboard</title> |
| <style> |
| * {{ margin: 0; padding: 0; box-sizing: border-box; }} |
| body {{ |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| background: linear-gradient(135deg, #075e54 0%, #128c7e 50%, #25d366 100%); |
| min-height: 100vh; |
| color: #333; |
| }} |
| .container {{ |
| max-width: 900px; |
| margin: 0 auto; |
| padding: 20px; |
| }} |
| .header {{ |
| text-align: center; |
| color: white; |
| padding: 30px 0; |
| }} |
| .header h1 {{ font-size: 2.5em; margin-bottom: 10px; }} |
| .header p {{ font-size: 1.1em; opacity: 0.9; }} |
| .card {{ |
| background: white; |
| border-radius: 16px; |
| padding: 24px; |
| margin-bottom: 20px; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.15); |
| }} |
| .card h2 {{ |
| color: #075e54; |
| margin-bottom: 16px; |
| font-size: 1.3em; |
| }} |
| .stats-grid {{ |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); |
| gap: 16px; |
| }} |
| .stat-box {{ |
| background: #f0faf5; |
| border-radius: 12px; |
| padding: 20px; |
| text-align: center; |
| border: 1px solid #e0f2e9; |
| }} |
| .stat-box .number {{ |
| font-size: 2em; |
| font-weight: bold; |
| color: #128c7e; |
| }} |
| .stat-box .label {{ |
| color: #666; |
| font-size: 0.9em; |
| margin-top: 4px; |
| }} |
| .status-badge {{ |
| display: inline-block; |
| padding: 6px 16px; |
| border-radius: 20px; |
| font-size: 0.9em; |
| font-weight: 600; |
| }} |
| .status-ok {{ background: #d4edda; color: #155724; }} |
| .status-warn {{ background: #fff3cd; color: #856404; }} |
| .status-err {{ background: #f8d7da; color: #721c24; }} |
| .config-table {{ |
| width: 100%; |
| border-collapse: collapse; |
| }} |
| .config-table td {{ |
| padding: 10px 12px; |
| border-bottom: 1px solid #eee; |
| }} |
| .config-table td:first-child {{ |
| font-weight: 600; |
| color: #555; |
| width: 220px; |
| }} |
| .messages-list {{ |
| max-height: 400px; |
| overflow-y: auto; |
| }} |
| .msg-item {{ |
| padding: 12px; |
| border-bottom: 1px solid #f0f0f0; |
| display: flex; |
| gap: 12px; |
| align-items: flex-start; |
| }} |
| .msg-item:last-child {{ border-bottom: none; }} |
| .msg-icon {{ |
| width: 36px; |
| height: 36px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 1.2em; |
| flex-shrink: 0; |
| }} |
| .msg-incoming .msg-icon {{ background: #dcf8c6; }} |
| .msg-outgoing .msg-icon {{ background: #e3f2fd; }} |
| .msg-text {{ font-size: 0.95em; color: #333; }} |
| .msg-meta {{ font-size: 0.8em; color: #999; margin-top: 2px; }} |
| .empty-state {{ |
| text-align: center; |
| padding: 40px; |
| color: #999; |
| }} |
| .setup-step {{ |
| padding: 12px 0; |
| border-bottom: 1px solid #f0f0f0; |
| display: flex; |
| gap: 12px; |
| }} |
| .setup-step:last-child {{ border-bottom: none; }} |
| .step-num {{ |
| width: 28px; |
| height: 28px; |
| border-radius: 50%; |
| background: #128c7e; |
| color: white; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-weight: bold; |
| font-size: 0.85em; |
| flex-shrink: 0; |
| }} |
| code {{ |
| background: #f4f4f4; |
| padding: 2px 8px; |
| border-radius: 4px; |
| font-size: 0.9em; |
| }} |
| .refresh-btn {{ |
| background: #128c7e; |
| color: white; |
| border: none; |
| padding: 8px 20px; |
| border-radius: 8px; |
| cursor: pointer; |
| font-size: 0.95em; |
| }} |
| .refresh-btn:hover {{ background: #0a6b5e; }} |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="header"> |
| <h1>π¬ WhatsApp Bot</h1> |
| <p>AI-Powered Assistant on Hugging Face</p> |
| </div> |
| |
| <!-- Status Card --> |
| <div class="card"> |
| <h2>π’ Bot Status</h2> |
| <div class="stats-grid"> |
| <div class="stat-box"> |
| <div class="number">{messages_received}</div> |
| <div class="label">Messages Received</div> |
| </div> |
| <div class="stat-box"> |
| <div class="number">{messages_sent}</div> |
| <div class="label">Replies Sent</div> |
| </div> |
| <div class="stat-box"> |
| <div class="number">{errors}</div> |
| <div class="label">Errors</div> |
| </div> |
| </div> |
| </div> |
| |
| <!-- Configuration Card --> |
| <div class="card"> |
| <h2>βοΈ Configuration</h2> |
| <table class="config-table"> |
| <tr> |
| <td>Webhook URL</td> |
| <td><code>https://miftahulkhairim-whatsapp-bot.hf.space/webhook</code></td> |
| </tr> |
| <tr> |
| <td>WhatsApp Verify Token</td> |
| <td><span class="status-badge {verify_status}">{verify_label}</span></td> |
| </tr> |
| <tr> |
| <td>WhatsApp Access Token</td> |
| <td><span class="status-badge {wa_token_status}">{wa_token_label}</span></td> |
| </tr> |
| <tr> |
| <td>Phone Number ID</td> |
| <td><span class="status-badge {phone_status}">{phone_label}</span></td> |
| </tr> |
| <tr> |
| <td>HF Token (AI Inference)</td> |
| <td><span class="status-badge {hf_token_status}">{hf_token_label}</span></td> |
| </tr> |
| <tr> |
| <td>AI Model</td> |
| <td><code>meta-llama/Llama-3.1-8B-Instruct</code></td> |
| </tr> |
| <tr> |
| <td>Running Since</td> |
| <td>{started_at}</td> |
| </tr> |
| </table> |
| </div> |
| |
| <!-- Recent Messages Card --> |
| <div class="card"> |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> |
| <h2>π¨ Recent Messages</h2> |
| <button class="refresh-btn" onclick="location.reload()">π Refresh</button> |
| </div> |
| <div class="messages-list"> |
| {messages_html} |
| </div> |
| </div> |
| |
| <!-- Setup Guide Card --> |
| <div class="card"> |
| <h2>π Quick Setup Guide</h2> |
| <div class="setup-step"> |
| <div class="step-num">1</div> |
| <div>Go to <a href="https://developers.facebook.com" target="_blank">Meta Developer Portal</a> β Create App β Add WhatsApp product</div> |
| </div> |
| <div class="setup-step"> |
| <div class="step-num">2</div> |
| <div>In WhatsApp β Configuration β Set webhook URL to: <code>https://miftahulkhairim-whatsapp-bot.hf.space/webhook</code></div> |
| </div> |
| <div class="setup-step"> |
| <div class="step-num">3</div> |
| <div>Add Space Secrets in Settings: <code>WHATSAPP_VERIFY_TOKEN</code>, <code>WHATSAPP_TOKEN</code>, <code>PHONE_NUMBER_ID</code>, <code>HF_TOKEN</code></div> |
| </div> |
| <div class="setup-step"> |
| <div class="step-num">4</div> |
| <div>Subscribe to <code>messages</code> webhook field in Meta Developer Portal</div> |
| </div> |
| <div class="setup-step"> |
| <div class="step-num">5</div> |
| <div>Send a WhatsApp message to your test number β the bot will reply with AI! π</div> |
| </div> |
| </div> |
| </div> |
| </body> |
| </html> |
| """ |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def dashboard(): |
| """Render the bot dashboard with live stats.""" |
|
|
| def secret_status(val, name): |
| if val: |
| return "status-ok", f"β
Configured" |
| return "status-warn", f"β οΈ Not set β add {name} in Space Secrets" |
|
|
| verify_status, verify_label = secret_status(VERIFY_TOKEN, "WHATSAPP_VERIFY_TOKEN") |
| wa_token_status, wa_token_label = secret_status(WHATSAPP_TOKEN, "WHATSAPP_TOKEN") |
| phone_status, phone_label = secret_status(PHONE_NUMBER_ID, "PHONE_NUMBER_ID") |
| hf_token_status, hf_token_label = secret_status(HF_TOKEN, "HF_TOKEN") |
|
|
| |
| if message_log: |
| msgs_html = "" |
| for msg in reversed(message_log): |
| direction = "msg-incoming" if msg["direction"] == "incoming" else "msg-outgoing" |
| icon = "π©" if msg["direction"] == "incoming" else "π€" |
| name = msg.get("name", "Unknown") |
| text = msg["message"][:300] |
| time_str = msg["timestamp"][:19].replace("T", " ") |
| msgs_html += f''' |
| <div class="msg-item {direction}"> |
| <div class="msg-icon">{icon}</div> |
| <div> |
| <div class="msg-text">{text}</div> |
| <div class="msg-meta">{name} β’ {time_str} UTC</div> |
| </div> |
| </div>''' |
| else: |
| msgs_html = '<div class="empty-state">No messages yet. Send a WhatsApp message to get started! π±</div>' |
|
|
| html = DASHBOARD_HTML.format( |
| messages_received=bot_stats["messages_received"], |
| messages_sent=bot_stats["messages_sent"], |
| errors=bot_stats["errors"], |
| verify_status=verify_status, |
| verify_label=verify_label, |
| wa_token_status=wa_token_status, |
| wa_token_label=wa_token_label, |
| phone_status=phone_status, |
| phone_label=phone_label, |
| hf_token_status=hf_token_status, |
| hf_token_label=hf_token_label, |
| started_at=bot_stats["started_at"][:19].replace("T", " ") + " UTC", |
| messages_html=msgs_html, |
| ) |
| return HTMLResponse(content=html) |
|
|
|
|
| |
| @app.get("/health") |
| async def health(): |
| """Health check endpoint.""" |
| return { |
| "status": "running", |
| "bot": "WhatsApp Bot", |
| "stats": bot_stats, |
| "config": { |
| "verify_token_set": bool(VERIFY_TOKEN), |
| "whatsapp_token_set": bool(WHATSAPP_TOKEN), |
| "phone_number_id_set": bool(PHONE_NUMBER_ID), |
| "hf_token_set": bool(HF_TOKEN), |
| } |
| } |
|
|
|
|
|
|