225 lines
8.2 KiB
Python
225 lines
8.2 KiB
Python
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from .config import settings
|
|
|
|
|
|
class WhatsAppGraphError(Exception):
|
|
def __init__(self, detail: str, status_code: int = 502) -> None:
|
|
super().__init__(detail)
|
|
self.detail = detail
|
|
self.status_code = status_code
|
|
|
|
|
|
class WhatsAppGraphClient:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
access_token: str | None = None,
|
|
phone_number_id: str | None = None,
|
|
business_account_id: str | None = None,
|
|
api_version: str | None = None,
|
|
) -> None:
|
|
self.access_token = access_token or settings.whatsapp_access_token
|
|
self.phone_number_id = phone_number_id or settings.whatsapp_phone_number_id
|
|
self.business_account_id = business_account_id or settings.whatsapp_business_account_id
|
|
self.api_version = api_version or settings.whatsapp_api_version
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return bool(self.access_token and self.phone_number_id)
|
|
|
|
def _graph_url(self, path: str) -> str:
|
|
return f"https://graph.facebook.com/{self.api_version}/{path.lstrip('/')}"
|
|
|
|
def _messages_url(self) -> str:
|
|
return self._graph_url(f"{self.phone_number_id}/messages")
|
|
|
|
async def _request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
json_payload: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
headers = {"Authorization": f"Bearer {self.access_token}"}
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
response = await client.request(method, url, json=json_payload, headers=headers)
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
data = {"raw": response.text}
|
|
if response.status_code >= 400:
|
|
detail = data.get("error", {}).get("message") if isinstance(data, dict) else response.text
|
|
raise WhatsAppGraphError(detail or "WhatsApp Graph API request failed", status_code=response.status_code)
|
|
return data if isinstance(data, dict) else {"data": data}
|
|
|
|
async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
if not self.configured:
|
|
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
|
|
body = {"messaging_product": "whatsapp", **payload}
|
|
return await self._request("POST", self._messages_url(), json_payload=body)
|
|
|
|
async def send_text(self, to: str, body: str, reply_to: str | None = None) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"recipient_type": "individual",
|
|
"to": to,
|
|
"type": "text",
|
|
"text": {"preview_url": False, "body": body},
|
|
}
|
|
if reply_to:
|
|
payload["context"] = {"message_id": reply_to}
|
|
return await self._send_message(payload)
|
|
|
|
async def send_media(
|
|
self,
|
|
to: str,
|
|
media_type: str,
|
|
*,
|
|
link: str | None = None,
|
|
media_id: str | None = None,
|
|
caption: str | None = None,
|
|
filename: str | None = None,
|
|
reply_to: str | None = None,
|
|
) -> dict[str, Any]:
|
|
media: dict[str, Any] = {}
|
|
if media_id:
|
|
media["id"] = media_id
|
|
elif link:
|
|
media["link"] = link
|
|
else:
|
|
raise WhatsAppGraphError("Media requires link or media_id", status_code=422)
|
|
if caption and media_type in {"image", "document", "video"}:
|
|
media["caption"] = caption
|
|
if filename and media_type == "document":
|
|
media["filename"] = filename
|
|
payload: dict[str, Any] = {
|
|
"recipient_type": "individual",
|
|
"to": to,
|
|
"type": media_type,
|
|
media_type: media,
|
|
}
|
|
if reply_to:
|
|
payload["context"] = {"message_id": reply_to}
|
|
return await self._send_message(payload)
|
|
|
|
async def send_template(
|
|
self,
|
|
to: str,
|
|
name: str,
|
|
language_code: str = "en",
|
|
components: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
template: dict[str, Any] = {"name": name, "language": {"code": language_code}}
|
|
if components:
|
|
template["components"] = components
|
|
return await self._send_message(
|
|
{
|
|
"recipient_type": "individual",
|
|
"to": to,
|
|
"type": "template",
|
|
"template": template,
|
|
}
|
|
)
|
|
|
|
async def send_interactive_buttons(
|
|
self,
|
|
to: str,
|
|
body: str,
|
|
buttons: list[dict[str, str]],
|
|
*,
|
|
header: str | None = None,
|
|
footer: str | None = None,
|
|
reply_to: str | None = None,
|
|
) -> dict[str, Any]:
|
|
interactive: dict[str, Any] = {
|
|
"type": "button",
|
|
"body": {"text": body},
|
|
"action": {
|
|
"buttons": [
|
|
{"type": "reply", "reply": {"id": button["id"], "title": button["title"][:20]}}
|
|
for button in buttons[:3]
|
|
]
|
|
},
|
|
}
|
|
if header:
|
|
interactive["header"] = {"type": "text", "text": header[:60]}
|
|
if footer:
|
|
interactive["footer"] = {"text": footer[:60]}
|
|
payload: dict[str, Any] = {
|
|
"recipient_type": "individual",
|
|
"to": to,
|
|
"type": "interactive",
|
|
"interactive": interactive,
|
|
}
|
|
if reply_to:
|
|
payload["context"] = {"message_id": reply_to}
|
|
return await self._send_message(payload)
|
|
|
|
async def send_interactive_list(
|
|
self,
|
|
to: str,
|
|
body: str,
|
|
button_text: str,
|
|
sections: list[dict[str, Any]],
|
|
*,
|
|
header: str | None = None,
|
|
footer: str | None = None,
|
|
reply_to: str | None = None,
|
|
) -> dict[str, Any]:
|
|
interactive: dict[str, Any] = {
|
|
"type": "list",
|
|
"body": {"text": body},
|
|
"action": {"button": button_text[:20], "sections": sections},
|
|
}
|
|
if header:
|
|
interactive["header"] = {"type": "text", "text": header[:60]}
|
|
if footer:
|
|
interactive["footer"] = {"text": footer[:60]}
|
|
payload: dict[str, Any] = {
|
|
"recipient_type": "individual",
|
|
"to": to,
|
|
"type": "interactive",
|
|
"interactive": interactive,
|
|
}
|
|
if reply_to:
|
|
payload["context"] = {"message_id": reply_to}
|
|
return await self._send_message(payload)
|
|
|
|
async def send_reaction(self, to: str, message_id: str, emoji: str) -> dict[str, Any]:
|
|
return await self._send_message(
|
|
{
|
|
"recipient_type": "individual",
|
|
"to": to,
|
|
"type": "reaction",
|
|
"reaction": {"message_id": message_id, "emoji": emoji},
|
|
}
|
|
)
|
|
|
|
async def mark_read(self, message_id: str) -> dict[str, Any]:
|
|
return await self._send_message({"status": "read", "message_id": message_id})
|
|
|
|
async def list_templates(self, limit: int = 50) -> dict[str, Any]:
|
|
if not self.access_token or not self.business_account_id:
|
|
raise WhatsAppGraphError("WhatsApp business account ID is not configured", status_code=503)
|
|
url = self._graph_url(f"{self.business_account_id}/message_templates?limit={limit}")
|
|
return await self._request("GET", url)
|
|
|
|
async def get_media_metadata(self, media_id: str) -> dict[str, Any]:
|
|
if not self.access_token:
|
|
raise WhatsAppGraphError("WhatsApp access token is not configured", status_code=503)
|
|
return await self._request("GET", self._graph_url(media_id))
|
|
|
|
async def download_media(self, media_id: str) -> tuple[bytes, str]:
|
|
metadata = await self.get_media_metadata(media_id)
|
|
download_url = metadata.get("url")
|
|
mime_type = metadata.get("mime_type") or "application/octet-stream"
|
|
if not download_url:
|
|
raise WhatsAppGraphError("Media URL not available", status_code=404)
|
|
headers = {"Authorization": f"Bearer {self.access_token}"}
|
|
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
|
response = await client.get(download_url, headers=headers)
|
|
if response.status_code >= 400:
|
|
raise WhatsAppGraphError("Failed to download media", status_code=response.status_code)
|
|
return response.content, mime_type
|