Acytel commited on
Commit
8d003ac
Β·
1 Parent(s): 0964f8b

Sprint 13: Add WhatsApp webhook router and dependencies

Browse files
Files changed (3) hide show
  1. api.py +2 -0
  2. whatsapp/__init__.py +0 -0
  3. whatsapp/webhook.py +142 -0
api.py CHANGED
@@ -15,6 +15,7 @@ from slowapi.util import get_remote_address
15
  from slowapi.errors import RateLimitExceeded
16
  from bhashini import translate_text, LANGUAGE_CODES
17
  from eligibility.engine import check_eligibility
 
18
 
19
  # --- SECURE KEYS ---
20
  SUPABASE_URL = os.environ.get("SUPABASE_URL")
@@ -26,6 +27,7 @@ supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
26
  groq_client = Groq(api_key=GROQ_API_KEY)
27
 
28
  app = FastAPI()
 
29
 
30
  # --- RATE LIMITER CONFIG ---
31
  limiter = Limiter(key_func=get_remote_address)
 
15
  from slowapi.errors import RateLimitExceeded
16
  from bhashini import translate_text, LANGUAGE_CODES
17
  from eligibility.engine import check_eligibility
18
+ from whatsapp.webhook import router as whatsapp_router
19
 
20
  # --- SECURE KEYS ---
21
  SUPABASE_URL = os.environ.get("SUPABASE_URL")
 
27
  groq_client = Groq(api_key=GROQ_API_KEY)
28
 
29
  app = FastAPI()
30
+ app.include_router(whatsapp_router)
31
 
32
  # --- RATE LIMITER CONFIG ---
33
  limiter = Limiter(key_func=get_remote_address)
