ForcePilot/backend/package/yuxi/channel/extensions/line/rich_menu.py
Kris 7fd658af57 feat(channel): 添加 LINE 渠道扩展
新增 LINE 渠道扩展,支持在 Yuxi 平台中集成 LINE 即时通讯渠道。

包含以下功能模块:
- bot: LINE Bot 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- signature: 请求签名验证
- token_manager: Token 管理
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- flex_templates: Flex 模板消息
- card_command: 卡片指令处理
- template_messages: 模板消息
- rich_menu: 富菜单管理
- actions: 动作处理
- directives: 指令处理
- delivery: 消息送达确认
- loading: 加载动画
- media: 媒体资源处理
- types: 类型定义
2026-05-21 11:16:16 +08:00

221 lines
9.0 KiB
Python

"""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)]