新增 LINE 官方账号对接的全套功能,包括: 1. 基础的 Bot 探测、会话解析、消息格式化能力 2. 富媒体消息模板、快速回复、卡片指令支持 3. Webhook 签名验证、重放防护、多账户路由管理 4. 消息发送、回复、分块传输、用户绑定管理 5. 交互式配置向导与诊断工具
572 lines
20 KiB
Python
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
|