"""WhatsApp Outbound 适配器 — 消息发送 (Cloud API HTTPS REST)""" import asyncio import aiohttp import logging from typing import Any logger = logging.getLogger(__name__) WHATSAPP_API_BASE = "https://graph.facebook.com/v22.0" MAX_RETRIES = 3 INITIAL_BACKOFF_SECONDS = 1.0 def _mime_for_type(media_type: str) -> str: mapping = { "image": "image/jpeg", "video": "video/mp4", "audio": "audio/mp3", "voice": "audio/ogg", "document": "application/octet-stream", } return mapping.get(media_type, "application/octet-stream") class WhatsAppOutbound: delivery_mode = "direct" chunker_mode = "length" text_chunk_limit: int = 2000 poll_max_options: int = 12 supports_poll_duration_seconds = False supports_anonymous_polls = False extract_markdown_images = True presentation_capabilities = None delivery_capabilities = None def __init__(self, config_adapter=None): self._config_adapter = config_adapter @staticmethod def _build_url(phone_number_id: str) -> str: return f"{WHATSAPP_API_BASE}/{phone_number_id}/messages" @staticmethod def _headers(access_token: str) -> dict: return { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } async def _resolve_account(self, account_id: str | None) -> dict | None: from yuxi.channel.extensions.whatsapp.config import WhatsAppConfigAdapter adapter = self._config_adapter or WhatsAppConfigAdapter() aid = account_id or adapter.default_account_id({}) return await adapter.resolve_account(aid) async def _send_with_retry( self, phone_number_id: str, access_token: str, payload: dict, *, max_retries: int = MAX_RETRIES, ) -> tuple[int, dict]: backoff = INITIAL_BACKOFF_SECONDS last_result: dict = {} for attempt in range(max_retries + 1): async with aiohttp.ClientSession() as session: async with session.post( self._build_url(phone_number_id), json=payload, headers=self._headers(access_token), ) as resp: result = await resp.json() if resp.status == 200: return resp.status, result if resp.status == 429 and attempt < max_retries: retry_after = resp.headers.get("Retry-After") wait = float(retry_after) if retry_after else backoff logger.warning( "WhatsApp rate limited, retrying in %.1fs (attempt %d/%d)", wait, attempt + 1, max_retries, ) await asyncio.sleep(wait) backoff *= 2 last_result = result continue return resp.status, result return 429, last_result @staticmethod def _inject_biz_opaque(payload: dict, callback_data: str | None) -> None: if callback_data: payload["biz_opaque_callback_data"] = callback_data[:512] # ── 基础消息发送 ────────────────────────────────────────── async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, recipient_type: str = "individual", biz_opaque_data: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: logger.error("WhatsApp send_text: account not resolved") return payload: dict[str, Any] = { "messaging_product": "whatsapp", "recipient_type": recipient_type, "to": target_id, "type": "text", "text": {"body": content}, } if reply_to_id: payload["context"] = {"message_id": reply_to_id} self._inject_biz_opaque(payload, biz_opaque_data) status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_text failed: {result}") else: logger.debug(f"WhatsApp send_text OK: msg_id={result.get('messages', [{}])[0].get('id')}") async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, recipient_type: str = "individual", *, use_link: bool = False, ) -> None: account = await self._resolve_account(account_id) if not account: return if not media_url: logger.error("WhatsApp send_media: media_url is empty") return media_ref: dict[str, str] if use_link: media_ref = {"link": media_url} else: media_id = await self._upload_media(media_url, media_type, account) if not media_id: logger.error("WhatsApp send_media: media upload failed, aborting") return media_ref = {"id": media_id} payload: dict[str, Any] = { "messaging_product": "whatsapp", "recipient_type": recipient_type, "to": target_id, "type": media_type, media_type: media_ref, } if reply_to_id: payload["context"] = {"message_id": reply_to_id} status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_media failed: {result}") async def send_reaction( self, target_id: str, message_id: str, emoji: str, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return payload = { "messaging_product": "whatsapp", "to": target_id, "type": "reaction", "reaction": {"message_id": message_id, "emoji": emoji}, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_reaction failed: {result}") async def send_sticker( self, target_id: str, media_url: str, *, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return if not media_url: logger.error("WhatsApp send_sticker: media_url is empty") return media_id = await self._upload_media(media_url, "image", account) if not media_id: logger.error("WhatsApp send_sticker: media upload failed, aborting") return payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "sticker", "sticker": {"id": media_id}, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_sticker failed: {result}") async def send_location( self, target_id: str, latitude: float, longitude: float, *, name: str | None = None, address: str | None = None, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return location: dict[str, Any] = {"latitude": str(latitude), "longitude": str(longitude)} if name: location["name"] = name if address: location["address"] = address payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "location", "location": location, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_location failed: {result}") async def send_contacts( self, target_id: str, contacts: list[dict], *, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "contacts", "contacts": contacts, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_contacts failed: {result}") # ── 请求/回复类交互式消息 ───────────────────────────── async def request_location( self, target_id: str, body_text: str, *, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "interactive", "interactive": { "type": "location_request_message", "body": {"text": body_text[:1024]}, }, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp request_location failed: {result}") async def request_address( self, target_id: str, body_text: str, country: str, *, values: list[str] | None = None, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return interactive: dict[str, Any] = { "type": "address_message", "body": {"text": body_text[:1024]}, } if values: interactive["action"] = { "name": "address_message", "parameters": { "country": country, "values": values, }, } else: interactive["action"] = { "name": "address_message", "parameters": {"country": country}, } payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "interactive", "interactive": interactive, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp request_address failed: {result}") async def send_cta_url_button( self, target_id: str, body_text: str, button_text: str, url: str, *, header_text: str | None = None, footer_text: str | None = None, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return interactive: dict[str, Any] = { "type": "cta_url", "body": {"text": body_text[:1024]}, "action": { "name": "cta_url", "parameters": {"display_text": button_text[:20], "url": url}, }, } if header_text: interactive["header"] = {"type": "text", "text": header_text[:60]} if footer_text: interactive["footer"] = {"text": footer_text[:60]} payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "interactive", "interactive": interactive, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_cta_url_button failed: {result}") async def send_text_to_group( self, group_id: str, content: str, *, reply_to_id: str | None = None, account_id: str | None = None, ) -> None: await self.send_text( group_id, content, reply_to_id=reply_to_id, account_id=account_id, recipient_type="group", ) # ── 用户管理 ───────────────────────────────────────────── async def block_user( self, phone_numbers: list[str], account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return phone_number_id = account.get("phone_number_id", "") access_token = account.get("access_token", "") if not phone_number_id or not access_token: logger.error("WhatsApp block_user: missing credentials") return headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } payload = {"messaging_product": "whatsapp", "blocked_numbers": phone_numbers} try: async with aiohttp.ClientSession() as session: url = f"{WHATSAPP_API_BASE}/{phone_number_id}/block_users" async with session.post(url, json=payload, headers=headers) as resp: result = await resp.json() if resp.status == 200: logger.info(f"Blocked users: {phone_numbers}") else: logger.error(f"Block users failed: {result}") except Exception: logger.exception("WhatsApp block_user failed") # ── 模板消息 ───────────────────────────────────────────── async def send_template( self, target_id: str, template_name: str, language_code: str, *, header_params: list[dict] | None = None, body_params: list[dict] | None = None, button_params: list[dict] | None = None, account_id: str | None = None, ) -> str | None: account = await self._resolve_account(account_id) if not account: logger.error("WhatsApp send_template: account not resolved") return None components: list[dict] = [] if header_params: components.append({"type": "header", "parameters": header_params}) if body_params: components.append({"type": "body", "parameters": body_params}) if button_params: components.append({"type": "button", "sub_type": "url", "parameters": button_params}) payload: dict[str, Any] = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "template", "template": { "name": template_name, "language": {"code": language_code}, }, } if components: payload["template"]["components"] = components status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_template failed: {result}") return None msg_id = result.get("messages", [{}])[0].get("id") logger.debug(f"WhatsApp send_template OK: msg_id={msg_id}") return msg_id # ── 交互式消息 ─────────────────────────────────────────── async def send_interactive_buttons( self, target_id: str, body_text: str, buttons: list[dict], *, header_text: str | None = None, footer_text: str | None = None, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return if len(buttons) > 3: logger.warning("WhatsApp supports max 3 reply buttons, truncating") buttons = buttons[:3] interactive: dict[str, Any] = { "type": "button", "body": {"text": body_text[:1024]}, "action": {"buttons": [{"type": "reply", "reply": b} for b in buttons]}, } if header_text: interactive["header"] = {"type": "text", "text": header_text[:60]} if footer_text: interactive["footer"] = {"text": footer_text[:60]} payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "interactive", "interactive": interactive, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_interactive_buttons failed: {result}") async def send_interactive_list( self, target_id: str, body_text: str, button_text: str, sections: list[dict], *, header_text: str | None = None, footer_text: str | None = None, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return interactive: dict[str, Any] = { "type": "list", "body": {"text": body_text[:1024]}, "action": { "button": button_text[:20], "sections": [ { "title": s.get("title", "")[:24], "rows": [ {"id": r["id"], "title": r["title"][:24]} for r in s.get("rows", [])[:10] ], } for s in sections ], }, } if header_text: interactive["header"] = {"type": "text", "text": header_text[:60]} if footer_text: interactive["footer"] = {"text": footer_text[:60]} payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": target_id, "type": "interactive", "interactive": interactive, } status, result = await self._send_with_retry( account.get("phone_number_id", ""), account.get("access_token", ""), payload, ) if status != 200: logger.error(f"WhatsApp send_interactive_list failed: {result}") # ── 已读回执 ───────────────────────────────────────────── async def mark_as_read( self, message_id: str, account_id: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return payload = { "messaging_product": "whatsapp", "status": "read", "message_id": message_id, } async with aiohttp.ClientSession() as session: async with session.post( self._build_url(account.get("phone_number_id", "")), json=payload, headers=self._headers(account.get("access_token", "")), ) as resp: if resp.status != 200: logger.error(f"WhatsApp mark_as_read failed: {await resp.json()}") # ── 媒体管理 ───────────────────────────────────────────── async def _upload_media(self, media_url: str, media_type: str, account: dict) -> str: phone_number_id = account.get("phone_number_id", "") access_token = account.get("access_token", "") if not phone_number_id or not access_token: logger.error("WhatsApp _upload_media: missing credentials") return "" upload_url = f"{WHATSAPP_API_BASE}/{phone_number_id}/media" headers = {"Authorization": f"Bearer {access_token}"} try: if media_url.startswith(("http://", "https://")): async with aiohttp.ClientSession() as session: async with session.get(media_url) as resp: if resp.status != 200: logger.error(f"Failed to download media from {media_url}: HTTP {resp.status}") return "" media_data = await resp.read() content_type = _mime_for_type(media_type) form_data = aiohttp.FormData() form_data.add_field("file", media_data, content_type=content_type) form_data.add_field("messaging_product", "whatsapp") async with aiohttp.ClientSession() as session: async with session.post(upload_url, data=form_data, headers=headers) as resp: result = await resp.json() if resp.status == 200: media_id = result.get("id", "") logger.debug(f"Media uploaded: media_id={media_id}") return media_id logger.error(f"Media upload failed: {result}") return "" else: logger.error(f"Unsupported media URL scheme: {media_url}") return "" except Exception: logger.exception("WhatsApp _upload_media failed") return "" async def download_media( self, media_id: str, *, account_id: str | None = None, ) -> bytes | None: account = await self._resolve_account(account_id) if not account: return None download_url = f"{WHATSAPP_API_BASE}/{media_id}" headers = {"Authorization": f"Bearer {account.get('access_token', '')}"} try: async with aiohttp.ClientSession() as session: async with session.get(download_url, headers=headers) as resp: if resp.status == 200: return await resp.read() logger.error(f"Media download failed: HTTP {resp.status}") return None except Exception: logger.exception("WhatsApp download_media failed") return None # ── 文本处理 ───────────────────────────────────────────── def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: if len(text) <= limit: return [text] chunks = [] while len(text) > limit: chunks.append(text[:limit]) text = text[limit:] if text: chunks.append(text) return chunks def sanitize_text(self, text: str, payload: object) -> str: from yuxi.channel.extensions.whatsapp.format import markdown_to_whatsapp return markdown_to_whatsapp(text) def should_skip_plain_text_sanitization(self, payload: object) -> bool: return False def resolve_target( self, to: str | None = None, *, config: dict | None = None, allow_from: list[str] | None = None, account_id: str | None = None, mode: str | None = None, ) -> tuple[bool, str]: if not to: return False, "target required" return True, to