"""LINE Rich Menu API — 持久化菜单管理""" from __future__ import annotations import logging import httpx logger = logging.getLogger(__name__) LINE_API_BASE = "https://api.line.me" def create_default_menu_config() -> dict: return { "size": {"width": 2500, "height": 843}, "selected": True, "name": "Default Menu", "chatBarText": "Tap to open menu", "areas": [ { "bounds": {"x": 0, "y": 0, "width": 833, "height": 843}, "action": {"type": "message", "text": "Help"}, }, { "bounds": {"x": 833, "y": 0, "width": 833, "height": 843}, "action": {"type": "message", "text": "Status"}, }, { "bounds": {"x": 1666, "y": 0, "width": 834, "height": 843}, "action": {"type": "message", "text": "Settings"}, }, ], } class LineRichMenuClient: def __init__(self, channel_access_token: str, timeout: float = 15.0): self._token = channel_access_token self._timeout = timeout def _headers(self) -> dict: return {"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"} async def _post(self, path: str, json: dict | None = None) -> dict | None: try: async with httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) as client: resp = await client.post( f"{LINE_API_BASE}{path}", headers=self._headers(), json=json, ) if resp.status_code in (200, 202): return resp.json() if resp.content else {} logger.warning("LINE rich menu POST %s failed: status=%s", path, resp.status_code) return None except Exception: logger.exception("LINE rich menu POST %s error", path) return None async def _get(self, path: str) -> dict | list | None: try: async with httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) as client: resp = await client.get( f"{LINE_API_BASE}{path}", headers={"Authorization": f"Bearer {self._token}"}, ) if resp.status_code == 200: return resp.json() logger.warning("LINE rich menu GET %s failed: status=%s", path, resp.status_code) return None except Exception: logger.exception("LINE rich menu GET %s error") return None async def _delete(self, path: str) -> bool: try: async with httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) as client: resp = await client.delete( f"{LINE_API_BASE}{path}", headers={"Authorization": f"Bearer {self._token}"}, ) return resp.status_code in (200, 202) except Exception: logger.exception("LINE rich menu DELETE %s error") return False async def create_rich_menu(self, menu_config: dict) -> str | None: result = await self._post("/v2/bot/richmenu", menu_config) return result.get("richMenuId") if isinstance(result, dict) else None async def upload_rich_menu_image(self, menu_id: str, image_data: bytes, content_type: str = "image/png") -> bool: try: async with httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) as client: resp = await client.post( f"{LINE_API_BASE}/v2/bot/richmenu/{menu_id}/content", headers={ "Authorization": f"Bearer {self._token}", "Content-Type": content_type, }, content=image_data, ) return resp.status_code in (200, 202) except Exception: logger.exception("LINE upload rich menu image error") return False async def set_default_rich_menu(self, menu_id: str) -> bool: result = await self._post(f"/v2/bot/user/all/richmenu/{menu_id}") return result is not None async def link_rich_menu_to_user(self, user_id: str, menu_id: str) -> bool: result = await self._post(f"/v2/bot/user/{user_id}/richmenu/{menu_id}") return result is not None async def unlink_rich_menu_from_user(self, user_id: str) -> bool: return await self._delete(f"/v2/bot/user/{user_id}/richmenu") async def delete_rich_menu(self, menu_id: str) -> bool: return await self._delete(f"/v2/bot/richmenu/{menu_id}") async def get_rich_menu_list(self) -> list | None: result = await self._get("/v2/bot/richmenu/list") return result if isinstance(result, list) else None async def get_default_rich_menu_id(self) -> str | None: result = await self._get("/v2/bot/user/all/richmenu") return result.get("richMenuId") if isinstance(result, dict) else None async def cancel_default_rich_menu(self) -> bool: return await self._delete("/v2/bot/user/all/richmenu") async def bulk_link_rich_menu(self, user_ids: list[str], menu_id: str) -> bool: for batch in _batch_list(user_ids, 500): result = await self._post( "/v2/bot/richmenu/bulk/link", {"richMenuId": menu_id, "userIds": batch}, ) if result is None: return False return True async def bulk_unlink_rich_menu(self, user_ids: list[str]) -> bool: for batch in _batch_list(user_ids, 500): result = await self._post( "/v2/bot/richmenu/bulk/unlink", {"userIds": batch}, ) if result is None: return False return True async def get_rich_menu(self, menu_id: str) -> dict | None: result = await self._get(f"/v2/bot/richmenu/{menu_id}") return result if isinstance(result, dict) else None async def create_rich_menu_alias(self, alias_id: str, menu_id: str) -> bool: result = await self._post("/v2/bot/richmenu/alias", {"richMenuAliasId": alias_id, "richMenuId": menu_id}) return result is not None async def get_rich_menu_alias(self, alias_id: str) -> dict | None: result = await self._get(f"/v2/bot/richmenu/alias/{alias_id}") return result if isinstance(result, dict) else None async def update_rich_menu_alias(self, alias_id: str, menu_id: str) -> bool: result = await self._post(f"/v2/bot/richmenu/alias/{alias_id}", {"richMenuId": menu_id}) return result is not None async def delete_rich_menu_alias(self, alias_id: str) -> bool: return await self._delete(f"/v2/bot/richmenu/alias/{alias_id}") async def download_rich_menu_image(self, menu_id: str) -> bytes | None: try: async with httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) as client: resp = await client.get( f"{LINE_API_BASE}/v2/bot/richmenu/{menu_id}/content", headers={"Authorization": f"Bearer {self._token}"}, ) if resp.status_code == 200: return resp.content logger.warning("LINE download rich menu image failed: status=%s", resp.status_code) except Exception: logger.exception("LINE download rich menu image error") return None @staticmethod def validate_rich_menu_object(menu: dict) -> tuple[bool, str | None]: required = ["size", "selected", "name", "chatBarText", "areas"] for field in required: if field not in menu: return False, f"缺少必填字段: {field}" size = menu.get("size", {}) if not isinstance(size, dict) or "width" not in size or "height" not in size: return False, "size 必须包含 width 和 height" width, height = size.get("width", 0), size.get("height", 0) if width <= 0 or height <= 0 or width > 2500 or height > 1686: return False, f"size 无效: {width}x{height} (上限 2500x1686)" areas = menu.get("areas", []) if not isinstance(areas, list) or len(areas) == 0 or len(areas) > 20: return False, "areas 必须为非空列表,最多 20 个区域" for i, area in enumerate(areas): if not isinstance(area, dict): return False, f"areas[{i}] 必须是对象" bounds = area.get("bounds", {}) if not isinstance(bounds, dict) or not all(k in bounds for k in ("x", "y", "width", "height")): return False, f"areas[{i}].bounds 必须包含 x, y, width, height" action = area.get("action", {}) if not isinstance(action, dict) or "type" not in action: return False, f"areas[{i}].action 必须包含 type" return True, None def _batch_list(items: list, batch_size: int) -> list[list]: return [items[i: i + batch_size] for i in range(0, len(items), batch_size)]