mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
feat(gateway): add WhatsApp Cloud adapter
This commit is contained in:
parent
a6b2882275
commit
daa123832e
4 changed files with 280 additions and 0 deletions
1
surfsense_backend/app/gateway/whatsapp/__init__.py
Normal file
1
surfsense_backend/app/gateway/whatsapp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""WhatsApp gateway implementations."""
|
||||
149
surfsense_backend/app/gateway/whatsapp/adapter_cloud.py
Normal file
149
surfsense_backend/app/gateway/whatsapp/adapter_cloud.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""WhatsApp Cloud API platform adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.gateway.base.adapter import (
|
||||
BasePlatformAdapter,
|
||||
ParsedInboundEvent,
|
||||
PlatformSendResult,
|
||||
)
|
||||
from app.gateway.whatsapp.client_cloud import WhatsAppCloudClient
|
||||
from app.gateway.whatsapp.credentials import WhatsAppCredentials
|
||||
|
||||
|
||||
class WhatsAppCloudAdapter(BasePlatformAdapter):
|
||||
platform = "whatsapp"
|
||||
|
||||
def __init__(self, credentials: WhatsAppCredentials) -> None:
|
||||
self.credentials = credentials
|
||||
self.client = WhatsAppCloudClient(
|
||||
business_token=credentials["business_token"],
|
||||
phone_number_id=credentials["phone_number_id"],
|
||||
api_version=credentials.get("api_version"),
|
||||
)
|
||||
|
||||
def parse_inbound(self, raw_payload: dict[str, Any]) -> ParsedInboundEvent:
|
||||
message = _first_message(raw_payload)
|
||||
if message is None:
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind="other",
|
||||
external_peer_id=None,
|
||||
external_peer_kind="unknown",
|
||||
external_message_id=None,
|
||||
external_user_id=None,
|
||||
text=None,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
|
||||
contact = _first_contact(raw_payload, message.get("from"))
|
||||
text = _message_text(message)
|
||||
wa_id = str(message.get("from") or "")
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind=str(message.get("type") or "message"),
|
||||
external_peer_id=wa_id or None,
|
||||
external_peer_kind="direct",
|
||||
external_message_id=str(message.get("id")) if message.get("id") else None,
|
||||
external_user_id=wa_id or None,
|
||||
text=text,
|
||||
raw_payload=raw_payload,
|
||||
display_name=(contact.get("profile") or {}).get("name"),
|
||||
username=None,
|
||||
metadata={
|
||||
"phone_number_id": _metadata(raw_payload).get("phone_number_id"),
|
||||
"display_phone_number": _metadata(raw_payload).get("display_phone_number"),
|
||||
"timestamp": message.get("timestamp"),
|
||||
"message_type": message.get("type"),
|
||||
},
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
return await self.client.send_text(
|
||||
to=external_peer_id,
|
||||
text=text,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
external_message_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
raise NotImplementedError("WhatsApp Cloud API does not support message edits")
|
||||
|
||||
async def send_typing_indicator(self, *, inbound_message_id: str) -> None:
|
||||
await self.client.send_typing_indicator(message_id=inbound_message_id)
|
||||
|
||||
async def validate_credentials(self) -> dict[str, Any]:
|
||||
return await self.client.validate()
|
||||
|
||||
|
||||
def _changes(raw_payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
changes: list[dict[str, Any]] = []
|
||||
for entry in raw_payload.get("entry") or []:
|
||||
if isinstance(entry, dict):
|
||||
changes.extend(
|
||||
change for change in (entry.get("changes") or []) if isinstance(change, dict)
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def _first_message(raw_payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
for change in _changes(raw_payload):
|
||||
value = change.get("value") or {}
|
||||
messages = value.get("messages") or []
|
||||
if messages and isinstance(messages[0], dict):
|
||||
return messages[0]
|
||||
if "message" in raw_payload and isinstance(raw_payload["message"], dict):
|
||||
return raw_payload["message"]
|
||||
return None
|
||||
|
||||
|
||||
def _first_contact(
|
||||
raw_payload: dict[str, Any],
|
||||
wa_id: object,
|
||||
) -> dict[str, Any]:
|
||||
for change in _changes(raw_payload):
|
||||
value = change.get("value") or {}
|
||||
for contact in value.get("contacts") or []:
|
||||
if isinstance(contact, dict) and (
|
||||
wa_id is None or str(contact.get("wa_id")) == str(wa_id)
|
||||
):
|
||||
return contact
|
||||
return {}
|
||||
|
||||
|
||||
def _metadata(raw_payload: dict[str, Any]) -> dict[str, Any]:
|
||||
for change in _changes(raw_payload):
|
||||
value = change.get("value") or {}
|
||||
metadata = value.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
return metadata
|
||||
return {}
|
||||
|
||||
|
||||
def _message_text(message: dict[str, Any]) -> str | None:
|
||||
message_type = message.get("type")
|
||||
if message_type == "text":
|
||||
return (message.get("text") or {}).get("body")
|
||||
if message_type == "button":
|
||||
return (message.get("button") or {}).get("text")
|
||||
if message_type == "interactive":
|
||||
interactive = message.get("interactive") or {}
|
||||
button_reply = interactive.get("button_reply") or {}
|
||||
list_reply = interactive.get("list_reply") or {}
|
||||
return button_reply.get("title") or list_reply.get("title")
|
||||
return None
|
||||
99
surfsense_backend/app/gateway/whatsapp/client_cloud.py
Normal file
99
surfsense_backend/app/gateway/whatsapp/client_cloud.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Small httpx wrapper for the WhatsApp Cloud API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import config
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
from app.gateway.ratelimit import wait_for_token
|
||||
from app.observability.metrics import record_gateway_rate_limit_hit
|
||||
|
||||
|
||||
class WhatsAppCloudClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
business_token: str,
|
||||
phone_number_id: str,
|
||||
api_version: str | None = None,
|
||||
) -> None:
|
||||
self.business_token = business_token
|
||||
self.phone_number_id = phone_number_id
|
||||
self.api_version = api_version or config.WHATSAPP_GRAPH_API_VERSION
|
||||
self.base_url = f"https://graph.facebook.com/{self.api_version}"
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
text: str,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
payload: dict[str, Any] = {
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "text",
|
||||
"text": {"preview_url": True, "body": text},
|
||||
}
|
||||
if reply_to_message_id:
|
||||
payload["context"] = {"message_id": reply_to_message_id}
|
||||
data = await self._post(f"/{self.phone_number_id}/messages", json=payload)
|
||||
message_id = str((data.get("messages") or [{}])[0].get("id") or "")
|
||||
return PlatformSendResult(external_message_id=message_id, raw_response=data)
|
||||
|
||||
async def send_typing_indicator(self, *, message_id: str) -> dict[str, Any]:
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"status": "read",
|
||||
"message_id": message_id,
|
||||
"typing_indicator": {"type": "text"},
|
||||
}
|
||||
return await self._post(f"/{self.phone_number_id}/messages", json=payload)
|
||||
|
||||
async def validate(self) -> dict[str, Any]:
|
||||
return await self._get(
|
||||
f"/{self.phone_number_id}",
|
||||
params={
|
||||
"fields": "verified_name,quality_rating,account_review_status,display_phone_number"
|
||||
},
|
||||
)
|
||||
|
||||
async def _post(self, path: str, *, json: dict[str, Any]) -> dict[str, Any]:
|
||||
await self._throttle()
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}{path}",
|
||||
headers={"Authorization": f"Bearer {self.business_token}"},
|
||||
json=json,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _get(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await self._throttle()
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}{path}",
|
||||
headers={"Authorization": f"Bearer {self.business_token}"},
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
wait_ms = await wait_for_token(
|
||||
f"wa:phone:{self.phone_number_id}",
|
||||
capacity=10,
|
||||
refill_per_sec=10.0,
|
||||
)
|
||||
if wait_ms:
|
||||
record_gateway_rate_limit_hit(bucket="wa:phone")
|
||||
31
surfsense_backend/app/gateway/whatsapp/credentials.py
Normal file
31
surfsense_backend/app/gateway/whatsapp/credentials.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Credential helpers for WhatsApp gateway accounts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
from app.config import config
|
||||
|
||||
|
||||
class WhatsAppCredentials(TypedDict, total=False):
|
||||
business_token: str
|
||||
waba_id: str
|
||||
phone_number_id: str
|
||||
business_id: str
|
||||
registration_pin: str
|
||||
api_version: str
|
||||
|
||||
|
||||
def load_system_whatsapp_credentials() -> WhatsAppCredentials:
|
||||
if not (
|
||||
config.WHATSAPP_SHARED_BUSINESS_TOKEN
|
||||
and config.WHATSAPP_SHARED_PHONE_NUMBER_ID
|
||||
):
|
||||
raise RuntimeError("whatsapp_system_credentials_not_configured")
|
||||
|
||||
return {
|
||||
"business_token": config.WHATSAPP_SHARED_BUSINESS_TOKEN,
|
||||
"phone_number_id": config.WHATSAPP_SHARED_PHONE_NUMBER_ID,
|
||||
"waba_id": config.WHATSAPP_SHARED_WABA_ID,
|
||||
"api_version": config.WHATSAPP_GRAPH_API_VERSION,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue