from __future__ import annotations import asyncio import httpx from yuxi.channels.models import DeliveryResult from yuxi.utils.logging_config import logger LINE_API_BASE = "https://api.line.me/v2/bot" LINE_DATA_API_BASE = "https://api-data.line.me/v2/bot" _MAX_RETRIES = 3 _BASE_BACKOFF = 0.5 class LINESender: def __init__(self, channel_access_token: str, timeout: float = 30, proxy: str | None = None): self._token = channel_access_token self._timeout = timeout self._proxy = proxy self._client: httpx.AsyncClient | None = None async def __aenter__(self): client_kwargs: dict = { "base_url": LINE_API_BASE, "headers": {"Authorization": f"Bearer {self._token}"}, "timeout": self._timeout, } if self._proxy: client_kwargs["proxy"] = self._proxy self._client = httpx.AsyncClient(**client_kwargs) return self async def __aexit__(self, *args): if self._client: await self._client.aclose() self._client = None async def push_message(self, to: str, messages: list[dict]) -> DeliveryResult: payload = {"to": to, "messages": messages[:5]} return await self._post_with_retry("/message/push", payload, api_name="push") async def multicast_message(self, to: list[str], messages: list[dict]) -> DeliveryResult: if len(to) > 500: logger.warning(f"LINE multicast: truncating recipients from {len(to)} to 500") to = to[:500] payload = {"to": to, "messages": messages[:5]} return await self._post_with_retry("/message/multicast", payload, api_name="multicast") async def broadcast_message(self, messages: list[dict]) -> DeliveryResult: payload = {"messages": messages[:5]} return await self._post_with_retry("/message/broadcast", payload, api_name="broadcast") async def reply_message(self, reply_token: str, messages: list[dict]) -> DeliveryResult: payload = {"replyToken": reply_token, "messages": messages[:5]} return await self._post_with_retry("/message/reply", payload, api_name="reply") async def show_loading_animation(self, chat_id: str, seconds: int = 20) -> None: if not self._client: return seconds = min(max(seconds, 5), 60) try: resp = await self._client.post( "/chat/loading/start", json={"chatId": chat_id, "loadingSeconds": seconds}, ) if resp.status_code == 202: logger.debug(f"LINE loading animation sent to {chat_id} ({seconds}s)") else: logger.debug(f"LINE loading animation failed: {resp.status_code}") except Exception as e: logger.debug(f"LINE loading animation exception (non-fatal): {e}") async def get_bot_info(self) -> dict | None: if not self._client: return None try: resp = await self._client.get("/info") if resp.status_code == 200: return resp.json() return None except Exception: return None async def get_profile(self, user_id: str) -> dict | None: if not self._client: return None try: resp = await self._client.get(f"/profile/{user_id}") if resp.status_code == 200: return resp.json() return None except Exception: return None async def get_user_display_name(self, user_id: str) -> str | None: profile = await self.get_profile(user_id) if profile: return profile.get("displayName") return None async def get_group_member_profile(self, group_id: str, user_id: str) -> dict | None: if not self._client: return None try: resp = await self._client.get(f"/group/{group_id}/member/{user_id}") if resp.status_code == 200: return resp.json() return None except Exception: return None async def get_room_member_profile(self, room_id: str, user_id: str) -> dict | None: if not self._client: return None try: resp = await self._client.get(f"/room/{room_id}/member/{user_id}") if resp.status_code == 200: return resp.json() return None except Exception: return None async def get_group_summary(self, group_id: str) -> dict | None: if not self._client: return None try: resp = await self._client.get(f"/group/{group_id}/summary") if resp.status_code == 200: return resp.json() return None except Exception: return None async def get_group_member_count(self, group_id: str) -> int | None: if not self._client: return None try: resp = await self._client.get(f"/group/{group_id}/members/count") if resp.status_code == 200: data = resp.json() return data.get("count") return None except Exception: return None async def get_message_content(self, message_id: str) -> bytes | None: if not self._client: return None try: resp = await self._client.get( f"/message/{message_id}/content", base_url=LINE_DATA_API_BASE, ) if resp.status_code == 200: return resp.content logger.warning(f"LINE get_message_content failed: {resp.status_code}") return None except Exception as e: logger.warning(f"LINE get_message_content exception: {e}") return None async def get_message_quota(self) -> dict | None: if not self._client: return None try: resp = await self._client.get("/message/quota") if resp.status_code == 200: return resp.json() return None except Exception: return None async def get_message_quota_consumption(self) -> dict | None: if not self._client: return None try: resp = await self._client.get("/message/quota/consumption") if resp.status_code == 200: return resp.json() return None except Exception: return None async def get_delivery_status(self, date: str, msg_type: str = "push") -> dict | None: if not self._client: return None if msg_type not in ("push", "reply", "multicast", "broadcast"): return None try: resp = await self._client.get( f"/message/delivery/{msg_type}", params={"date": date}, ) if resp.status_code == 200: return resp.json() return None except Exception: return None async def mark_as_read(self, chat_id: str) -> bool: if not self._client: return False try: resp = await self._client.post( "/chat/markAsRead", json={"chatId": chat_id}, ) return resp.status_code == 200 except Exception: return False async def send_file(self, to: str, original_content_url: str, file_name: str, file_size: int) -> DeliveryResult: payload = { "to": to, "messages": [ { "type": "file", "originalContentUrl": original_content_url, "fileName": file_name[:600], "fileSize": file_size, } ], } return await self._post_with_retry("/message/push", payload, api_name="file") async def send_sticker( self, to: str, package_id: str, sticker_id: str, reply_token: str | None = None ) -> DeliveryResult: sticker_msg = { "type": "sticker", "packageId": package_id, "stickerId": sticker_id, } payload = {"to": to, "messages": [sticker_msg]} if reply_token: return await self._post_with_retry( "/message/reply", {"replyToken": reply_token, "messages": [sticker_msg]}, api_name="sticker_reply", ) return await self._post_with_retry("/message/push", payload, api_name="sticker") async def validate_push(self, messages: list[dict]) -> bool: return await self._validate_messages("/message/validate/push", {"messages": messages[:5]}) async def validate_reply(self, messages: list[dict]) -> bool: return await self._validate_messages("/message/validate/reply", {"messages": messages[:5]}) async def validate_multicast(self, user_ids: list[str], messages: list[dict]) -> bool: return await self._validate_messages( "/message/validate/multicast", {"to": user_ids[:500], "messages": messages[:5]}, ) async def validate_broadcast(self, messages: list[dict]) -> bool: return await self._validate_messages("/message/validate/broadcast", {"messages": messages[:5]}) async def _validate_messages(self, path: str, payload: dict) -> bool: if not self._client: return False try: resp = await self._client.post(path, json=payload) return resp.status_code == 200 except Exception: return False async def get_rich_menus(self) -> list[dict] | None: if not self._client: return None try: resp = await self._client.get("/richmenu/list") if resp.status_code == 200: data = resp.json() return data.get("richmenus", []) return None except Exception: return None async def get_rich_menu(self, rich_menu_id: str) -> dict | None: if not self._client: return None try: resp = await self._client.get(f"/richmenu/{rich_menu_id}") if resp.status_code == 200: return resp.json() return None except Exception: return None async def create_rich_menu(self, rich_menu: dict) -> str | None: if not self._client: return None try: resp = await self._client.post("/richmenu", json=rich_menu) if resp.status_code == 200: return resp.json().get("richMenuId") return None except Exception: return None async def delete_rich_menu(self, rich_menu_id: str) -> bool: if not self._client: return False try: resp = await self._client.delete(f"/richmenu/{rich_menu_id}") return resp.status_code == 200 except Exception: return False async def set_rich_menu_image(self, rich_menu_id: str, image_bytes: bytes, content_type: str = "image/png") -> bool: if not self._client: return False try: resp = await self._client.post( f"/richmenu/{rich_menu_id}/content", content=image_bytes, headers={"Content-Type": content_type}, ) return resp.status_code == 200 except Exception: return False async def link_rich_menu_to_user(self, user_id: str, rich_menu_id: str) -> bool: if not self._client: return False try: resp = await self._client.post( f"/user/{user_id}/richmenu/{rich_menu_id}", ) return resp.status_code == 200 except Exception: return False async def unlink_rich_menu_from_user(self, user_id: str) -> bool: if not self._client: return False try: resp = await self._client.delete(f"/user/{user_id}/richmenu") return resp.status_code == 200 except Exception: return False async def get_user_rich_menu(self, user_id: str) -> dict | None: if not self._client: return None try: resp = await self._client.get(f"/user/{user_id}/richmenu") if resp.status_code == 200: return resp.json() return None except Exception: return None async def link_rich_menu_to_users(self, user_ids: list[str], rich_menu_id: str) -> bool: if not self._client: return False if len(user_ids) > 150: logger.warning(f"LINE bulk link: truncating user_ids from {len(user_ids)} to 150") user_ids = user_ids[:150] try: resp = await self._client.post( "/richmenu/bulk/link", json={"userIds": user_ids, "richMenuId": rich_menu_id}, ) return resp.status_code in (200, 202) except Exception: return False async def set_default_rich_menu(self, rich_menu_id: str) -> bool: if not self._client: return False try: resp = await self._client.post( f"/user/all/richmenu/{rich_menu_id}", ) return resp.status_code in (200, 202) except Exception: return False async def cancel_default_rich_menu(self) -> bool: if not self._client: return False try: resp = await self._client.delete("/user/all/richmenu") return resp.status_code in (200, 202) except Exception: return False async def link_rich_menu_to_multiple_users(self, user_ids: list[str], rich_menu_id: str) -> bool: if not self._client: return False batch_size = 500 success = True for i in range(0, len(user_ids), batch_size): batch = user_ids[i : i + batch_size] try: resp = await self._client.post( "/richmenu/bulk/link", json={"userIds": batch, "richMenuId": rich_menu_id}, ) if resp.status_code not in (200, 202): success = False except Exception: success = False return success async def unlink_rich_menu_from_multiple_users(self, user_ids: list[str]) -> bool: if not self._client: return False batch_size = 500 success = True for i in range(0, len(user_ids), batch_size): batch = user_ids[i : i + batch_size] try: resp = await self._client.post( "/richmenu/bulk/unlink", json={"userIds": batch}, ) if resp.status_code not in (200, 202): success = False except Exception: success = False return success async def get_rich_menu_image(self, rich_menu_id: str) -> bytes | None: if not self._client: return None try: resp = await self._client.get(f"/richmenu/{rich_menu_id}/content") if resp.status_code == 200: return resp.content return None except Exception: return None async def validate_rich_menu_object(self, rich_menu: dict) -> bool: if not self._client: return False try: resp = await self._client.post("/richmenu/validate", json=rich_menu) return resp.status_code == 200 except Exception: return False async def get_rich_menu_alias_list(self) -> list[dict] | None: if not self._client: return None try: resp = await self._client.get("/richmenu/alias/list") if resp.status_code == 200: data = resp.json() return data.get("aliases", []) return None except Exception: return None async def create_rich_menu_alias(self, alias_name: str, rich_menu_id: str) -> bool: if not self._client: return False try: resp = await self._client.post( "/richmenu/alias", json={"richMenuAliasId": alias_name, "richMenuId": rich_menu_id}, ) return resp.status_code in (200, 202) except Exception: return False async def update_rich_menu_alias(self, alias_name: str, rich_menu_id: str) -> bool: return await self.create_rich_menu_alias(alias_name, rich_menu_id) async def delete_rich_menu_alias(self, alias_name: str) -> bool: if not self._client: return False try: resp = await self._client.delete(f"/richmenu/alias/{alias_name}") return resp.status_code in (200, 202) except Exception: return False async def get_rich_menu_by_alias(self, alias_name: str) -> dict | None: if not self._client: return None try: resp = await self._client.get(f"/richmenu/alias/{alias_name}") if resp.status_code == 200: return resp.json() return None except Exception: return None async def _post_with_retry(self, path: str, payload: dict, api_name: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") last_error: str | None = None for attempt in range(_MAX_RETRIES): try: resp = await self._client.post(path, json=payload) except httpx.TimeoutException: last_error = f"{api_name} request timeout" logger.warning(f"LINE {last_error} (attempt {attempt + 1})") if attempt < _MAX_RETRIES - 1: await asyncio.sleep(_BASE_BACKOFF * (2**attempt)) continue return DeliveryResult(success=False, error=last_error) except httpx.NetworkError as e: last_error = f"{api_name} network error: {e}" logger.warning(f"LINE {last_error} (attempt {attempt + 1})") if attempt < _MAX_RETRIES - 1: await asyncio.sleep(_BASE_BACKOFF * (2**attempt)) continue return DeliveryResult(success=False, error=last_error) except httpx.HTTPError as e: last_error = f"{api_name} HTTP client error: {e}" logger.warning(f"LINE {last_error} (attempt {attempt + 1})") if attempt < _MAX_RETRIES - 1: await asyncio.sleep(_BASE_BACKOFF * (2**attempt)) continue return DeliveryResult(success=False, error=last_error) if resp.status_code == 200: return DeliveryResult(success=True, message_id=api_name) if resp.status_code == 429: retry_after = _parse_retry_after(resp.headers.get("Retry-After", "")) wait = retry_after if retry_after > 0 else 1.0 logger.warning(f"LINE {api_name} rate limited (429), waiting {wait:.1f}s") await asyncio.sleep(wait) last_error = "rate limited (429)" continue if resp.status_code == 401: _safe_text = resp.text[:100] if resp.text else "" logger.error(f"LINE {api_name} auth failed (401)") return DeliveryResult( success=False, error="Authentication failed (401)", ) if resp.status_code == 403: logger.error(f"LINE {api_name} forbidden (403)") return DeliveryResult( success=False, error="Forbidden (403)", ) if resp.status_code >= 500: last_error = f"server error ({resp.status_code})" logger.warning(f"LINE {api_name} {last_error} (attempt {attempt + 1})") if attempt < _MAX_RETRIES - 1: await asyncio.sleep(_BASE_BACKOFF * (2**attempt)) continue return DeliveryResult(success=False, error=last_error) logger.error(f"LINE {api_name} failed ({resp.status_code})") return DeliveryResult( success=False, error=f"HTTP {resp.status_code}", ) return DeliveryResult(success=False, error=last_error or "max retries exceeded") def _parse_retry_after(value: str) -> float: try: return float(value) except (ValueError, TypeError): return 1.0