whatsapp-bot / main.py
ChaoticEconomist's picture
Upload 2 files
9616c3d verified
"""
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 ────────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("whatsapp-bot")
# ─── App ────────────────────────────────────────────────────────────────────
# ─── Lifespan (startup) ────────────────────────────────────────────────────
@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)
# ─── Configuration (loaded from HF Space Secrets) ──────────────────────────
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"
# ─── In-memory message log (last 100 messages) ─────────────────────────────
message_log = deque(maxlen=100)
bot_stats = {"messages_received": 0, "messages_sent": 0, "errors": 0, "started_at": datetime.now(timezone.utc).isoformat()}
# ═══════════════════════════════════════════════════════════════════════════
# WEBHOOK ENDPOINTS
# ═══════════════════════════════════════════════════════════════════════════
@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)
# Navigate the WhatsApp Cloud API payload structure
try:
entry = body["entry"][0]
changes = entry["changes"][0]
value = changes["value"]
# Check if this is a message event (not a status update)
if "messages" not in value:
return Response(status_code=200)
message = value["messages"][0]
msg_type = message["type"]
from_number = message["from"]
# Get contact name if available
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"
})
# Process text messages - run LLM in background to respond quickly
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)
# ═══════════════════════════════════════════════════════════════════════════
# MESSAGE PROCESSING
# ═══════════════════════════════════════════════════════════════════════════
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}")
# Send a fallback message
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",
}
# WhatsApp has a ~4096 char limit per message; split if needed
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 UI
# ═══════════════════════════════════════════════════════════════════════════
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")
# Build messages HTML
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)
# ─── Health check API ──────────────────────────────────────────────────────
@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),
}
}