ForcePilot/backend/package/yuxi/channels/adapters/line/send.py
Kris a4ec94ef9d feat(line): 实现完整的 LINE 聊天适配器功能
新增 LINE 官方账号对接的全套功能,包括:
1. 基础的 Bot 探测、会话解析、消息格式化能力
2. 富媒体消息模板、快速回复、卡片指令支持
3. Webhook 签名验证、重放防护、多账户路由管理
4. 消息发送、回复、分块传输、用户绑定管理
5. 交互式配置向导与诊断工具
2026-05-12 00:45:33 +08:00

572 lines
20 KiB
Python

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