ForcePilot/backend/package/yuxi/channel/extensions/line/bot.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

521 lines
23 KiB
Python

from __future__ import annotations
import logging
from dataclasses import dataclass
import httpx
from yuxi.channel.extensions.line.types import LineBotProfile
logger = logging.getLogger(__name__)
LINE_API_BASE = "https://api.line.me"
LINE_API_DATA_BASE = "https://api-data.line.me"
@dataclass
class LineBotClient:
channel_access_token: str
timeout: float = 10.0
async def get_bot_info(self) -> LineBotProfile | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/info",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return LineBotProfile.from_api_response(resp.json())
logger.warning("LINE get_bot_info failed: status=%s body=%s", resp.status_code, resp.text[:200])
except Exception:
logger.exception("LINE get_bot_info error")
return None
async def get_message_content(self, message_id: str) -> bytes | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_DATA_BASE}/v2/bot/message/{message_id}/content",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.content
logger.warning("LINE get_message_content failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_message_content error")
return None
async def get_message_content_preview(self, message_id: str) -> bytes | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_DATA_BASE}/v2/bot/message/{message_id}/content/preview",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.content
logger.warning("LINE get_message_content_preview failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_message_content_preview error")
return None
async def verify_content_transcoding(self, message_id: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_DATA_BASE}/v2/bot/message/{message_id}/content/transcoding",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE verify_content_transcoding failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE verify_content_transcoding error")
return None
async def show_loading_animation(self, user_id: str, loading_seconds: int = 20) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/chat/loading/start",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json={"chatId": user_id, "loadingSeconds": loading_seconds},
)
return resp.status_code == 202
except Exception:
logger.exception("LINE show_loading_animation error")
return False
async def reply_message(self, reply_token: str, messages: list[dict], *, quote_token: str | None = None) -> bool:
try:
body: dict = {"replyToken": reply_token, "messages": messages}
if quote_token:
body["quoteToken"] = quote_token
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/message/reply",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json=body,
)
if resp.status_code == 200:
return True
body_data = resp.json()
logger.warning(
"LINE reply_message failed: status=%s message=%s",
resp.status_code,
body_data.get("message", "") if isinstance(body_data, dict) else "",
)
except Exception:
logger.exception("LINE reply_message error")
return False
async def push_message(self, to: str, messages: list[dict], *, sender: dict | None = None) -> bool:
try:
body: dict = {"to": to, "messages": messages}
if sender:
body["sender"] = sender
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/message/push",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json=body,
)
if resp.status_code == 200:
return True
logger.warning(
"LINE push_message failed: status=%s body=%s", resp.status_code, resp.text[:200]
)
except Exception:
logger.exception("LINE push_message error")
return False
async def multicast_message(
self, to: list[str], messages: list[dict],
*, notification_disabled: bool = False, sender: dict | None = None,
) -> bool:
try:
body: dict = {
"to": to[:500],
"messages": messages,
"notificationDisabled": notification_disabled,
}
if sender:
body["sender"] = sender
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/message/multicast",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json=body,
)
if resp.status_code == 200:
return True
logger.warning("LINE multicast_message failed: status=%s body=%s", resp.status_code, resp.text[:200])
except Exception:
logger.exception("LINE multicast_message error")
return False
async def broadcast_message(self, messages: list[dict], *, notification_disabled: bool = False) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/message/broadcast",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json={"messages": messages, "notificationDisabled": notification_disabled},
)
if resp.status_code == 200:
return True
logger.warning("LINE broadcast_message failed: status=%s body=%s", resp.status_code, resp.text[:200])
except Exception:
logger.exception("LINE broadcast_message error")
return False
async def narrowcast_message(
self, messages: list[dict],
*,
recipient: dict | None = None,
filter: dict | None = None,
limit: dict | None = None,
notification_disabled: bool = False,
) -> bool:
try:
body: dict = {"messages": messages, "notificationDisabled": notification_disabled}
if recipient:
body["recipient"] = recipient
if filter:
body["filter"] = filter
if limit:
body["limit"] = limit
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/message/narrowcast",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json=body,
)
if resp.status_code in (200, 202):
return True
logger.warning("LINE narrowcast_message failed: status=%s body=%s", resp.status_code, resp.text[:200])
except Exception:
logger.exception("LINE narrowcast_message error")
return False
async def get_user_profile(self, user_id: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/profile/{user_id}",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_user_profile failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_user_profile error")
return None
async def get_group_member_profile(self, group_id: str, user_id: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/group/{group_id}/member/{user_id}",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_group_member_profile failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_group_member_profile error")
return None
async def get_room_member_profile(self, room_id: str, user_id: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/room/{room_id}/member/{user_id}",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_room_member_profile failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_room_member_profile error")
return None
async def get_group_member_ids(self, group_id: str, continuation_token: str | None = None) -> dict | None:
try:
params = {}
if continuation_token:
params["start"] = continuation_token
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/group/{group_id}/members/ids",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
params=params,
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_group_member_ids failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_group_member_ids error")
return None
async def get_room_member_ids(self, room_id: str, continuation_token: str | None = None) -> dict | None:
try:
params = {}
if continuation_token:
params["start"] = continuation_token
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/room/{room_id}/members/ids",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
params=params,
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_room_member_ids failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_room_member_ids error")
return None
async def get_followers_count(self) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/followers/count",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_followers_count failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_followers_count error")
return None
async def get_friend_demographics(self) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/demographic",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_friend_demographics failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_friend_demographics error")
return None
async def mark_as_read(self, user_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/chat/members/markAsRead",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json={"userId": user_id},
)
return resp.status_code == 200
except Exception:
logger.exception("LINE mark_as_read error")
return False
async def get_message_quota(self) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/message/quota",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_message_quota failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_message_quota error")
return None
async def get_message_consumption(self) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/message/quota/consumption",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_message_consumption failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_message_consumption error")
return None
async def get_message_delivery_count(self, delivery_type: str, date: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/message/delivery/{delivery_type}",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
params={"date": date},
)
if resp.status_code == 200:
return resp.json()
logger.warning(
"LINE get_message_delivery_count(%s) failed: status=%s",
delivery_type, resp.status_code,
)
except Exception:
logger.exception("LINE get_message_delivery_count error")
return None
async def get_user_interaction_stats(self, date: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/analytics/event",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
params={"date": date},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_user_interaction_stats failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_user_interaction_stats error")
return None
async def create_audience_group(self, description: str, audiences: list[dict]) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/audienceGroup/upload",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json={"description": description, "audiences": audiences},
)
if resp.status_code in (200, 202):
return resp.json()
logger.warning("LINE create_audience_group failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE create_audience_group error")
return None
async def create_audience_group_by_file(self, description: str, file_url: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(
f"{LINE_API_BASE}/v2/bot/audienceGroup/upload/byFile",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json={"description": description, "fileUrl": file_url},
)
if resp.status_code in (200, 202):
return resp.json()
logger.warning("LINE create_audience_group_by_file failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE create_audience_group_by_file error")
return None
async def add_users_to_audience(self, audience_group_id: str, audiences: list[dict]) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.put(
f"{LINE_API_BASE}/v2/bot/audienceGroup/upload",
headers={
"Authorization": f"Bearer {self.channel_access_token}",
"Content-Type": "application/json",
},
json={"audienceGroupId": audience_group_id, "audiences": audiences},
)
return resp.status_code in (200, 202)
except Exception:
logger.exception("LINE add_users_to_audience error")
return False
async def get_audience_groups(self, page: int = 1, size: int = 40) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/audienceGroup/list",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
params={"page": page, "size": size},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_audience_groups failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_audience_groups error")
return None
async def delete_audience_group(self, audience_group_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.delete(
f"{LINE_API_BASE}/v2/bot/audienceGroup/{audience_group_id}",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
)
return resp.status_code in (200, 202)
except Exception:
logger.exception("LINE delete_audience_group error")
return False
async def get_statistics_per_unit(
self, custom_aggregation_unit: str, from_date: str, to_date: str,
) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/statistics/unit",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
params={
"customAggregationUnit": custom_aggregation_unit,
"from": from_date,
"to": to_date,
},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_statistics_per_unit failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_statistics_per_unit error")
return None
async def get_narrowcast_progress(self, request_id: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.get(
f"{LINE_API_BASE}/v2/bot/message/progress/narrowcast",
headers={"Authorization": f"Bearer {self.channel_access_token}"},
params={"requestId": request_id},
)
if resp.status_code == 200:
return resp.json()
logger.warning("LINE get_narrowcast_progress failed: status=%s", resp.status_code)
except Exception:
logger.exception("LINE get_narrowcast_progress error")
return None