import hashlib import hmac import logging from typing import Any import httpx from yuxi.channel.extensions.workplace.errors import classify_error from yuxi.channel.extensions.workplace.types import WorkplaceOutboundResult logger = logging.getLogger(__name__) WORKPLACE_API_BASE = "https://graph.facebook.com/v24.0" TEXT_MAX_CHARS = 2000 QUICK_REPLY_MAX = 13 class WorkplaceOutbound: 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 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, ) -> list[dict]: account = await self._resolve_account(account_id) if not account: logger.error("Workplace send_text: account not resolved") return [{"success": False, "error": "account not resolved"}] chunks = self._chunk_text(content, TEXT_MAX_CHARS) results = [] for chunk in chunks: message: dict[str, Any] = {"text": chunk} if reply_to_id: message["reply_to"] = {"mid": reply_to_id} payload: dict[str, Any] = { "messaging_type": "RESPONSE", "recipient": self._build_recipient(target_id, thread_id), "message": message, } result = await self._post(account, payload) results.append(result) if not result.get("success"): break return results 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, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"success": False, "error": "account not resolved"} messenger_types = {"image": "image", "video": "video", "voice": "audio", "file": "file"} fb_type = messenger_types.get(media_type, "file") message: dict[str, Any] = { "attachment": { "type": fb_type, "payload": {"url": media_url, "is_reusable": True}, } } if reply_to_id: message["reply_to"] = {"mid": reply_to_id} payload = { "messaging_type": "RESPONSE", "recipient": self._build_recipient(target_id, thread_id), "message": message, } result = await self._post(account, payload) return result async def send_typing(self, target_id: str, thread_id: str | None = None) -> None: await self.send_sender_action(target_id, "typing_on") async def clear_typing(self, target_id: str, thread_id: str | None = None) -> None: await self.send_sender_action(target_id, "typing_off") async def send_sender_action( self, target_id: str, action: str, account_id: str | None = None, thread_id: str | None = None, ) -> WorkplaceOutboundResult: account = await self._resolve_account(account_id) if not account: return WorkplaceOutboundResult(success=False) payload = { "recipient": self._build_recipient(target_id, thread_id), "sender_action": action, } result = await self._post(account, payload) return WorkplaceOutboundResult( message_id=result.get("message_id"), recipient_id=target_id, success=result.get("success", False), ) async def send_template( self, target_id: str, template_type: str, elements: list[dict], *, thread_id: str | None = None, account_id: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"success": False, "error": "account not resolved"} payload = { "messaging_type": "RESPONSE", "recipient": self._build_recipient(target_id, thread_id), "message": { "attachment": { "type": "template", "payload": { "template_type": template_type, "elements": elements, }, } }, } result = await self._post(account, payload) return result async def send_quick_replies( self, target_id: str, text: str, quick_replies: list[dict], *, thread_id: str | None = None, account_id: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"success": False, "error": "account not resolved"} payload = { "messaging_type": "RESPONSE", "recipient": self._build_recipient(target_id, thread_id), "message": { "text": text[:2000], "quick_replies": quick_replies[:QUICK_REPLY_MAX], }, } result = await self._post(account, payload) return result async def create_group_chat( self, user_ids: list[str], initial_message: str, *, account_id: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"success": False, "error": "account not resolved"} payload = { "recipient": {"ids": user_ids}, "message": {"text": initial_message[:2000]}, } url = f"{account.get('api_base_url', 'https://graph.facebook.com/v24.0')}/me/messages" params = {"access_token": account["access_token"]} app_secret = account.get("app_secret", "") if app_secret: params["appsecret_proof"] = _compute_appsecret_proof(account["access_token"], app_secret) try: await self._ensure_client() resp = await self._client.post(url, json=payload, params=params) data = resp.json() if resp.status_code == 200: thread_id = data.get("thread_id", data.get("message_id", "")) logger.info("Group chat created: thread_id=%s", thread_id) return {"success": True, "thread_id": thread_id, "data": data} error = classify_error(resp.status_code, data) return {"success": False, "error": str(error)} except httpx.RequestError as exc: logger.error("Workplace create_group_chat network error: %s", exc) return {"success": False, "error": str(exc)} def _build_recipient(self, target_id: str, thread_id: str | None = None) -> dict: if thread_id: return {"thread_key": thread_id} return {"id": target_id} def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: return self._chunk_text(text, limit) 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 def _chunk_text(text: str, limit: int) -> list[str]: if not text: return [""] 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 async def _post(self, account: dict, payload: dict) -> dict: await self._ensure_client() api_version = account.get("graph_api_version", "v24.0") access_token = account.get("access_token", "") url = f"https://graph.facebook.com/{api_version}/me/messages" params = {"access_token": access_token} app_secret = account.get("app_secret", "") if app_secret: params["appsecret_proof"] = _compute_appsecret_proof(access_token, app_secret) try: resp = await self._client.post(url, json=payload, params=params) data = resp.json() if resp.status_code == 200: return { "success": True, "message_id": data.get("message_id", ""), "recipient_id": data.get("recipient_id", ""), } else: error = classify_error(resp.status_code, data) return { "success": False, "message_id": None, "error": error, "is_rate_limited": resp.status_code == 429 or error.subcode == 613, } except httpx.RequestError as exc: logger.error("Workplace send error: %s", exc) return {"success": False, "message_id": None, "error": str(exc)} @staticmethod async def _resolve_account(account_id: str | None = None) -> dict | None: from yuxi.channel.extensions.workplace.config import WorkplaceConfigAdapter adapter = WorkplaceConfigAdapter() aid = account_id or adapter.default_account_id() return await adapter.resolve_account(aid) async def send_location( self, target_id: str, lat: float, lng: float, *, thread_id: str | None = None, account_id: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"success": False, "error": "account not resolved"} payload = { "messaging_type": "RESPONSE", "recipient": self._build_recipient(target_id, thread_id), "message": { "attachment": { "type": "location", "payload": {"coordinates": {"lat": lat, "long": lng}}, } }, } result = await self._post(account, payload) return result async def send_react( self, target_id: str, message_id: str, reaction: str = "smile", *, thread_id: str | None = None, account_id: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"success": False, "error": "account not resolved"} payload = { "messaging_type": "RESPONSE", "recipient": self._build_recipient(target_id, thread_id), "sender_action": "react", "payload": { "reaction": reaction, "message_id": message_id, }, } result = await self._post(account, payload) return result async def send_unreact( self, target_id: str, message_id: str, *, thread_id: str | None = None, account_id: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"success": False, "error": "account not resolved"} payload = { "messaging_type": "RESPONSE", "recipient": self._build_recipient(target_id, thread_id), "sender_action": "unreact", "payload": {"message_id": message_id}, } result = await self._post(account, payload) return result def _compute_appsecret_proof(access_token: str, app_secret: str) -> str: return hmac.new( app_secret.encode("utf-8"), access_token.encode("utf-8"), hashlib.sha256, ).hexdigest()