import logging from typing import Any import httpx from yuxi.channel.extensions.messenger.errors import MessengerErrorKind, classify_error from yuxi.channel.extensions.messenger.types import MessengerOutboundResult logger = logging.getLogger(__name__) MESSENGER_API_BASE = "https://graph.facebook.com/v22.0" TEXT_MAX_CHARS = 2000 QUICK_REPLY_MAX = 13 QUICK_REPLY_TITLE_MAX = 20 BUTTON_TITLE_MAX = 20 GENERIC_CARD_MAX = 10 class MessengerOutbound: delivery_mode = "direct" chunker_mode = "length" text_chunk_limit: int = TEXT_MAX_CHARS poll_max_options: int | None = None supports_poll_duration_seconds = False supports_anonymous_polls = False extract_markdown_images = False presentation_capabilities = None delivery_capabilities = None def __init__(self): self._client: httpx.AsyncClient | None = None async def _ensure_client(self): if self._client is None: self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) async def _close_client(self): if self._client: await self._client.aclose() self._client = None @staticmethod def _build_url(page_id: str) -> str: return f"{MESSENGER_API_BASE}/{page_id}/messages" @staticmethod def _build_send_payload(account: dict, target_id: str, message_payload: dict, tag: str | None = None) -> dict: messaging_type = account.get("default_messaging_type", "RESPONSE") notification_type = account.get("notification_type", "REGULAR") persona_id = account.get("persona_id") payload: dict[str, Any] = { "messaging_type": messaging_type, "recipient": {"id": target_id}, "message": message_payload, } if messaging_type == "MESSAGE_TAG" and tag: payload["tag"] = tag if notification_type and notification_type != "REGULAR": payload["notification_type"] = notification_type if persona_id: payload["persona_id"] = persona_id return payload 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, ) -> MessengerOutboundResult: account = await self._resolve_account(account_id) if not account: return MessengerOutboundResult(success=False, error_message="account not resolved") text = content[:TEXT_MAX_CHARS] message_payload: dict[str, Any] = {"text": text} if reply_to_id: message_payload["reply_to"] = {"mid": reply_to_id} payload = self._build_send_payload(account, target_id, message_payload) return await self._post(account, payload) 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, ) -> MessengerOutboundResult: account = await self._resolve_account(account_id) if not account: return MessengerOutboundResult(success=False, error_message="account not resolved") messenger_types = {"image": "image", "video": "video", "voice": "audio", "file": "file"} fb_type = messenger_types.get(media_type, "file") message_payload = { "attachment": { "type": fb_type, "payload": {"url": media_url, "is_reusable": True}, } } if reply_to_id: message_payload["reply_to"] = {"mid": reply_to_id} payload = self._build_send_payload(account, target_id, message_payload) return await self._post(account, payload) async def send_sender_action( self, target_id: str, action: str, account_id: str | None = None, ) -> MessengerOutboundResult: account = await self._resolve_account(account_id) if not account: return MessengerOutboundResult(success=False, error_message="account not resolved") payload = { "recipient": {"id": target_id}, "sender_action": action, } return await self._post(account, payload) async def send_mark_seen( self, target_id: str, account_id: str | None = None, ) -> MessengerOutboundResult: account = await self._resolve_account(account_id) if not account: return MessengerOutboundResult(success=False, error_message="account not resolved") payload = { "recipient": {"id": target_id}, "sender_action": "mark_seen", } return await self._post(account, payload) async def send_template( self, target_id: str, template_type: str, payload: dict, account_id: str | None = None, ) -> MessengerOutboundResult: account = await self._resolve_account(account_id) if not account: return MessengerOutboundResult(success=False, error_message="account not resolved") message_payload = { "attachment": { "type": "template", "payload": {"template_type": template_type, **payload}, } } body = self._build_send_payload(account, target_id, message_payload) return await self._post(account, body) async def send_quick_replies( self, target_id: str, text: str, items: list[dict], account_id: str | None = None, ) -> MessengerOutboundResult: account = await self._resolve_account(account_id) if not account: return MessengerOutboundResult(success=False, error_message="account not resolved") message_payload = { "text": text[:640], "quick_replies": items[:QUICK_REPLY_MAX], } body = self._build_send_payload(account, target_id, message_payload) return await self._post(account, body) async def _post(self, account: dict, payload: dict) -> MessengerOutboundResult: await self._ensure_client() page_id = account.get("page_id", "") access_token = account.get("page_access_token", "") url = f"{self._build_url(page_id)}?access_token={access_token}" try: resp = await self._client.post(url, json=payload) result = resp.json() if resp.status_code == 200: msg_id = result.get("message_id", "") logger.debug(f"messenger send OK: msg_id={msg_id}") return MessengerOutboundResult(message_id=msg_id, recipient_id=payload["recipient"]["id"], success=True) error = result.get("error", {}) error_code = error.get("code", 0) error_subcode = error.get("error_subcode", 0) error_msg = error.get("message", "") error_kind = classify_error(error_code, error_subcode) logger.error(f"messenger send failed: code={error_code} subcode={error_subcode} msg={error_msg}") return MessengerOutboundResult( success=False, error_code=error_code, error_message=error_msg, recipient_id=payload["recipient"]["id"], is_window_expired=(error_kind == MessengerErrorKind.WINDOW_EXPIRED), is_user_blocked=(error_kind == MessengerErrorKind.USER_BLOCKED), is_rate_limited=(error_kind == MessengerErrorKind.RATE_LIMITED), ) except Exception: logger.exception("messenger send exception") return MessengerOutboundResult(success=False, error_message="network error") 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: return 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 @staticmethod async def _resolve_account(account_id: str | None) -> dict | None: from yuxi.channel.extensions.messenger.config import MessengerConfigAdapter adapter = MessengerConfigAdapter() aid = account_id or adapter.default_account_id() return adapter.resolve_account(aid)