whatsapp/__init__.py ADDED
File without changes
whatsapp/webhook.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hmac
2
+ import hashlib
3
+ import os
4
+ import httpx
5
+ import datetime
6
+ from fastapi import APIRouter, Request, HTTPException, BackgroundTasks
7
+ from fastapi.responses import PlainTextResponse
8
+ from supabase import create_client
9
+
10
+ router = APIRouter()
11
+
12
+ VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN")
13
+ WA_TOKEN = os.environ.get("WHATSAPP_TOKEN")
14
+ PHONE_NUMBER_ID = os.environ.get("WHATSAPP_PHONE_NUMBER_ID")
15
+ APP_SECRET = os.environ.get("WHATSAPP_APP_SECRET") # Fixed signature secret
16
+
17
+ SUPABASE_URL = os.environ.get("SUPABASE_URL")
18
+ SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
19
+ supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
20
+
21
+ WA_API_URL = f"https://graph.facebook.com/v19.0/{PHONE_NUMBER_ID}/messages"
22
+
23
+ # ── Webhook Verification (GET) ────────────────────────────────────────────────
24
+ @router.get("/webhook/whatsapp")
25
+ async def verify_webhook(request: Request):
26
+ params = request.query_params
27
+ if params.get("hub.mode") == "subscribe" and params.get("hub.verify_token") == VERIFY_TOKEN:
28
+ print("βœ… WhatsApp webhook verified")
29
+ return PlainTextResponse(content=params.get("hub.challenge"))
30
+ raise HTTPException(status_code=403, detail="Verification failed")
31
+
32
+ # ── Incoming Message Handler (POST) ──────────────────────────────────────────
33
+ @router.post("/webhook/whatsapp")
34
+ async def receive_message(request: Request, background_tasks: BackgroundTasks):
35
+ # Verify HMAC signature using APP_SECRET
36
+ signature = request.headers.get("X-Hub-Signature-256", "")
37
+ body = await request.body()
38
+
39
+ expected = "sha256=" + hmac.new(APP_SECRET.encode(), body, hashlib.sha256).hexdigest()
40
+ if not hmac.compare_digest(signature, expected):
41
+ raise HTTPException(status_code=403, detail="Invalid signature")
42
+
43
+ data = await request.json()
44
+ try:
45
+ entry = data["entry"][0]["changes"][0]["value"]
46
+ if "messages" not in entry:
47
+ return {"status": "ok"} # Ignore status updates (read receipts, etc.)
48
+
49
+ message = entry["messages"][0]
50
+ from_number = message["from"]
51
+ msg_type = message["type"]
52
+
53
+ if msg_type == "text":
54
+ query_text = message["text"]["body"]
55
+ background_tasks.add_task(process_and_reply, from_number, query_text, "english")
56
+ elif msg_type == "audio":
57
+ audio_id = message["audio"]["id"]
58
+ background_tasks.add_task(process_voice_and_reply, from_number, audio_id)
59
+
60
+ except (KeyError, IndexError) as e:
61
+ print(f"Webhook parsing error: {e}")
62
+
63
+ return {"status": "ok"}
64
+
65
+ async def send_whatsapp_message(to: str, text: str):
66
+ """Send a text reply via WhatsApp Cloud API."""
67
+ if len(text) > 4096:
68
+ text = text[:4000] + "\n\n_[Reply truncated. Visit govbridge.in for full answer]_"
69
+
70
+ payload = {
71
+ "messaging_product": "whatsapp",
72
+ "to": to,
73
+ "type": "text",
74
+ "text": {"body": text}
75
+ }
76
+ async with httpx.AsyncClient() as client:
77
+ await client.post(
78
+ WA_API_URL, json=payload, headers={"Authorization": f"Bearer {WA_TOKEN}"}
79
+ )
80
+
81
+ def check_and_increment_quota() -> bool:
82
+ """Returns True if within quota, False if limit reached."""
83
+ current_month = datetime.datetime.utcnow().strftime("%Y-%m")
84
+
85
+ # Supabase atomic upsert via RPC or standard select/update
86
+ res = supabase.table("whatsapp_quota").select("conversation_count").eq("month", current_month).execute()
87
+
88
+ if res.data:
89
+ count = res.data[0]["conversation_count"]
90
+ if count >= 950:
91
+ return False
92
+ supabase.table("whatsapp_quota").update({"conversation_count": count + 1}).eq("month", current_month).execute()
93
+ else:
94
+ supabase.table("whatsapp_quota").insert({"month": current_month, "conversation_count": 1}).execute()
95
+
96
+ return True
97
+
98
+ async def process_and_reply(from_number: str, query: str, language: str):
99
+ """Full RAG pipeline for WhatsApp."""
100
+ if not check_and_increment_quota():
101
+ await send_whatsapp_message(from_number, "⚠️ Our WhatsApp service has reached its monthly limit. Please visit govbridge.in for full access.")
102
+ return
103
+
104
+ # TODO: Import your actual run_rag_pipeline function here once refactored
105
+ # from api import run_rag_pipeline
106
+ # answer, sources = await run_rag_pipeline(query, language)
107
+
108
+ # Mocking response until Step 4 refactor is complete
109
+ answer = f"Echoing your RAG query: {query}"
110
+ sources = ["Source 1", "Source 2"]
111
+
112
+ sources_text = ""
113
+ if sources:
114
+ sources_text = "\n\nπŸ“„ *Sources:*\n" + "\n".join([f"β€’ {s}" for s in sources[:3]])
115
+
116
+ reply = f"πŸ›οΈ *GovBridge India*\n\n{answer}{sources_text}"
117
+ await send_whatsapp_message(from_number, reply)
118
+
119
+ async def process_voice_and_reply(from_number: str, audio_id: str):
120
+ """Download voice message and transcribe via Whisper."""
121
+ async with httpx.AsyncClient() as client:
122
+ meta_resp = await client.get(
123
+ f"https://graph.facebook.com/v19.0/{audio_id}",
124
+ headers={"Authorization": f"Bearer {WA_TOKEN}"}
125
+ )
126
+ audio_url = meta_resp.json()["url"]
127
+
128
+ audio_resp = await client.get(audio_url, headers={"Authorization": f"Bearer {WA_TOKEN}"})
129
+ audio_bytes = audio_resp.content
130
+
131
+ transcription_resp = await client.post(
132
+ "https://api.groq.com/openai/v1/audio/transcriptions",
133
+ headers={"Authorization": f"Bearer {os.environ['GROQ_API_KEY']}"},
134
+ files={"file": ("audio.ogg", audio_bytes, "audio/ogg")},
135
+ data={"model": "whisper-large-v3-turbo", "language": "hi"}
136
+ )
137
+ transcript = transcription_resp.json().get("text", "")
138
+
139
+ if transcript:
140
+ await process_and_reply(from_number, transcript, "hindi")
141
+ else:
142
+ await send_whatsapp_message(from_number, "πŸ™ Sorry, I couldn't understand the voice message. Please type your question.")