Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84021bc14 | ||
|
|
cf55576d10 | ||
|
|
4afdc36765 | ||
|
|
3920cf14a1 |
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
|
||||
# ── CORS ─────────────────────────────────────────────────────────
|
||||
CORS_ORIGINS: str = "*"
|
||||
|
||||
# ── WhatsApp ─────────────────────────────────────────────────────
|
||||
WHATSAPP_PHONE_NUMBER_ID: str = ""
|
||||
WHATSAPP_ACCESS_TOKEN: str = ""
|
||||
WHATSAPP_VERIFY_TOKEN: str = ""
|
||||
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────────────
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""WhatsApp log model for mock endpoint."""
|
||||
"""WhatsApp log model for inbound webhook messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
|
||||
__tablename__ = "whatsapp_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
from_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
message_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
|
||||
return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>"
|
||||
|
||||
+139
-18
@@ -1,44 +1,165 @@
|
||||
"""Mock WhatsApp endpoint for testing command parsing."""
|
||||
"""WhatsApp webhook handler — Meta Graph API integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.whatsapp_log import WhatsAppLog
|
||||
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
|
||||
from app.schemas.whatsapp import (
|
||||
MetaWebhookRequest,
|
||||
MockWhatsAppLogEntry,
|
||||
WebhookVerificationResponse,
|
||||
WhatsAppReplyResponse,
|
||||
)
|
||||
from app.services.ticket import create_ticket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
|
||||
|
||||
REPLY_TEMPLATE = (
|
||||
"Thank you for contacting Denya OneCare. "
|
||||
"Your ticket number is {ticket_number}. "
|
||||
"We will get back to you soon."
|
||||
)
|
||||
|
||||
@router.post("/mock", response_model=MockWhatsAppResponse)
|
||||
async def mock_whatsapp(
|
||||
body: MockWhatsAppRequest,
|
||||
|
||||
# ── Meta Graph API helpers ──────────────────────────────────────────
|
||||
async def send_whatsapp_reply(
|
||||
to_phone: str,
|
||||
text: str,
|
||||
) -> WhatsAppReplyResponse:
|
||||
"""Send a text message via Meta Graph API."""
|
||||
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
data = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to_phone,
|
||||
"type": "text",
|
||||
"text": {"body": text},
|
||||
}
|
||||
# Encode nested dict as JSON string for form data (Meta requirement)
|
||||
data["text"] = '{"body":' + f'"{text}"' + "}"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, data=data, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
return WhatsAppReplyResponse(success=True, message="Reply sent")
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error("Failed to send WhatsApp reply: %s", exc)
|
||||
return WhatsAppReplyResponse(success=False, message=str(exc))
|
||||
|
||||
|
||||
# ── Webhook endpoint ────────────────────────────────────────────────
|
||||
@router.post("/webhook")
|
||||
async def whatsapp_webhook(
|
||||
body: MetaWebhookRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> MockWhatsAppResponse:
|
||||
"""Accept a mock WhatsApp command and log it."""
|
||||
log = WhatsAppLog(
|
||||
command=body.command,
|
||||
from_number=body.from_number,
|
||||
ticket_id=body.ticket_id,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
return MockWhatsAppResponse()
|
||||
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
|
||||
mode: str | None = Query(None),
|
||||
hub_challenge: str | None = Query(None),
|
||||
) -> dict:
|
||||
"""Handle incoming WhatsApp webhook from Meta."""
|
||||
|
||||
# ── Verification GET request (Meta sends this on webhook setup) ──
|
||||
if mode and hub_challenge:
|
||||
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
|
||||
return {"error": "Verify token mismatch"}
|
||||
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
|
||||
|
||||
# ── Process inbound messages ────────────────────────────────────
|
||||
if not body.entry:
|
||||
return {"status": "no entry"}
|
||||
|
||||
for entry in body.entry:
|
||||
if not entry.changes:
|
||||
continue
|
||||
|
||||
for change in entry.changes:
|
||||
if not change.message or not change.message.text:
|
||||
logger.info("Non-text message received, skipping")
|
||||
continue
|
||||
|
||||
message_text = change.message.text.text
|
||||
sender = change.message.from_field
|
||||
wa_msg_id = change.message.id or change.id
|
||||
|
||||
# ── Create ticket ──────────────────────────────────────
|
||||
try:
|
||||
ticket = await create_ticket(
|
||||
db,
|
||||
data={
|
||||
"description": message_text,
|
||||
"reporter": sender,
|
||||
"reported_via": "WhatsApp",
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to create ticket: %s", exc)
|
||||
# Still log the message even if ticket creation fails
|
||||
log = WhatsAppLog(
|
||||
from_number=sender,
|
||||
message_text=message_text,
|
||||
wa_message_id=wa_msg_id,
|
||||
ticket_id=None,
|
||||
ticket_number=None,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
return {"status": "ticket creation failed, message logged"}
|
||||
|
||||
# ── Store WhatsApp log ─────────────────────────────────
|
||||
log = WhatsAppLog(
|
||||
from_number=sender,
|
||||
message_text=message_text,
|
||||
wa_message_id=wa_msg_id,
|
||||
ticket_id=ticket.id,
|
||||
ticket_number=ticket.ticket_number,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
|
||||
# ── Send auto-reply ────────────────────────────────────
|
||||
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
|
||||
reply_result = await send_whatsapp_reply(sender, reply_text)
|
||||
if not reply_result.success:
|
||||
logger.warning(
|
||||
"Auto-reply failed for ticket %s: %s",
|
||||
ticket.ticket_number,
|
||||
reply_result.message,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "processed",
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_number": ticket.ticket_number,
|
||||
"reply_sent": reply_result.success,
|
||||
}
|
||||
|
||||
return {"status": "no messages"}
|
||||
|
||||
|
||||
# ── Legacy debug endpoint ──────────────────────────────────────────
|
||||
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
|
||||
async def mock_whatsapp_log(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
limit: int = 50,
|
||||
) -> list[MockWhatsAppLogEntry]:
|
||||
"""Return recent mock WhatsApp submissions."""
|
||||
"""Return recent WhatsApp webhook submissions for debugging."""
|
||||
result = await db.execute(
|
||||
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
|
||||
)
|
||||
|
||||
+61
-12
@@ -1,10 +1,69 @@
|
||||
"""Pydantic schemas for mock WhatsApp endpoint."""
|
||||
"""Pydantic schemas for Meta WhatsApp webhook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Meta Webhook Verification ────────────────────────────────────────
|
||||
class WebhookVerificationResponse(BaseModel):
|
||||
challenge: str
|
||||
|
||||
|
||||
# ── Inbound Message Parsing ─────────────────────────────────────────
|
||||
class MessageText(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
from_field: str = Field(alias="from")
|
||||
id: str | None = None
|
||||
text: MessageText | None = None
|
||||
type: str | None = None
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
wa_id: str | None = None
|
||||
profile: dict | None = None
|
||||
|
||||
|
||||
class EntryMessagesItem(BaseModel):
|
||||
id: str | None = None
|
||||
message: Message | None = None
|
||||
contacts: list[Contact] | None = None
|
||||
timestamp: str | None = None
|
||||
|
||||
|
||||
class Entry(BaseModel):
|
||||
id: str | None = None
|
||||
changes: list[EntryMessagesItem] | None = None
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class MetaWebhookRequest(BaseModel):
|
||||
object: str | None = None
|
||||
entry: list[Entry] | None = None
|
||||
|
||||
|
||||
# ── Auto-reply ──────────────────────────────────────────────────────
|
||||
class WhatsAppReplyResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ── Legacy mock schemas (kept for /mock-log endpoint) ───────────────
|
||||
class MockWhatsAppLogEntry(BaseModel):
|
||||
id: int
|
||||
from_number: str
|
||||
message_text: str
|
||||
wa_message_id: str | None
|
||||
ticket_id: int | None
|
||||
ticket_number: str | None
|
||||
received_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MockWhatsAppRequest(BaseModel):
|
||||
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
|
||||
class MockWhatsAppResponse(BaseModel):
|
||||
status: str = "received"
|
||||
message: str = "Command logged successfully"
|
||||
|
||||
|
||||
class MockWhatsAppLogEntry(BaseModel):
|
||||
id: int
|
||||
command: str
|
||||
from_number: str | None
|
||||
ticket_id: int | None
|
||||
received_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
Reference in New Issue
Block a user