from __future__ import annotations import io import logging import tempfile import httpx from PIL import Image from yuxi.channel.extensions.viber.config import ViberConfigAdapter from yuxi.channel.extensions.viber.errors import ViberErrorCode from yuxi.channel.extensions.viber.format import chunk_text from yuxi.channel.extensions.viber.rate_limiter import ViberRateLimiter from yuxi.channel.extensions.viber.types import VIBER_API_BASE, ViberKeyboard, ViberRichMedia from yuxi.channel.protocols import OutboundDeliveryMode logger = logging.getLogger(__name__) VIBER_PICTURE_MAX_BYTES = 1 * 1024 * 1024 class ViberOutboundAdapter: delivery_mode = OutboundDeliveryMode.DIRECT chunker_mode = "length" text_chunk_limit: int = 7000 media_max_mb: int = 50 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._config = ViberConfigAdapter() self._rate_limiter = ViberRateLimiter(max_requests=30, per_seconds=1.0) 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, tracking_data: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: logger.error("Viber send_text: account not resolved") return auth_token = account.get("auth_token", "") if not auth_token: return sender_name = account.get("sender_name", "ForcePilot Bot") sender_avatar = account.get("sender_avatar", "") limit = account.get("text_chunk_limit", 7000) chunks = chunk_text(content, limit) for chunk in chunks: await self._send_text_api( auth_token, target_id, chunk, sender_name, sender_avatar, tracking_data=tracking_data, ) 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, tracking_data: str | None = None, ) -> None: account = await self._resolve_account(account_id) if not account: return auth_token = account.get("auth_token", "") if not auth_token: return sender_name = account.get("sender_name", "ForcePilot Bot") sender_avatar = account.get("sender_avatar", "") match media_type: case "image": await self._send_picture_api( auth_token, target_id, media_url, "", sender_name, sender_avatar, tracking_data=tracking_data, ) case "video": await self._send_video_api( auth_token, target_id, media_url, 0, sender_name, sender_avatar, tracking_data=tracking_data, ) case "file": filename = media_url.rsplit("/", 1)[-1] if "/" in media_url else "file" await self._send_file_api( auth_token, target_id, media_url, 0, filename, sender_name, sender_avatar, tracking_data=tracking_data, ) case _: await self._send_text_api( auth_token, target_id, media_url, sender_name, sender_avatar, tracking_data=tracking_data, ) async def send_picture( self, receiver: str, media_url: str, text: str = "", thumbnail_url: str | None = None, account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender_name = account.get("sender_name", "ForcePilot Bot") sender_avatar = account.get("sender_avatar", "") return await self._send_picture_api( auth_token, receiver, media_url, text, sender_name, sender_avatar, thumbnail_url, tracking_data=tracking_data, ) async def send_video( self, receiver: str, media_url: str, size: int, duration: int = 0, thumbnail_url: str | None = None, account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender_name = account.get("sender_name", "ForcePilot Bot") sender_avatar = account.get("sender_avatar", "") return await self._send_video_api( auth_token, receiver, media_url, size, sender_name, sender_avatar, duration, thumbnail_url, tracking_data=tracking_data, ) async def send_file( self, receiver: str, media_url: str, size: int, file_name: str, account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender_name = account.get("sender_name", "ForcePilot Bot") sender_avatar = account.get("sender_avatar", "") return await self._send_file_api( auth_token, receiver, media_url, size, file_name, sender_name, sender_avatar, tracking_data=tracking_data, ) async def send_contact( self, receiver: str, contact_name: str, phone_number: str, account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender = self._build_sender(account) min_api = account.get("min_api_version", 7) payload = { "receiver": receiver, "type": "contact", "contact": {"name": contact_name, "phone_number": phone_number}, "sender": sender, "min_api_version": min_api, } if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) async def send_location( self, receiver: str, lat: float, lon: float, account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender = self._build_sender(account) min_api = account.get("min_api_version", 7) payload = { "receiver": receiver, "type": "location", "location": {"lat": lat, "lon": lon}, "sender": sender, "min_api_version": min_api, } if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) async def send_sticker( self, receiver: str, sticker_id: int, account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender = self._build_sender(account) min_api = account.get("min_api_version", 7) payload = { "receiver": receiver, "type": "sticker", "sticker_id": sticker_id, "sender": sender, "min_api_version": min_api, } if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) async def send_url( self, receiver: str, media_url: str, account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender = self._build_sender(account) min_api = account.get("min_api_version", 7) payload = { "receiver": receiver, "type": "url", "media": media_url, "sender": sender, "min_api_version": min_api, } if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) async def send_picture_auto_convert( self, receiver: str, media_url: str, text: str = "", account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender_name = account.get("sender_name", "ForcePilot Bot") sender_avatar = account.get("sender_avatar", "") ext = media_url.lower().rsplit(".", 1)[-1] if "." in media_url else "" if ext in ("png", "gif"): return await self._send_picture_api( auth_token, receiver, media_url, text, sender_name, sender_avatar, tracking_data=tracking_data ) if not ext or ext not in ("jpg", "jpeg"): logger.warning("Viber picture: unsupported format, auto-converting: %s", media_url) converted_url = await self._convert_to_jpeg(media_url) if converted_url: return await self._send_picture_api( auth_token, receiver, converted_url, text, sender_name, sender_avatar, tracking_data=tracking_data ) return {"status": 1, "status_message": "unsupported image format"} return await self._send_picture_api( auth_token, receiver, media_url, text, sender_name, sender_avatar, tracking_data=tracking_data ) async def send_rich_media( self, receiver: str, rich_media: dict, account_id: str | None = None, tracking_data: str | None = None, ) -> dict: account = await self._resolve_account(account_id) if not account: return {"status": -1, "status_message": "account not resolved"} auth_token = account.get("auth_token", "") sender = self._build_sender(account) min_api = account.get("min_api_version", 7) payload = { "receiver": receiver, "type": "rich_media", "rich_media": rich_media, "sender": sender, "min_api_version": min_api, } if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: return 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 True 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 build_keyboard(keyboard: ViberKeyboard) -> dict: buttons = [] for btn in keyboard.buttons: buttons.append( { "Columns": btn.columns, "Rows": btn.rows, "ActionType": btn.action_type, "ActionBody": btn.action_body, "Text": btn.text, "TextSize": btn.text_size, "BgColor": btn.bg_color, "Silent": btn.silent, } ) return { "Type": "keyboard", "DefaultHeight": keyboard.default_height, "BgColor": keyboard.bg_color, "Buttons": buttons, } @staticmethod def build_rich_media(rich_media: ViberRichMedia) -> dict: buttons = [] for elem in rich_media.buttons: btn = { "Columns": elem.columns, "Rows": elem.rows, "ActionType": elem.action_type, "ActionBody": elem.action_body, "Text": elem.text, "TextSize": elem.text_size, "TextHAlign": elem.text_h_align, "TextVAlign": elem.text_v_align, "BgColor": elem.bg_color, } if elem.image: btn["Image"] = elem.image buttons.append(btn) total_rows = sum(e.rows for e in rich_media.buttons) if rich_media.buttons_group_rows != total_rows: logger.warning( "Viber RichMedia ButtonsGroupRows mismatch: specified=%d actual=%d, auto-correcting", rich_media.buttons_group_rows, total_rows, ) result = { "Type": "rich_media", "ButtonsGroupColumns": rich_media.buttons_group_columns, "ButtonsGroupRows": total_rows, "BgColor": rich_media.bg_color, "Buttons": buttons, } if rich_media.alt_text: result["alt_text"] = rich_media.alt_text return result async def _resolve_account(self, account_id: str | None) -> dict | None: if not account_id: account_id = "default" try: return await self._config.resolve_account(account_id) except Exception: logger.exception("Viber resolve_account error") return None @staticmethod async def _convert_to_jpeg(media_url: str) -> str | None: try: async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client: resp = await client.get(media_url) resp.raise_for_status() image_data = resp.content img = Image.open(io.BytesIO(image_data)) if img.mode not in ("RGB", "L"): img = img.convert("RGB") compressed = ViberOutboundAdapter._compress_jpeg(img) tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) tmp.write(compressed) tmp.close() logger.info("Viber image converted to JPEG: %s → %s", media_url, tmp.name) return tmp.name except Exception: logger.exception("Viber image JPEG conversion failed: %s", media_url) return None @staticmethod def _compress_jpeg(img: Image.Image, max_bytes: int = VIBER_PICTURE_MAX_BYTES) -> bytes: quality = 85 buf = io.BytesIO() img.save(buf, format="JPEG", quality=quality) data = buf.getvalue() while len(data) > max_bytes and quality > 20: quality -= 10 buf = io.BytesIO() img.save(buf, format="JPEG", quality=quality) data = buf.getvalue() if len(data) > max_bytes: max_dim = 2048 while len(data) > max_bytes and max_dim > 512: max_dim = int(max_dim * 0.75) resized = img.copy() resized.thumbnail((max_dim, max_dim)) buf = io.BytesIO() resized.save(buf, format="JPEG", quality=quality) data = buf.getvalue() return data @staticmethod def _build_sender(account: dict) -> dict: sender = {"name": account.get("sender_name", "ForcePilot Bot")} avatar = account.get("sender_avatar", "") if avatar: sender["avatar"] = avatar return sender async def _send_text_api( self, auth_token: str, receiver: str, text: str, sender_name: str, sender_avatar: str, keyboard: dict | None = None, tracking_data: str | None = None, ) -> dict: sender = {"name": sender_name} if sender_avatar: sender["avatar"] = sender_avatar payload = { "receiver": receiver, "type": "text", "text": text, "sender": sender, "min_api_version": 7, } if keyboard: payload["keyboard"] = keyboard if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) async def _send_picture_api( self, auth_token: str, receiver: str, media_url: str, text: str, sender_name: str, sender_avatar: str, thumbnail_url: str | None = None, tracking_data: str | None = None, ) -> dict: sender = {"name": sender_name} if sender_avatar: sender["avatar"] = sender_avatar payload = { "receiver": receiver, "type": "picture", "text": text, "media": media_url, "sender": sender, "min_api_version": 7, } if thumbnail_url: payload["thumbnail"] = thumbnail_url if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) async def _send_video_api( self, auth_token: str, receiver: str, media_url: str, size: int, sender_name: str, sender_avatar: str, duration: int = 0, thumbnail_url: str | None = None, tracking_data: str | None = None, ) -> dict: sender = {"name": sender_name} if sender_avatar: sender["avatar"] = sender_avatar payload = { "receiver": receiver, "type": "video", "media": media_url, "size": size, "sender": sender, "min_api_version": 7, } if duration: payload["duration"] = duration if thumbnail_url: payload["thumbnail"] = thumbnail_url if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) async def _send_file_api( self, auth_token: str, receiver: str, media_url: str, size: int, file_name: str, sender_name: str, sender_avatar: str, tracking_data: str | None = None, ) -> dict: sender = {"name": sender_name} if sender_avatar: sender["avatar"] = sender_avatar payload = { "receiver": receiver, "type": "file", "media": media_url, "size": size, "file_name": file_name, "sender": sender, "min_api_version": 7, } if tracking_data: payload["tracking_data"] = tracking_data return await self._api_call(auth_token, "send_message", payload) async def _api_call(self, auth_token: str, endpoint: str, payload: dict) -> dict: await self._rate_limiter.acquire() headers = { "X-Viber-Auth-Token": auth_token, "Content-Type": "application/json", } try: async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: resp = await client.post( f"{VIBER_API_BASE}/{endpoint}", json=payload, headers=headers, ) resp.raise_for_status() result = resp.json() status = result.get("status") if status != 0: status_msg = result.get("status_message", "") retryable = ViberErrorCode.is_retryable(status) auth_fail = ViberErrorCode.is_auth_error(status) extra = "" if retryable: extra = " [retryable]" if auth_fail: extra += " [auth_failure]" logger.warning( "Viber API %s failed: status=%s message=%s%s", endpoint, status, status_msg, extra, ) billing_status = result.get("billing_status") if billing_status is not None: logger.debug( "Viber billing: endpoint=%s status=%s", endpoint, billing_status, ) return result except Exception: logger.exception("Viber API %s error", endpoint) return {"status": -1, "status_message": "exception"}