1400 lines
63 KiB
Python
1400 lines
63 KiB
Python
"""
|
||
WhatsApp Cloud API adapter — official Meta WhatsApp Business Platform.
|
||
|
||
Complement to the Baileys bridge plugin (``plugins/platforms/whatsapp``): official
|
||
Cloud API, Business account + public webhook URL required, token auth. Both share
|
||
gating / mention / formatting behavior via ``WhatsAppBehaviorMixin``.
|
||
|
||
Required env vars to enable the adapter:
|
||
- WHATSAPP_CLOUD_PHONE_NUMBER_ID (the Graph URL path component)
|
||
- WHATSAPP_CLOUD_ACCESS_TOKEN (System User permanent token)
|
||
|
||
Optional:
|
||
- WHATSAPP_CLOUD_APP_ID
|
||
- WHATSAPP_CLOUD_APP_SECRET (HMAC key for X-Hub-Signature-256)
|
||
- WHATSAPP_CLOUD_WABA_ID (analytics / future use)
|
||
- WHATSAPP_CLOUD_VERIFY_TOKEN (hub.verify_token shared secret)
|
||
- WHATSAPP_CLOUD_WEBHOOK_HOST (default: unset → dual-stack, all interfaces IPv4+IPv6)
|
||
- WHATSAPP_CLOUD_WEBHOOK_PORT (default 8090)
|
||
- WHATSAPP_CLOUD_WEBHOOK_PATH (default /whatsapp/webhook)
|
||
- WHATSAPP_CLOUD_API_VERSION (default v20.0)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import logging
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import shutil
|
||
import uuid
|
||
from collections import OrderedDict
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Optional
|
||
|
||
try:
|
||
from aiohttp import web
|
||
|
||
AIOHTTP_AVAILABLE = True
|
||
except ImportError:
|
||
AIOHTTP_AVAILABLE = False
|
||
web = None # type: ignore[assignment]
|
||
|
||
try:
|
||
import httpx
|
||
|
||
HTTPX_AVAILABLE = True
|
||
except ImportError:
|
||
HTTPX_AVAILABLE = False
|
||
httpx = None # type: ignore[assignment]
|
||
|
||
from gateway.config import Platform, PlatformConfig
|
||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
||
from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin, _get_wsecret
|
||
from gateway.platforms.media_cache import ext_for_mime
|
||
from gateway import rich_sent_store
|
||
from hermes_constants import get_hermes_dir
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
DEFAULT_API_VERSION = "v20.0"
|
||
# ``None`` → aiohttp binds one socket per address family (IPv4 + IPv6). "0.0.0.0"
|
||
# bound IPv4 only and was unreachable on IPv6-only networks (e.g. Fly.io 6PN).
|
||
DEFAULT_WEBHOOK_HOST = None
|
||
DEFAULT_WEBHOOK_PORT = 8090
|
||
DEFAULT_WEBHOOK_PATH = "/whatsapp/webhook"
|
||
GRAPH_API_BASE = "https://graph.facebook.com"
|
||
WEBHOOK_MAX_BODY_BYTES = 3 * 1024 * 1024
|
||
# Meta retries failed webhooks for up to 7 days, but the practical duplicate
|
||
# risk is within minutes — 5000 FIFO entries bounds memory and covers that.
|
||
WAMID_DEDUP_CACHE_SIZE = 5000
|
||
# Cap for the interactive-button state dicts and the per-chat last-wamid cache.
|
||
INTERACTIVE_STATE_CACHE_SIZE = 1000
|
||
|
||
# Meta's hard per-type caps for the /media endpoint; refuse locally instead of
|
||
# round-tripping to Graph just to be rejected.
|
||
# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media
|
||
_MEDIA_SIZE_LIMITS = {
|
||
"image": 5 * 1024 * 1024,
|
||
"video": 16 * 1024 * 1024,
|
||
"audio": 16 * 1024 * 1024,
|
||
"document": 100 * 1024 * 1024,
|
||
"sticker": 100 * 1024,
|
||
}
|
||
|
||
# Default mime types when we can't guess from the path's extension.
|
||
_DEFAULT_MIME = {
|
||
"image": "image/jpeg",
|
||
"video": "video/mp4",
|
||
"audio": "audio/mpeg",
|
||
"document": "application/octet-stream",
|
||
"sticker": "image/webp",
|
||
}
|
||
|
||
# ``shutil.which`` honours PATHEXT on Windows. None → MP3 voice falls back to an
|
||
# "audio file attachment" rendering in WhatsApp.
|
||
_FFMPEG_PATH = shutil.which("ffmpeg")
|
||
|
||
# mimetypes returns RFC-correct but uncommon extensions (audio/ogg → .oga,
|
||
# audio/mp4 → .mp4, image/jpeg → .jpe). Downstream STT/vision whitelists the
|
||
# in-the-wild forms, so pin the few Meta sends.
|
||
_WHATSAPP_MIME_EXTENSION_OVERRIDES: Dict[str, str] = {
|
||
"audio/ogg": ".ogg",
|
||
"audio/x-opus+ogg": ".ogg",
|
||
"audio/opus": ".ogg",
|
||
"audio/mp4": ".m4a",
|
||
"audio/x-m4a": ".m4a",
|
||
"image/jpeg": ".jpg",
|
||
}
|
||
|
||
_INBOUND_MEDIA_KINDS = {"image", "video", "audio", "voice", "document", "sticker"}
|
||
_TEXT_INJECT_EXTS = {
|
||
".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml",
|
||
".log", ".py", ".js", ".ts", ".html", ".css",
|
||
}
|
||
_MAX_TEXT_INJECT_BYTES = 100 * 1024 # matches Telegram/Discord/Slack
|
||
_MESSAGE_TYPE_BY_KIND = {
|
||
"text": MessageType.TEXT,
|
||
"image": MessageType.PHOTO,
|
||
"video": MessageType.VIDEO,
|
||
"audio": MessageType.VOICE,
|
||
"voice": MessageType.VOICE,
|
||
"document": MessageType.DOCUMENT,
|
||
"sticker": MessageType.PHOTO,
|
||
"button": MessageType.TEXT,
|
||
"interactive": MessageType.TEXT,
|
||
"location": MessageType.TEXT,
|
||
"contacts": MessageType.TEXT,
|
||
}
|
||
|
||
|
||
async def _read_limited_request_body(request: Any, max_bytes: int) -> bytes:
|
||
"""Read at most ``max_bytes`` from an aiohttp request body."""
|
||
try:
|
||
body = await request.content.readexactly(max_bytes + 1)
|
||
except asyncio.IncompleteReadError as exc:
|
||
body = exc.partial
|
||
if len(body) > max_bytes:
|
||
raise ValueError("payload too large")
|
||
return body
|
||
|
||
|
||
def _ext_for_mime(mime: str) -> Optional[str]:
|
||
"""Mime → on-disk extension: WhatsApp overrides → mimetypes → None (never the shared default table)."""
|
||
if not mime:
|
||
return None
|
||
return ext_for_mime(
|
||
mime, overrides=_WHATSAPP_MIME_EXTENSION_OVERRIDES,
|
||
use_defaults=False, use_mimetypes=True, fallback=None,
|
||
)
|
||
|
||
|
||
# Under the hermes dir so it survives restarts/reloads — same as the Baileys bridge.
|
||
_INBOUND_MEDIA_CACHE = Path(get_hermes_dir("platforms/whatsapp_cloud/media", "whatsapp_cloud/media"))
|
||
|
||
|
||
def check_whatsapp_cloud_requirements() -> bool:
|
||
"""aiohttp (webhook server) + httpx (Graph API) — both default deps."""
|
||
return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
|
||
|
||
|
||
class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||
"""Outbound: Graph ``/<api_version>/<phone_id>/messages``; inbound: aiohttp webhook
|
||
server. The mixin comes first so its ``format_message`` overrides the base one."""
|
||
|
||
splits_long_messages = True # send() chunks via truncate_message()
|
||
|
||
def __init__(self, config: PlatformConfig):
|
||
super().__init__(config, Platform.WHATSAPP_CLOUD)
|
||
extra = config.extra or {}
|
||
self._phone_number_id: str = str(extra.get("phone_number_id", "")).strip()
|
||
self._access_token: str = str(extra.get("access_token", "")).strip()
|
||
self._app_id: str = str(extra.get("app_id", "")).strip()
|
||
self._app_secret: str = str(extra.get("app_secret", "")).strip()
|
||
self._waba_id: str = str(extra.get("waba_id", "")).strip()
|
||
self._verify_token: str = str(extra.get("verify_token", "")).strip()
|
||
|
||
# Falsy host (None/"") collapses to the dual-stack default.
|
||
_raw_webhook_host = extra.get("webhook_host", DEFAULT_WEBHOOK_HOST) or DEFAULT_WEBHOOK_HOST
|
||
self._webhook_host: Optional[str] = str(_raw_webhook_host) if _raw_webhook_host else None
|
||
self._webhook_port: int = int(extra.get("webhook_port", DEFAULT_WEBHOOK_PORT))
|
||
self._webhook_path: str = self._normalize_path(extra.get("webhook_path", DEFAULT_WEBHOOK_PATH))
|
||
self._health_path: str = self._normalize_path(extra.get("health_path", "/health"))
|
||
self._api_version: str = str(extra.get("api_version", DEFAULT_API_VERSION))
|
||
|
||
# Behavior-mixin contract attributes. WHATSAPP_CLOUD_* env vars take
|
||
# precedence so both adapters can run in parallel with independent
|
||
# policies; shared WHATSAPP_* names remain the fallback.
|
||
self._reply_prefix: Optional[str] = extra.get("reply_prefix")
|
||
# Config first, then legacy ALLOW_FROM, then the documented ALLOWED_USERS.
|
||
allow_raw = self._select_dm_allowlist(
|
||
extra, ("WHATSAPP_CLOUD_ALLOW_FROM", "WHATSAPP_CLOUD_ALLOWED_USERS"), _get_wsecret
|
||
)
|
||
self._allow_from: set[str] = self._normalize_allow_ids(self._coerce_allow_list(allow_raw))
|
||
# DM policy default: "open" if the operator opted into allow-all, else
|
||
# "allowlist" when one is configured (so it is enforced), else "open".
|
||
_allow_all_optin = str(
|
||
_get_wsecret("WHATSAPP_CLOUD_ALLOW_ALL_USERS", default="") or ""
|
||
).strip().lower() in {"true", "1", "yes"}
|
||
_default_dm_policy = "open" if _allow_all_optin else ("allowlist" if self._allow_from else "open")
|
||
self._dm_policy: str = str(
|
||
extra.get("dm_policy")
|
||
or _get_wsecret("WHATSAPP_CLOUD_DM_POLICY")
|
||
or _get_wsecret("WHATSAPP_DM_POLICY")
|
||
or _default_dm_policy
|
||
).strip().lower()
|
||
self._group_policy: str = str(
|
||
extra.get("group_policy")
|
||
or _get_wsecret("WHATSAPP_CLOUD_GROUP_POLICY")
|
||
or _get_wsecret("WHATSAPP_GROUP_POLICY", default="open")
|
||
or "open"
|
||
).strip().lower()
|
||
self._group_allow_from: set[str] = self._normalize_allow_ids(
|
||
self._coerce_allow_list(
|
||
extra.get("group_allow_from")
|
||
or extra.get("groupAllowFrom")
|
||
or _get_wsecret("WHATSAPP_CLOUD_GROUP_ALLOW_FROM")
|
||
)
|
||
)
|
||
self._mention_patterns = self._compile_mention_patterns()
|
||
|
||
# Webhook dedup state (in-memory, FIFO-evicted).
|
||
self._seen_wamids: "OrderedDict[str, bool]" = OrderedDict()
|
||
self._duplicate_count: int = 0
|
||
self._accepted_count: int = 0
|
||
self._rejected_signature_count: int = 0
|
||
self._warned_no_ffmpeg: bool = False
|
||
|
||
# Latest inbound wamid per chat: Meta's typing/read-receipt API needs a
|
||
# message_id to attach to, and the base send_typing contract has none.
|
||
self._last_inbound_wamid_by_chat: "OrderedDict[str, str]" = OrderedDict()
|
||
|
||
# Interactive-button state: short id (in the button payload) → session_key
|
||
# for the gateway resolver. Popped on tap; FIFO-capped via _bounded_put so
|
||
# ignored prompts don't accumulate (an evicted tap degrades to text fallback).
|
||
self._clarify_state: "OrderedDict[str, str]" = OrderedDict()
|
||
self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict()
|
||
self._slash_confirm_state: "OrderedDict[str, str]" = OrderedDict()
|
||
self._runner = None
|
||
self._http_client: Optional["httpx.AsyncClient"] = None
|
||
|
||
# ------------------------------------------------------------------ helpers
|
||
@staticmethod
|
||
def _normalize_path(path: Any) -> str:
|
||
raw = str(path or "").strip() or "/"
|
||
return raw if raw.startswith("/") else f"/{raw}"
|
||
|
||
def _graph_url(self, path: str) -> str:
|
||
"""Build a Graph API URL for this adapter's phone-number scope."""
|
||
if path.startswith("/"):
|
||
path = path[1:]
|
||
return f"{GRAPH_API_BASE}/{self._api_version}/{self._phone_number_id}/{path}"
|
||
|
||
def _auth_headers(self, *, json_body: bool = True) -> Dict[str, str]:
|
||
headers = {"Authorization": f"Bearer {self._access_token}"}
|
||
if json_body:
|
||
headers["Content-Type"] = "application/json"
|
||
return headers
|
||
|
||
@staticmethod
|
||
def _bounded_put(cache: "OrderedDict[str, str]", key: str, value: str) -> None:
|
||
"""Insert into a FIFO-capped OrderedDict, evicting oldest entries."""
|
||
cache[key] = value
|
||
while len(cache) > INTERACTIVE_STATE_CACHE_SIZE:
|
||
cache.popitem(last=False)
|
||
|
||
def _effective_reply_prefix(self) -> str:
|
||
"""Cloud API has no self-chat concept (a Baileys-only setting) — no default prefix."""
|
||
if self._reply_prefix is not None:
|
||
return self._reply_prefix.replace("\\n", "\n")
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _normalize_allow_ids(ids: set[str]) -> set[str]:
|
||
"""Normalize allowlist entries to bare wa_id (digits): strip ``@...`` JID suffixes and non-digits."""
|
||
normalized: set[str] = set()
|
||
for entry in ids:
|
||
bare = entry.split("@", 1)[0]
|
||
digits = re.sub(r"\D", "", bare)
|
||
normalized.add(digits or entry)
|
||
return normalized
|
||
|
||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||
"""Allowlist check against the normalized bare wa_id."""
|
||
if self._dm_policy == "allowlist":
|
||
bare = re.sub(r"\D", "", str(sender_id).split("@", 1)[0])
|
||
allow_from = self._normalize_allow_ids(self._live_dm_allow_from())
|
||
return (bare or sender_id) in allow_from
|
||
return super()._is_dm_allowed(sender_id)
|
||
|
||
def _open_dm_opted_in(self) -> bool:
|
||
"""Also honor the documented WHATSAPP_CLOUD_ALLOW_ALL_USERS opt-in."""
|
||
if str(_get_wsecret("WHATSAPP_CLOUD_ALLOW_ALL_USERS", default="") or "").strip().lower() in {"true", "1", "yes"}:
|
||
return True
|
||
return super()._open_dm_opted_in()
|
||
|
||
def _is_interactive_sender_authorized(self, sender_id: str) -> bool:
|
||
"""Interactive taps bypass ``_should_process_message``; re-check the strict DM gate
|
||
so a stale prompt can't be answered after the sender leaves the allowlist."""
|
||
principal = str(sender_id or "").strip()
|
||
if not principal:
|
||
return False
|
||
return self._is_dm_allowed(principal)
|
||
|
||
# ------------------------------------------------------------------ lifecycle
|
||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||
if not check_whatsapp_cloud_requirements():
|
||
self._set_fatal_error(
|
||
"whatsapp_cloud_deps_missing",
|
||
"aiohttp and httpx are required for whatsapp_cloud — "
|
||
"reinstall hermes-agent.",
|
||
retryable=False,
|
||
)
|
||
return False
|
||
if not self._phone_number_id or not self._access_token:
|
||
self._set_fatal_error(
|
||
"whatsapp_cloud_unconfigured",
|
||
"WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN "
|
||
"are required.",
|
||
retryable=False,
|
||
)
|
||
return False
|
||
|
||
# Tighter keepalive so idle CLOSE_WAIT drains promptly.
|
||
from gateway.platforms._http_client_limits import platform_httpx_limits
|
||
self._http_client = httpx.AsyncClient(timeout=30.0, limits=platform_httpx_limits())
|
||
|
||
# client_max_size backstops the bounded reader in _handle_webhook.
|
||
app = web.Application(client_max_size=WEBHOOK_MAX_BODY_BYTES)
|
||
app.router.add_get(self._health_path, self._handle_health)
|
||
app.router.add_get(self._webhook_path, self._handle_verify)
|
||
app.router.add_post(self._webhook_path, self._handle_webhook)
|
||
self._runner = web.AppRunner(app)
|
||
await self._runner.setup()
|
||
site = web.TCPSite(self._runner, self._webhook_host, self._webhook_port)
|
||
await site.start()
|
||
self._mark_connected()
|
||
logger.info(
|
||
"[whatsapp_cloud] Listening on %s:%d%s (Graph %s, phone_id=%s)",
|
||
self._webhook_host, self._webhook_port, self._webhook_path,
|
||
self._api_version, self._phone_number_id,
|
||
)
|
||
if not self._verify_token:
|
||
logger.warning(
|
||
"[whatsapp_cloud] WHATSAPP_CLOUD_VERIFY_TOKEN is not set — "
|
||
"the GET subscription handshake will fail until it is."
|
||
)
|
||
if not self._app_secret:
|
||
logger.warning(
|
||
"[whatsapp_cloud] WHATSAPP_CLOUD_APP_SECRET is not set — "
|
||
"incoming webhook POSTs will be refused with 503. Set "
|
||
"the app secret to enable inbound message delivery."
|
||
)
|
||
self._wire_plugin_handlers(None)
|
||
return True
|
||
|
||
async def disconnect(self) -> None:
|
||
if self._runner is not None:
|
||
try:
|
||
await self._runner.cleanup()
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] webhook server cleanup failed")
|
||
self._runner = None
|
||
if self._http_client is not None:
|
||
try:
|
||
await self._http_client.aclose()
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] http client close failed")
|
||
self._http_client = None
|
||
self._mark_disconnected()
|
||
|
||
# ------------------------------------------------------------------ outbound
|
||
@staticmethod
|
||
def _response_error(resp: Any) -> str:
|
||
"""Map a non-200 Graph response to an actionable error string."""
|
||
try:
|
||
body = resp.json()
|
||
except Exception:
|
||
body = {"raw": resp.text[:500]}
|
||
return WhatsAppCloudAdapter._format_graph_error(body, resp.status_code)
|
||
|
||
async def _post_messages(
|
||
self, payload: Dict[str, Any], *, fail_log: str, reject_log: str, reject_args: tuple = (),
|
||
) -> tuple[list, Optional[str]]:
|
||
"""POST one /messages payload. Returns ``(response messages[], None)`` or ``([], error)``."""
|
||
try:
|
||
resp = await self._http_client.post(
|
||
self._graph_url("messages"), headers=self._auth_headers(), json=payload
|
||
)
|
||
except Exception as exc:
|
||
logger.exception(fail_log)
|
||
return [], str(exc) or type(exc).__name__
|
||
if resp.status_code != 200:
|
||
error_msg = self._response_error(resp)
|
||
logger.warning(reject_log, resp.status_code, *reject_args, error_msg)
|
||
return [], error_msg
|
||
try:
|
||
return resp.json().get("messages") or [], None
|
||
except Exception:
|
||
return [], None
|
||
|
||
async def send(
|
||
self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Send a text message via Graph API. ``chat_id`` is the recipient's ``wa_id``."""
|
||
if self._http_client is None:
|
||
return SendResult(success=False, error="Not connected")
|
||
if not content or not content.strip():
|
||
return SendResult(success=True, message_id=None)
|
||
formatted = self.format_message(content)
|
||
chunks = self.truncate_message(formatted, self._outgoing_chunk_limit())
|
||
last_message_id: Optional[str] = None
|
||
for idx, chunk in enumerate(chunks):
|
||
payload: Dict[str, Any] = {
|
||
"messaging_product": "whatsapp",
|
||
"recipient_type": "individual",
|
||
"to": chat_id,
|
||
"type": "text",
|
||
"text": {"body": chunk, "preview_url": True},
|
||
}
|
||
if reply_to and idx == 0:
|
||
# Quote the user's message on the first chunk only.
|
||
payload["context"] = {"message_id": reply_to}
|
||
ids, err = await self._post_messages(
|
||
payload,
|
||
fail_log="[whatsapp_cloud] send failed",
|
||
reject_log="[whatsapp_cloud] send rejected (status=%d): %s",
|
||
)
|
||
if err is not None:
|
||
return SendResult(success=False, error=err)
|
||
if ids:
|
||
last_message_id = ids[0].get("id")
|
||
|
||
# Index (chat_id, wamid) → text: Meta's inbound ``context`` carries only the
|
||
# quoted message's id, so this is how replies to our messages resolve text.
|
||
if last_message_id:
|
||
rich_sent_store.record(chat_id, last_message_id, formatted)
|
||
return SendResult(success=True, message_id=last_message_id)
|
||
|
||
# ------------------------------------------------------------------ typing indicator + read receipts
|
||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||
"""Mark the latest inbound message read AND show a typing indicator.
|
||
|
||
Meta couples both into one POST (``status: "read"`` + ``typing_indicator``);
|
||
the indicator auto-dismisses on reply or after 25s. Best-effort: every error
|
||
is swallowed so the main reply path is never blocked.
|
||
"""
|
||
if self._http_client is None:
|
||
return
|
||
wamid = self._last_inbound_wamid_by_chat.get(chat_id)
|
||
if not wamid:
|
||
return # no inbound yet for this chat (or cache cleared on restart)
|
||
payload = {
|
||
"messaging_product": "whatsapp",
|
||
"status": "read",
|
||
"message_id": wamid,
|
||
"typing_indicator": {"type": "text"},
|
||
}
|
||
try:
|
||
resp = await self._http_client.post(
|
||
self._graph_url("messages"), headers=self._auth_headers(), json=payload
|
||
)
|
||
except Exception:
|
||
return
|
||
# Code 131009 = "Parameter value is not valid" (typically wamid > 30 days
|
||
# old) — common after a long-quiet conversation, so log at info.
|
||
if resp.status_code != 200:
|
||
try:
|
||
code = ((resp.json() or {}).get("error") or {}).get("code")
|
||
except Exception:
|
||
code = None
|
||
if code == 131009:
|
||
logger.info(
|
||
"[whatsapp_cloud] typing/read indicator rejected: "
|
||
"wamid %s likely older than 30 days", wamid,
|
||
)
|
||
else:
|
||
logger.debug(
|
||
"[whatsapp_cloud] typing/read indicator returned %d (%s)",
|
||
resp.status_code, code,
|
||
)
|
||
|
||
# ------------------------------------------------------------------ interactive messages
|
||
#
|
||
# ``interactive.type=button``: ≤3 quick-reply buttons (id ≤256 chars, title ≤20).
|
||
# ``interactive.type=list``: one "Tap to choose" button opening ≤10 rows.
|
||
# Free-form (no Meta approval) but only valid inside the 24h window — fine, since
|
||
# all senders here fire in direct response to a user message.
|
||
|
||
async def _post_interactive(
|
||
self, chat_id: str, interactive_body: Dict[str, Any], reply_to: Optional[str] = None,
|
||
) -> SendResult:
|
||
"""POST an ``interactive`` message; caller supplies ``type``/``body``/``action``."""
|
||
if self._http_client is None:
|
||
return SendResult(success=False, error="Not connected")
|
||
payload: Dict[str, Any] = {
|
||
"messaging_product": "whatsapp",
|
||
"recipient_type": "individual",
|
||
"to": chat_id,
|
||
"type": "interactive",
|
||
"interactive": interactive_body,
|
||
}
|
||
if reply_to:
|
||
payload["context"] = {"message_id": reply_to}
|
||
ids, err = await self._post_messages(
|
||
payload,
|
||
fail_log="[whatsapp_cloud] interactive send failed",
|
||
reject_log="[whatsapp_cloud] interactive rejected (status=%d): %s",
|
||
)
|
||
if err is not None:
|
||
return SendResult(success=False, error=err)
|
||
return SendResult(success=True, message_id=ids[0].get("id") if ids else None)
|
||
|
||
@staticmethod
|
||
def _truncate_button_label(text: str, limit: int = 20) -> str:
|
||
"""Button titles cap at 20 chars, list-row titles at 24; the ellipsis counts."""
|
||
text = str(text or "").strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: max(1, limit - 1)] + "…"
|
||
|
||
@staticmethod
|
||
def _truncate_body(text: str, limit: int = 1024) -> str:
|
||
"""``interactive.body.text`` caps at 1024 chars."""
|
||
text = str(text or "")
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: limit - 3] + "..."
|
||
|
||
@staticmethod
|
||
def _reply_buttons(*buttons: tuple[str, str]) -> list[Dict[str, Any]]:
|
||
return [{"type": "reply", "reply": {"id": bid, "title": title}} for bid, title in buttons]
|
||
|
||
async def send_clarify(
|
||
self, chat_id: str, question: str, choices: Optional[list], clarify_id: str,
|
||
session_key: str, metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Clarify as native buttons: 1–3 choices → buttons, 4+ → list (+ "Other" row
|
||
that flips the entry into text-capture mode), 0 → plain text question.
|
||
|
||
Button ``id`` carries ``cl:<clarify_id>:<idx|other>``; inbound dispatches on it.
|
||
"""
|
||
if self._http_client is None:
|
||
return SendResult(success=False, error="Not connected")
|
||
question = (question or "").strip()
|
||
reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
|
||
if not choices:
|
||
return await self.send(chat_id, f"❓ {question}", reply_to=reply_to)
|
||
|
||
# Full choice text goes in the body so long options aren't lost to the
|
||
# 20-char label cap; labels are just the option number.
|
||
choices_list = [str(c).strip() for c in choices[:10] if str(c).strip()]
|
||
option_lines = "\n".join(f"{i + 1}. {c}" for i, c in enumerate(choices_list))
|
||
body_text = self._truncate_body(f"❓ {question}\n\n{option_lines}")
|
||
if len(choices_list) <= 3:
|
||
interactive: Dict[str, Any] = {
|
||
"type": "button",
|
||
"body": {"text": body_text},
|
||
"action": {"buttons": self._reply_buttons(*(
|
||
(f"cl:{clarify_id}:{idx}", self._truncate_button_label(str(idx + 1)))
|
||
for idx in range(len(choices_list))
|
||
))},
|
||
}
|
||
else:
|
||
# List rows: id + title (≤24) + description (≤72) with the choice text.
|
||
rows = [
|
||
{
|
||
"id": f"cl:{clarify_id}:{idx}",
|
||
"title": self._truncate_button_label(f"{idx + 1}", limit=24),
|
||
"description": self._truncate_button_label(choice_text, limit=72),
|
||
}
|
||
for idx, choice_text in enumerate(choices_list)
|
||
]
|
||
rows.append({
|
||
"id": f"cl:{clarify_id}:other",
|
||
"title": "✏️ Other",
|
||
"description": "Type your own answer",
|
||
})
|
||
interactive = {
|
||
"type": "list",
|
||
"body": {"text": body_text},
|
||
"action": {"button": "Choose", "sections": [{"title": "Options", "rows": rows}]},
|
||
}
|
||
result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
|
||
if result.success:
|
||
self._bounded_put(self._clarify_state, clarify_id, session_key)
|
||
return result
|
||
|
||
async def send_exec_approval(
|
||
self, chat_id: str, command: str, session_key: str, description: str = "dangerous command",
|
||
metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True,
|
||
allow_session: bool = True, smart_denied: bool = False,
|
||
) -> SendResult:
|
||
"""Approve / Deny buttons; a tap resolves via ``tools.approval.resolve_gateway_approval``."""
|
||
if self._http_client is None:
|
||
return SendResult(success=False, error="Not connected")
|
||
del allow_permanent, allow_session # This adapter already offers one-shot Approve / Deny only.
|
||
# Body caps at 1024; reserve room for the framing prose.
|
||
cmd = command or ""
|
||
cmd_preview = cmd if len(cmd) <= 800 else cmd[:800] + "..."
|
||
body_text = self._truncate_body(
|
||
f"⚠️ *Command Approval Required*\n\n"
|
||
f"```\n{cmd_preview}\n```\n\n"
|
||
f"Reason: {description}"
|
||
+ ("\n\nSmart DENY: owner override applies to this one operation only." if smart_denied else "")
|
||
)
|
||
approval_id = uuid.uuid4().hex[:12]
|
||
reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
|
||
interactive = {
|
||
"type": "button",
|
||
"body": {"text": body_text},
|
||
"action": {"buttons": self._reply_buttons(
|
||
(f"appr:{approval_id}:approve", "✅ Approve"),
|
||
(f"appr:{approval_id}:deny", "❌ Deny"),
|
||
)},
|
||
}
|
||
result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
|
||
if result.success:
|
||
self._bounded_put(self._exec_approval_state, approval_id, session_key)
|
||
return result
|
||
|
||
async def send_slash_confirm(
|
||
self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Approve Once / Always / Cancel buttons; ``confirm_id`` is caller-supplied."""
|
||
if self._http_client is None:
|
||
return SendResult(success=False, error="Not connected")
|
||
body_text = self._truncate_body(f"*{title}*\n\n{message}")
|
||
reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
|
||
interactive = {
|
||
"type": "button",
|
||
"body": {"text": body_text},
|
||
"action": {"buttons": self._reply_buttons(
|
||
(f"sc:once:{confirm_id}", "✅ Approve Once"),
|
||
(f"sc:always:{confirm_id}", "🔒 Always"),
|
||
(f"sc:cancel:{confirm_id}", "❌ Cancel"),
|
||
)},
|
||
}
|
||
result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
|
||
if result.success:
|
||
self._bounded_put(self._slash_confirm_state, confirm_id, session_key)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _format_graph_error(body: Dict[str, Any], status_code: int) -> str:
|
||
# Graph shape: {"error": {"message", "type", "code", "fbtrace_id"}}
|
||
err = (body or {}).get("error") or {}
|
||
message = err.get("message") or body.get("raw") or "unknown error"
|
||
code = err.get("code")
|
||
if code is not None:
|
||
return f"graph error {code} (HTTP {status_code}): {message}"
|
||
return f"HTTP {status_code}: {message}"
|
||
|
||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||
# No chat-info endpoint; profile name arrives via webhook contacts[].
|
||
return {"name": chat_id, "type": "dm"}
|
||
|
||
# ------------------------------------------------------------------ outbound media
|
||
async def _upload_media(
|
||
self, file_path: str, media_kind: str, mime_type: Optional[str] = None,
|
||
) -> tuple[Optional[str], Optional[str]]:
|
||
"""Upload a local file to Graph /media. Returns ``(media_id, None)`` or ``(None, error)``."""
|
||
if self._http_client is None:
|
||
return None, "Not connected"
|
||
if not os.path.exists(file_path):
|
||
return None, f"File not found: {file_path}"
|
||
size = os.path.getsize(file_path)
|
||
cap = _MEDIA_SIZE_LIMITS.get(media_kind, _MEDIA_SIZE_LIMITS["document"])
|
||
if size > cap:
|
||
return None, (
|
||
f"File {os.path.basename(file_path)} is {size} bytes; "
|
||
f"Cloud API {media_kind} cap is {cap} bytes"
|
||
)
|
||
if not mime_type:
|
||
mime_type, _ = mimetypes.guess_type(file_path)
|
||
if not mime_type:
|
||
mime_type = _DEFAULT_MIME.get(media_kind, "application/octet-stream")
|
||
try:
|
||
with open(file_path, "rb") as fh:
|
||
files = {
|
||
"file": (os.path.basename(file_path), fh, mime_type),
|
||
"messaging_product": (None, "whatsapp"),
|
||
"type": (None, mime_type),
|
||
}
|
||
resp = await self._http_client.post(
|
||
self._graph_url("media"), headers=self._auth_headers(json_body=False), files=files
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("[whatsapp_cloud] media upload failed")
|
||
return None, str(exc)
|
||
if resp.status_code != 200:
|
||
return None, self._response_error(resp)
|
||
try:
|
||
media_id = resp.json().get("id")
|
||
except Exception:
|
||
media_id = None
|
||
if not media_id:
|
||
return None, "Upload response missing 'id'"
|
||
return media_id, None
|
||
|
||
async def _send_media(
|
||
self, chat_id: str, media_kind: str, *, media_id: Optional[str] = None,
|
||
media_link: Optional[str] = None, caption: Optional[str] = None,
|
||
filename: Optional[str] = None, reply_to: Optional[str] = None,
|
||
) -> SendResult:
|
||
"""POST a media message referencing exactly one of an uploaded ``media_id`` or a public ``link``.
|
||
|
||
Caption is accepted on image/video/document; filename on document only.
|
||
"""
|
||
if self._http_client is None:
|
||
return SendResult(success=False, error="Not connected")
|
||
if bool(media_id) == bool(media_link):
|
||
return SendResult(success=False, error="Exactly one of media_id or media_link must be set")
|
||
media_block: Dict[str, Any] = {}
|
||
if media_id:
|
||
media_block["id"] = media_id
|
||
else:
|
||
media_block["link"] = media_link
|
||
if caption and media_kind in {"image", "video", "document"}:
|
||
media_block["caption"] = caption
|
||
if filename and media_kind == "document":
|
||
media_block["filename"] = filename
|
||
payload: Dict[str, Any] = {
|
||
"messaging_product": "whatsapp",
|
||
"recipient_type": "individual",
|
||
"to": chat_id,
|
||
"type": media_kind,
|
||
media_kind: media_block,
|
||
}
|
||
if reply_to:
|
||
payload["context"] = {"message_id": reply_to}
|
||
ids, err = await self._post_messages(
|
||
payload,
|
||
fail_log="[whatsapp_cloud] media send failed",
|
||
reject_log="[whatsapp_cloud] media send rejected (status=%d, kind=%s): %s",
|
||
reject_args=(media_kind,),
|
||
)
|
||
if err is not None:
|
||
return SendResult(success=False, error=err)
|
||
return SendResult(success=True, message_id=ids[0].get("id") if ids else None)
|
||
|
||
async def _send_media_from_path_or_link(
|
||
self, chat_id: str, source: str, media_kind: str, *, caption: Optional[str] = None,
|
||
filename: Optional[str] = None, reply_to: Optional[str] = None,
|
||
mime_type: Optional[str] = None,
|
||
) -> SendResult:
|
||
"""HTTPS URL → ``link`` send (one fewer round trip); local path → upload + ``id`` send."""
|
||
if source.startswith(("http://", "https://")):
|
||
return await self._send_media(
|
||
chat_id, media_kind, media_link=source, caption=caption, filename=filename, reply_to=reply_to
|
||
)
|
||
media_id, err = await self._upload_media(source, media_kind, mime_type)
|
||
if err:
|
||
return SendResult(success=False, error=err)
|
||
return await self._send_media(
|
||
chat_id, media_kind, media_id=media_id, caption=caption, filename=filename, reply_to=reply_to
|
||
)
|
||
|
||
# ``**kwargs`` absorbs base-class args (e.g. ``metadata``) the Cloud API has no use for.
|
||
async def send_image(
|
||
self, chat_id: str, image_url: str, caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None, **kwargs,
|
||
) -> SendResult:
|
||
return await self._send_media_from_path_or_link(chat_id, image_url, "image", caption=caption, reply_to=reply_to)
|
||
|
||
async def send_image_file(
|
||
self, chat_id: str, image_path: str, caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None, **kwargs,
|
||
) -> SendResult:
|
||
return await self._send_media_from_path_or_link(chat_id, image_path, "image", caption=caption, reply_to=reply_to)
|
||
|
||
async def send_video(
|
||
self, chat_id: str, video_path: str, caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None, **kwargs,
|
||
) -> SendResult:
|
||
return await self._send_media_from_path_or_link(chat_id, video_path, "video", caption=caption, reply_to=reply_to)
|
||
|
||
async def send_voice(
|
||
self, chat_id: str, audio_path: str, caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None, **kwargs,
|
||
) -> SendResult:
|
||
"""Voice message: ``audio/ogg; codecs=opus`` renders as a voice bubble, so a
|
||
local MP3 (Hermes TTS output) is converted via ffmpeg first; other audio is sent as-is."""
|
||
source = audio_path
|
||
mime_type: Optional[str] = None
|
||
is_local_mp3 = (
|
||
not audio_path.startswith(("http://", "https://"))
|
||
and audio_path.lower().endswith(".mp3")
|
||
and os.path.exists(audio_path)
|
||
)
|
||
if is_local_mp3:
|
||
opus_path = await self._convert_to_opus(audio_path)
|
||
if opus_path:
|
||
try:
|
||
result = await self._send_media_from_path_or_link(
|
||
chat_id, opus_path, "audio",
|
||
caption=caption, reply_to=reply_to,
|
||
mime_type="audio/ogg; codecs=opus",
|
||
)
|
||
finally:
|
||
# The .ogg is a transient artifact next to the source MP3.
|
||
try:
|
||
os.unlink(opus_path)
|
||
except OSError:
|
||
pass
|
||
return result
|
||
# No ffmpeg (warn-once logged in _convert_to_opus) → MP3 attachment.
|
||
mime_type = "audio/mpeg"
|
||
return await self._send_media_from_path_or_link(
|
||
chat_id, source, "audio", caption=caption, reply_to=reply_to, mime_type=mime_type,
|
||
)
|
||
|
||
async def send_document(
|
||
self, chat_id: str, file_path: str, caption: Optional[str] = None,
|
||
file_name: Optional[str] = None, reply_to: Optional[str] = None, **kwargs,
|
||
) -> SendResult:
|
||
return await self._send_media_from_path_or_link(
|
||
chat_id, file_path, "document",
|
||
caption=caption, filename=file_name or os.path.basename(file_path), reply_to=reply_to,
|
||
)
|
||
|
||
# ------------------------------------------------------------------ opus conversion
|
||
async def _convert_to_opus(self, mp3_path: str) -> Optional[str]:
|
||
"""MP3 → ``audio/ogg; codecs=opus``; None if ffmpeg is missing or fails.
|
||
|
||
``-application voip`` tunes for speech; ``-b:a 32k -vbr on`` matches
|
||
WhatsApp's native voice-note bitrate.
|
||
"""
|
||
if not _FFMPEG_PATH:
|
||
self._warn_once_no_ffmpeg()
|
||
return None
|
||
out_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
_FFMPEG_PATH, "-y", "-i", mp3_path,
|
||
"-c:a", "libopus", "-b:a", "32k", "-vbr", "on",
|
||
"-application", "voip", out_path,
|
||
stdout=asyncio.subprocess.DEVNULL,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
)
|
||
_, stderr = await proc.communicate()
|
||
if proc.returncode != 0 or not Path(out_path).exists():
|
||
logger.error(
|
||
"[whatsapp_cloud] ffmpeg opus conversion failed "
|
||
"(returncode=%s): %s",
|
||
proc.returncode,
|
||
(stderr or b"").decode("utf-8", errors="replace")[:500],
|
||
)
|
||
return None
|
||
return out_path
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] ffmpeg subprocess raised")
|
||
return None
|
||
|
||
def _warn_once_no_ffmpeg(self) -> None:
|
||
if self._warned_no_ffmpeg:
|
||
return
|
||
self._warned_no_ffmpeg = True
|
||
logger.warning(
|
||
"[whatsapp_cloud] ffmpeg not found on PATH — voice messages will "
|
||
"be delivered as MP3 audio attachments instead of native voice "
|
||
"notes (green waveform bubble). Install ffmpeg to enable: "
|
||
"Windows `winget install Gyan.FFmpeg`, macOS `brew install ffmpeg`, "
|
||
"Linux package manager."
|
||
)
|
||
|
||
# ------------------------------------------------------------------ inbound media
|
||
async def _download_media_to_cache(
|
||
self, media_id: str, *, ext_hint: Optional[str] = None,
|
||
) -> tuple[Optional[str], Optional[str]]:
|
||
"""Two-step Graph download: ``GET /<id>`` → signed temp URL (~5 min) → bytes.
|
||
|
||
Returns ``(local_path, mime_type)`` or ``(None, None)`` on any failure (logged).
|
||
"""
|
||
if self._http_client is None:
|
||
return None, None
|
||
# media_id is interpolated into a Graph URL and a cache filename — refuse
|
||
# anything that isn't a plain Meta-style id so a hostile payload can't traverse.
|
||
media_id = str(media_id).strip()
|
||
if not re.fullmatch(r"[A-Za-z0-9._-]+", media_id):
|
||
logger.warning("[whatsapp_cloud] refusing malformed media id %r", media_id[:64])
|
||
return None, None
|
||
headers = self._auth_headers(json_body=False)
|
||
try:
|
||
meta_resp = await self._http_client.get(
|
||
f"{GRAPH_API_BASE}/{self._api_version}/{media_id}", headers=headers
|
||
)
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] media metadata fetch raised (id=%s)", media_id)
|
||
return None, None
|
||
if meta_resp.status_code != 200:
|
||
logger.warning(
|
||
"[whatsapp_cloud] media metadata fetch failed (id=%s, status=%d)",
|
||
media_id, meta_resp.status_code,
|
||
)
|
||
return None, None
|
||
try:
|
||
meta = meta_resp.json()
|
||
except Exception:
|
||
return None, None
|
||
temp_url = meta.get("url")
|
||
mime = meta.get("mime_type") or ""
|
||
if not temp_url:
|
||
return None, None
|
||
|
||
# Auth is required even though the URL is signed (Meta documents this).
|
||
try:
|
||
blob_resp = await self._http_client.get(temp_url, headers=headers)
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] media bytes fetch raised (id=%s)", media_id)
|
||
return None, None
|
||
if blob_resp.status_code != 200:
|
||
logger.warning(
|
||
"[whatsapp_cloud] media bytes fetch failed (id=%s, status=%d)",
|
||
media_id, blob_resp.status_code,
|
||
)
|
||
return None, None
|
||
ext = ext_hint
|
||
if not ext and mime:
|
||
ext = _ext_for_mime(mime)
|
||
if not ext:
|
||
ext = ".bin"
|
||
_INBOUND_MEDIA_CACHE.mkdir(parents=True, exist_ok=True)
|
||
out_path = _INBOUND_MEDIA_CACHE / f"{media_id}{ext}"
|
||
try:
|
||
out_path.write_bytes(blob_resp.content)
|
||
except OSError:
|
||
logger.exception("[whatsapp_cloud] failed to write cached media (id=%s)", media_id)
|
||
return None, None
|
||
return str(out_path), mime or None
|
||
|
||
# ------------------------------------------------------------------ inbound
|
||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||
return web.json_response(
|
||
{
|
||
"status": "ok",
|
||
"platform": self.platform.value,
|
||
"phone_number_id": self._phone_number_id,
|
||
"webhook_path": self._webhook_path,
|
||
"verify_token_configured": bool(self._verify_token),
|
||
"app_secret_configured": bool(self._app_secret),
|
||
"ffmpeg_present": _FFMPEG_PATH is not None,
|
||
"accepted": self._accepted_count,
|
||
"duplicates": self._duplicate_count,
|
||
"rejected_signature": self._rejected_signature_count,
|
||
}
|
||
)
|
||
|
||
async def _handle_verify(self, request: "web.Request") -> "web.Response":
|
||
"""Meta subscription handshake: echo ``hub.challenge`` iff mode is
|
||
``subscribe`` and ``hub.verify_token`` matches (constant-time)."""
|
||
if not self._verify_token:
|
||
# Refuse rather than accept any token, which would let an attacker subscribe.
|
||
return web.Response(status=503, text="verify_token not configured")
|
||
mode = request.query.get("hub.mode", "")
|
||
token = request.query.get("hub.verify_token", "")
|
||
challenge = request.query.get("hub.challenge", "")
|
||
if mode != "subscribe":
|
||
return web.Response(status=400, text="bad mode")
|
||
# Compare as bytes: compare_digest raises TypeError on non-ASCII str.
|
||
if not hmac.compare_digest(token.encode(), self._verify_token.encode()):
|
||
return web.Response(status=403, text="verify_token mismatch")
|
||
if not challenge:
|
||
return web.Response(status=400, text="missing challenge")
|
||
return web.Response(text=challenge, content_type="text/plain")
|
||
|
||
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
||
"""Inbound webhook POST: raw bytes → HMAC verify → JSON → dispatch.
|
||
|
||
Signature is over the raw body, so JSON parsing must come after
|
||
verification. Always 200 once a valid request is ack'd — Meta retries
|
||
non-200 for up to 7 days and would multiply downstream agent work.
|
||
"""
|
||
# Read one byte past Meta's 3MB max so oversized chunked bodies are
|
||
# rejected before buffering the rest.
|
||
try:
|
||
raw = await _read_limited_request_body(request, WEBHOOK_MAX_BODY_BYTES)
|
||
except ValueError:
|
||
return web.Response(status=413)
|
||
except Exception:
|
||
return web.Response(status=400)
|
||
|
||
# Without app_secret the sender can't be authenticated → refuse (same
|
||
# posture as the GET handshake refusing without verify_token).
|
||
if not self._app_secret:
|
||
logger.error(
|
||
"[whatsapp_cloud] webhook POST refused: app_secret unset. "
|
||
"Set WHATSAPP_CLOUD_APP_SECRET to enable inbound delivery."
|
||
)
|
||
return web.Response(status=503, text="app_secret not configured")
|
||
signature_header = request.headers.get("X-Hub-Signature-256", "")
|
||
if not self._verify_signature(raw, signature_header):
|
||
self._rejected_signature_count += 1
|
||
logger.warning(
|
||
"[whatsapp_cloud] rejected webhook: invalid X-Hub-Signature-256 "
|
||
"(header=%r, body_len=%d)",
|
||
signature_header, len(raw),
|
||
)
|
||
return web.Response(status=401)
|
||
try:
|
||
payload = json.loads(raw)
|
||
except Exception:
|
||
logger.warning("[whatsapp_cloud] webhook body is not valid JSON")
|
||
return web.Response(status=400)
|
||
if not isinstance(payload, dict):
|
||
return web.Response(status=400)
|
||
await self._dispatch_payload(payload)
|
||
return web.Response(status=200)
|
||
|
||
# ------------------------------------------------------------------ signature
|
||
def _verify_signature(self, raw_body: bytes, header: str) -> bool:
|
||
"""Verify ``sha256=<hex>`` HMAC of the raw body keyed by ``app_secret`` (constant-time)."""
|
||
if not self._app_secret or not header:
|
||
return False
|
||
if not header.startswith("sha256="):
|
||
return False
|
||
expected_hex = header[len("sha256="):].strip()
|
||
if not expected_hex:
|
||
return False
|
||
computed = hmac.new(self._app_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
|
||
# Compare as bytes: compare_digest raises TypeError on non-ASCII str.
|
||
return hmac.compare_digest(computed.lower().encode(), expected_hex.lower().encode())
|
||
|
||
# ------------------------------------------------------------------ dispatch
|
||
def _dedup_wamid(self, wamid: str) -> bool:
|
||
"""True if this wamid is new; False (and count a duplicate) if already seen."""
|
||
if not wamid:
|
||
return True # can't dedup without an id — let it through
|
||
if wamid in self._seen_wamids:
|
||
self._duplicate_count += 1
|
||
return False
|
||
self._seen_wamids[wamid] = True
|
||
while len(self._seen_wamids) > WAMID_DEDUP_CACHE_SIZE:
|
||
self._seen_wamids.popitem(last=False)
|
||
return True
|
||
|
||
async def _dispatch_payload(self, payload: Dict[str, Any]) -> None:
|
||
"""Walk ``entry[].changes[].value.{messages, contacts, statuses}`` and dispatch each message.
|
||
|
||
``statuses`` (sent/delivered/read/failed) are logged at debug, not
|
||
dispatched — the agent doesn't consume delivery receipts.
|
||
"""
|
||
if payload.get("object") != "whatsapp_business_account":
|
||
logger.debug("[whatsapp_cloud] ignoring non-WABA payload (object=%r)", payload.get("object"))
|
||
return
|
||
for entry in payload.get("entry") or []:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
for change in entry.get("changes") or []:
|
||
if not isinstance(change, dict):
|
||
continue
|
||
if change.get("field") != "messages":
|
||
continue # account_alerts, template_status_update, … — not message ingress
|
||
value = change.get("value") or {}
|
||
metadata = value.get("metadata") or {}
|
||
contacts_by_waid: Dict[str, str] = {}
|
||
for contact in value.get("contacts") or []:
|
||
if not isinstance(contact, dict):
|
||
continue
|
||
wa_id = str(contact.get("wa_id") or "").strip()
|
||
profile = contact.get("profile") or {}
|
||
name = str(profile.get("name") or "").strip()
|
||
if wa_id:
|
||
contacts_by_waid[wa_id] = name
|
||
for raw_message in value.get("messages") or []:
|
||
if not isinstance(raw_message, dict):
|
||
continue
|
||
wamid = str(raw_message.get("id") or "").strip()
|
||
if not self._dedup_wamid(wamid):
|
||
logger.debug("[whatsapp_cloud] duplicate wamid %s, skipping", wamid)
|
||
continue
|
||
# Neither build nor dispatch errors may bubble out: the wamid is
|
||
# already dedup-marked, so a 500 would make Meta retry the batch
|
||
# and every message in it would then be dropped as a duplicate.
|
||
try:
|
||
event = await self._build_message_event_from_cloud(raw_message, contacts_by_waid, metadata)
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] failed to build event for wamid %s", wamid)
|
||
continue
|
||
if event is None:
|
||
continue
|
||
self._accepted_count += 1
|
||
try:
|
||
await self.handle_message(event)
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] handle_message raised for wamid %s", wamid)
|
||
for status in value.get("statuses") or []:
|
||
if isinstance(status, dict):
|
||
logger.debug("[whatsapp_cloud] status %s for %s", status.get("status"), status.get("id"))
|
||
|
||
async def _dispatch_interactive_reply(
|
||
self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str],
|
||
) -> bool:
|
||
"""Route an inbound button tap to its resolver (see ``_INTERACTIVE_HANDLERS``).
|
||
|
||
True = claimed. False (unknown prefix, no live state, or no waiter) makes the
|
||
caller fall back to text dispatch with the button title — covers stale taps.
|
||
"""
|
||
inter = raw_message.get("interactive") or {}
|
||
# button_reply (type=button) and list_reply (type=list) carry id+title.
|
||
inner = inter.get("button_reply") or inter.get("list_reply") or {}
|
||
button_id = str(inner.get("id") or "").strip()
|
||
if not button_id:
|
||
return False
|
||
sender_id = str(raw_message.get("from") or "").strip()
|
||
if not self._is_interactive_sender_authorized(sender_id):
|
||
logger.warning(
|
||
"[whatsapp_cloud] Rejected unauthorized interactive tap "
|
||
"from %s (button_id=%r)",
|
||
sender_id or "<unknown>", button_id,
|
||
)
|
||
return True # claim so the tap isn't re-dispatched as plain text
|
||
parts = button_id.split(":", 2)
|
||
reply_target = str(raw_message.get("from") or "")
|
||
for prefix, handler in self._INTERACTIVE_HANDLERS.items():
|
||
if button_id.startswith(prefix):
|
||
if len(parts) != 3:
|
||
return False
|
||
return await handler(self, reply_target, inner, parts)
|
||
# Unknown prefix (maybe a plugin-defined adapter's) — text dispatch is the safe default.
|
||
return False
|
||
|
||
async def _reply_best_effort(self, to: str, text: str, fail_log: str) -> None:
|
||
try:
|
||
await self.send(to, text)
|
||
except Exception:
|
||
logger.exception(fail_log)
|
||
|
||
async def _handle_clarify_tap(self, to: str, inner: Dict[str, Any], parts: list) -> bool:
|
||
_, clarify_id, choice = parts
|
||
session_key = self._clarify_state.pop(clarify_id, None)
|
||
if not session_key:
|
||
logger.info(
|
||
"[whatsapp_cloud] clarify tap with no matching state "
|
||
"(clarify_id=%s) — likely stale; falling back to text",
|
||
clarify_id,
|
||
)
|
||
return False
|
||
try:
|
||
from tools.clarify_gateway import resolve_gateway_clarify
|
||
except ImportError:
|
||
logger.warning(
|
||
"[whatsapp_cloud] clarify resolver unavailable; "
|
||
"falling back to text dispatch"
|
||
)
|
||
return False
|
||
if choice == "other":
|
||
# Flip the entry into text-capture mode so the gateway's text intercept
|
||
# resolves the clarify with the next message; otherwise that text would
|
||
# hit the agent path while it's still blocked in clarify ("Interrupting
|
||
# current task" loop).
|
||
try:
|
||
from tools.clarify_gateway import mark_awaiting_text
|
||
flipped = mark_awaiting_text(clarify_id)
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] mark_awaiting_text failed for %s", clarify_id)
|
||
flipped = False
|
||
if not flipped:
|
||
# Entry vanished (timeout, /new, restart) — fall through to text.
|
||
logger.info(
|
||
"[whatsapp_cloud] clarify 'Other' tap but entry "
|
||
"missing (clarify_id=%s); falling back to text",
|
||
clarify_id,
|
||
)
|
||
return False
|
||
# Keep the mapping live for further taps on the same prompt.
|
||
self._clarify_state[clarify_id] = session_key
|
||
await self._reply_best_effort(to, "✏️ Type your answer:", "[whatsapp_cloud] clarify other-prompt failed")
|
||
return True
|
||
try:
|
||
idx = int(choice)
|
||
except ValueError:
|
||
logger.warning("[whatsapp_cloud] clarify tap had non-int choice: %r", choice)
|
||
self._clarify_state[clarify_id] = session_key # a follow-up text can still resolve
|
||
return False
|
||
# Title is the numeric label; the agent has the prompt in context to interpret it.
|
||
response_text = str(inner.get("title") or str(idx + 1))
|
||
resolved = resolve_gateway_clarify(clarify_id, response_text)
|
||
if not resolved:
|
||
logger.info(
|
||
"[whatsapp_cloud] clarify resolver reported no waiter "
|
||
"(clarify_id=%s) — falling back to text", clarify_id,
|
||
)
|
||
return False
|
||
return True
|
||
|
||
async def _handle_approval_tap(self, to: str, inner: Dict[str, Any], parts: list) -> bool:
|
||
_, approval_id, choice = parts
|
||
session_key = self._exec_approval_state.pop(approval_id, None)
|
||
if not session_key:
|
||
logger.info(
|
||
"[whatsapp_cloud] approval tap with no matching state "
|
||
"(approval_id=%s) — likely stale; falling back to text",
|
||
approval_id,
|
||
)
|
||
return False
|
||
if choice not in ("approve", "deny"):
|
||
self._exec_approval_state[approval_id] = session_key
|
||
return False
|
||
try:
|
||
from tools.approval import resolve_gateway_approval
|
||
except ImportError:
|
||
logger.warning("[whatsapp_cloud] approval resolver unavailable")
|
||
return False
|
||
count = resolve_gateway_approval(session_key, choice)
|
||
if not count:
|
||
logger.info(
|
||
"[whatsapp_cloud] approval resolver reported no waiter "
|
||
"(session_key=%s) — likely already resolved",
|
||
session_key,
|
||
)
|
||
# A tap after the wait timed out (count == 0) must not claim approval:
|
||
# the command was already denied fail-closed.
|
||
if count:
|
||
confirm_text = "✅ Approved." if choice == "approve" else "❌ Denied."
|
||
else:
|
||
confirm_text = (
|
||
"⌛ Approval expired — command was not run "
|
||
"(already timed out or resolved elsewhere)."
|
||
)
|
||
await self._reply_best_effort(to, confirm_text, "[whatsapp_cloud] approval confirm failed")
|
||
return True
|
||
|
||
async def _handle_slash_confirm_tap(self, to: str, inner: Dict[str, Any], parts: list) -> bool:
|
||
_, choice, confirm_id = parts
|
||
session_key = self._slash_confirm_state.pop(confirm_id, None)
|
||
if not session_key:
|
||
logger.info(
|
||
"[whatsapp_cloud] slash_confirm tap with no matching state "
|
||
"(confirm_id=%s) — likely stale", confirm_id,
|
||
)
|
||
return False
|
||
if choice not in ("once", "always", "cancel"):
|
||
self._slash_confirm_state[confirm_id] = session_key
|
||
return False
|
||
try:
|
||
from tools import slash_confirm as _slash_confirm_mod
|
||
except ImportError:
|
||
logger.warning("[whatsapp_cloud] slash_confirm resolver unavailable")
|
||
return False
|
||
try:
|
||
result_text = await _slash_confirm_mod.resolve(session_key, confirm_id, choice)
|
||
except Exception:
|
||
logger.exception("[whatsapp_cloud] slash_confirm.resolve failed")
|
||
return True # still claim the tap; surfacing it as text wouldn't help
|
||
if result_text:
|
||
await self._reply_best_effort(to, result_text, "[whatsapp_cloud] slash_confirm reply failed")
|
||
return True
|
||
|
||
_INTERACTIVE_HANDLERS = {
|
||
"cl:": _handle_clarify_tap,
|
||
"appr:": _handle_approval_tap,
|
||
"sc:": _handle_slash_confirm_tap,
|
||
}
|
||
|
||
async def _collect_inbound_media(
|
||
self, msg_type_str: str, raw_message: Dict[str, Any], body: str
|
||
) -> tuple[list[str], list[str], str]:
|
||
"""Download inbound media by ``media_id``; returns ``(media_urls, media_types, body)``."""
|
||
media_urls: list[str] = []
|
||
media_types: list[str] = []
|
||
inner = raw_message.get(msg_type_str) or {}
|
||
media_id = str(inner.get("id") or "").strip()
|
||
inbound_mime = str(inner.get("mime_type") or "").strip()
|
||
if not media_id:
|
||
return media_urls, media_types, body
|
||
ext_hint = _ext_for_mime(inbound_mime) if inbound_mime else None
|
||
local_path, dl_mime = await self._download_media_to_cache(media_id, ext_hint=ext_hint)
|
||
if local_path:
|
||
media_urls.append(local_path)
|
||
media_types.append(dl_mime or inbound_mime or "application/octet-stream")
|
||
logger.info("[whatsapp_cloud] cached inbound %s media: %s", msg_type_str, local_path)
|
||
else:
|
||
logger.warning(
|
||
"[whatsapp_cloud] failed to download inbound %s (id=%s) — "
|
||
"agent will see message metadata but not the binary",
|
||
msg_type_str, media_id,
|
||
)
|
||
if msg_type_str == "document":
|
||
fname = str(inner.get("filename") or "").strip()
|
||
if fname and not body:
|
||
body = f"[Document: {fname}]"
|
||
return media_urls, media_types, body
|
||
|
||
@staticmethod
|
||
def _inject_document_text(media_urls: list[str], body: str) -> str:
|
||
"""Prepend text-readable document contents (≤100KB) to the body."""
|
||
for doc_path in media_urls:
|
||
if Path(doc_path).suffix.lower() not in _TEXT_INJECT_EXTS:
|
||
continue
|
||
try:
|
||
file_size = Path(doc_path).stat().st_size
|
||
if file_size > _MAX_TEXT_INJECT_BYTES:
|
||
logger.info(
|
||
"[whatsapp_cloud] skipping text injection for %s "
|
||
"(%d bytes > %d)",
|
||
doc_path, file_size, _MAX_TEXT_INJECT_BYTES,
|
||
)
|
||
continue
|
||
content = Path(doc_path).read_text(encoding="utf-8", errors="replace")
|
||
injection = f"[Content of {Path(doc_path).name}]:\n{content}"
|
||
body = f"{injection}\n\n{body}" if body else injection
|
||
except OSError:
|
||
logger.exception("[whatsapp_cloud] failed to read document text: %s", doc_path)
|
||
return body
|
||
|
||
async def _build_message_event_from_cloud(
|
||
self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str],
|
||
metadata: Dict[str, Any],
|
||
) -> Optional[MessageEvent]:
|
||
"""Convert a Cloud-API message object into a MessageEvent, or None if gated out."""
|
||
msg_type_str = str(raw_message.get("type") or "text").lower()
|
||
|
||
# Button taps route to the gateway resolver BEFORE text dispatch — the
|
||
# resolver unblocks the waiting agent, so don't also start a fresh turn.
|
||
if msg_type_str == "interactive":
|
||
if await self._dispatch_interactive_reply(raw_message, contacts_by_waid):
|
||
return None
|
||
body = ""
|
||
if msg_type_str == "text":
|
||
body = str((raw_message.get("text") or {}).get("body") or "")
|
||
elif msg_type_str == "button":
|
||
body = str((raw_message.get("button") or {}).get("text") or "")
|
||
elif msg_type_str == "interactive":
|
||
inter = raw_message.get("interactive") or {}
|
||
inner = inter.get("button_reply") or inter.get("list_reply") or {}
|
||
body = str(inner.get("title") or "")
|
||
elif msg_type_str in _INBOUND_MEDIA_KINDS:
|
||
body = str((raw_message.get(msg_type_str) or {}).get("caption") or "")
|
||
message_type = _MESSAGE_TYPE_BY_KIND.get(msg_type_str, MessageType.TEXT)
|
||
sender_id = str(raw_message.get("from") or "").strip()
|
||
sender_name = contacts_by_waid.get(sender_id, "")
|
||
|
||
# DMs only: chat_id == sender wa_id. A ``chat`` field marks a group-shaped
|
||
# payload (capability-gated by Meta) — refuse rather than treat as a DM.
|
||
chat_field = raw_message.get("chat")
|
||
if chat_field:
|
||
logger.warning(
|
||
"[whatsapp_cloud] received group-shaped message (chat=%s, "
|
||
"wamid=%s) — group support is not yet implemented; dropping. "
|
||
"Use the Baileys whatsapp adapter for group chats.",
|
||
chat_field, raw_message.get("id"),
|
||
)
|
||
return None
|
||
chat_id = sender_id
|
||
gating_data = {"chatId": chat_id, "senderId": sender_id, "isGroup": False, "body": body}
|
||
if not self._should_process_message(gating_data):
|
||
return None
|
||
media_urls: list[str] = []
|
||
media_types: list[str] = []
|
||
if msg_type_str in _INBOUND_MEDIA_KINDS:
|
||
media_urls, media_types, body = await self._collect_inbound_media(msg_type_str, raw_message, body)
|
||
if msg_type_str == "document" and media_urls:
|
||
body = self._inject_document_text(media_urls, body)
|
||
|
||
# Meta's ``context`` gives only the quoted message's id (+ author), never its
|
||
# text; resolve from rich_sent_store so run.py can build "[Replying to: ...]".
|
||
context = raw_message.get("context") or {}
|
||
reply_to_id = str(context.get("id") or "").strip() or None
|
||
reply_to_text: Optional[str] = None
|
||
reply_to_is_own = False
|
||
if reply_to_id:
|
||
reply_to_text = rich_sent_store.lookup(chat_id, reply_to_id)
|
||
# context.from == our business number → user replied to the bot.
|
||
quoted_from = str(context.get("from") or "").strip()
|
||
our_number = str(metadata.get("display_phone_number") or "").strip()
|
||
if quoted_from and our_number:
|
||
reply_to_is_own = quoted_from == our_number
|
||
source = self.build_source(
|
||
chat_id=chat_id,
|
||
chat_name=sender_name or chat_id,
|
||
chat_type="dm",
|
||
user_id=sender_id,
|
||
user_name=sender_name or None,
|
||
)
|
||
wamid = str(raw_message.get("id") or "") or None
|
||
if wamid and chat_id:
|
||
# Done AFTER gating so filtered messages don't leak typing/read receipts.
|
||
self._bounded_put(self._last_inbound_wamid_by_chat, chat_id, wamid)
|
||
if body:
|
||
rich_sent_store.record(chat_id, wamid, body)
|
||
return MessageEvent(
|
||
text=body,
|
||
message_type=message_type,
|
||
source=source,
|
||
raw_message=raw_message,
|
||
message_id=wamid,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_text=reply_to_text,
|
||
reply_to_is_own_message=reply_to_is_own,
|
||
media_urls=media_urls,
|
||
media_types=media_types,
|
||
)
|