新增 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: 类型定义
415 lines
14 KiB
Python
415 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Header, Request
|
|
from fastapi.responses import PlainTextResponse
|
|
|
|
from yuxi.channel.extensions.line.bot import LineBotClient
|
|
from yuxi.channel.extensions.line.config import ENV_CHANNEL_ACCESS_TOKEN, ENV_CHANNEL_SECRET, LineConfigAdapter
|
|
from yuxi.channel.extensions.line.dedupe import LineEventDeduplicator, build_event_dedupe_key
|
|
from yuxi.channel.extensions.line.monitor import LineMonitor
|
|
from yuxi.channel.extensions.line.signature import match_signature_against_accounts, validate_line_signature
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024
|
|
LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_S = 5.0
|
|
|
|
router = APIRouter(prefix="/webhook/line", tags=["line"])
|
|
|
|
_config = LineConfigAdapter()
|
|
_monitor = LineMonitor()
|
|
_dedupe = LineEventDeduplicator()
|
|
_webhook_running = False
|
|
|
|
|
|
def set_webhook_running(running: bool) -> None:
|
|
global _webhook_running
|
|
_webhook_running = running
|
|
|
|
|
|
def set_webhook_config(config: dict) -> None:
|
|
_config.list_account_ids(config)
|
|
group_configs = _config.resolve_group_configs()
|
|
_monitor.set_group_configs(group_configs)
|
|
|
|
|
|
@router.post("/callback")
|
|
async def line_webhook_callback(
|
|
request: Request,
|
|
x_line_signature: str = Header("", alias="X-Line-Signature"),
|
|
):
|
|
if not _webhook_running:
|
|
return PlainTextResponse("", status_code=503)
|
|
|
|
body = await _read_body_with_limit(request)
|
|
if body is None:
|
|
return PlainTextResponse("", status_code=413)
|
|
|
|
if not x_line_signature:
|
|
return PlainTextResponse("", status_code=401)
|
|
|
|
account, account_id, token = await _resolve_account_for_webhook(body, x_line_signature)
|
|
if not account:
|
|
return PlainTextResponse("", status_code=401)
|
|
|
|
payload = _monitor.parse_webhook_body(body)
|
|
events = _monitor.parse_events(payload)
|
|
|
|
group_configs = _config.resolve_group_configs()
|
|
_monitor.set_group_configs(group_configs)
|
|
|
|
for event in events:
|
|
event_type = event.get("type", "")
|
|
|
|
if event_type == "postback":
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if _dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
um = _monitor.parse_postback_to_unified(event, account_id, token)
|
|
if um:
|
|
asyncio.create_task(
|
|
_dispatch_to_agent(um),
|
|
name=f"line-postback-{um.sender.id}",
|
|
)
|
|
continue
|
|
|
|
if event_type == "follow":
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if _dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
um = _monitor.parse_follow_to_unified(event, account_id, token)
|
|
if um:
|
|
asyncio.create_task(
|
|
_dispatch_to_agent(um),
|
|
name=f"line-follow-{um.sender.id}",
|
|
)
|
|
continue
|
|
|
|
if event_type != "message":
|
|
_log_non_message_event(account_id, event, event_type)
|
|
continue
|
|
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if _dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
|
|
display_name = await _fetch_display_name(token, event)
|
|
um = _monitor.parse_event_to_unified(event, account_id, token, display_name=display_name)
|
|
if um:
|
|
asyncio.create_task(
|
|
_dispatch_to_agent(um),
|
|
name=f"line-dispatch-{um.sender.id}",
|
|
)
|
|
|
|
return PlainTextResponse("ok")
|
|
|
|
|
|
async def _resolve_account_for_webhook(body: bytes, signature: str) -> tuple[dict | None, str, str]:
|
|
account_ids = _config.list_account_ids({})
|
|
accounts = []
|
|
for aid in account_ids:
|
|
try:
|
|
acct = await _config.resolve_account(aid)
|
|
if acct:
|
|
accounts.append(acct)
|
|
except Exception:
|
|
logger.exception("LINE resolve_account error for %s", aid)
|
|
|
|
if len(accounts) == 1:
|
|
acct = accounts[0]
|
|
if validate_line_signature(body, signature, acct.get("channel_secret", "")):
|
|
return acct, acct.get("account_id", "default"), acct.get("channel_access_token", "")
|
|
return None, "", ""
|
|
|
|
if len(accounts) > 1:
|
|
matched = match_signature_against_accounts(body, signature, accounts)
|
|
if matched:
|
|
return matched, matched.get("account_id", "default"), matched.get("channel_access_token", "")
|
|
return None, "", ""
|
|
|
|
secret = os.environ.get(ENV_CHANNEL_SECRET, "")
|
|
token = os.environ.get(ENV_CHANNEL_ACCESS_TOKEN, "")
|
|
if secret and token and validate_line_signature(body, signature, secret):
|
|
return {
|
|
"account_id": "default",
|
|
"channel_access_token": token,
|
|
"channel_secret": secret,
|
|
"name": "default",
|
|
}, "default", token
|
|
|
|
return None, "", ""
|
|
|
|
|
|
async def _dispatch_to_agent(msg) -> None:
|
|
try:
|
|
from yuxi.channel.runtime.manager import gateway
|
|
|
|
processor = getattr(gateway, "_processor", None)
|
|
if processor is None:
|
|
logger.warning("LINE dispatch: message processor not available")
|
|
return
|
|
|
|
await asyncio.wait_for(processor.process(msg), timeout=120.0)
|
|
except TimeoutError:
|
|
logger.error("LINE agent response timeout for user %s", msg.sender.id)
|
|
except Exception:
|
|
logger.exception("LINE dispatch error for user %s", msg.sender.id)
|
|
|
|
|
|
async def _fetch_display_name(token: str, event: dict) -> str | None:
|
|
source = event.get("source", {})
|
|
if source.get("type") != "user":
|
|
return None
|
|
user_id = source.get("userId", "")
|
|
if not user_id or not token:
|
|
return None
|
|
try:
|
|
bot_client = LineBotClient(channel_access_token=token)
|
|
profile = await asyncio.wait_for(
|
|
bot_client.get_user_profile(user_id),
|
|
timeout=3.0,
|
|
)
|
|
if profile:
|
|
return profile.get("displayName")
|
|
except TimeoutError:
|
|
logger.debug("LINE fetch display_name timeout for user=%s", user_id)
|
|
except Exception:
|
|
logger.debug("LINE fetch display_name error for user=%s", user_id)
|
|
return None
|
|
|
|
|
|
def _log_non_message_event(account_id: str, event: dict, event_type: str) -> None:
|
|
source = event.get("source", {})
|
|
source_type = source.get("type", "")
|
|
user_id = source.get("userId", "")
|
|
group_id = source.get("groupId", "")
|
|
room_id = source.get("roomId", "")
|
|
|
|
match event_type:
|
|
case "follow":
|
|
logger.info("LINE follow event: account=%s user=%s", account_id, user_id)
|
|
case "unfollow":
|
|
logger.info("LINE unfollow event: account=%s user=%s", account_id, user_id)
|
|
case "join":
|
|
logger.info(
|
|
"LINE join event: account=%s source=%s group=%s room=%s user=%s",
|
|
account_id, source_type, group_id, room_id, user_id,
|
|
)
|
|
case "leave":
|
|
logger.info(
|
|
"LINE leave event: account=%s source=%s group=%s room=%s user=%s",
|
|
account_id, source_type, group_id, room_id, user_id,
|
|
)
|
|
case "memberJoined":
|
|
members = event.get("joined", {}).get("members", [])
|
|
member_ids = [m.get("userId", "") for m in members if isinstance(m, dict)]
|
|
logger.info(
|
|
"LINE memberJoined: account=%s group=%s room=%s members=%s",
|
|
account_id, group_id, room_id, member_ids,
|
|
)
|
|
case "memberLeft":
|
|
members = event.get("left", {}).get("members", [])
|
|
member_ids = [m.get("userId", "") for m in members if isinstance(m, dict)]
|
|
logger.info(
|
|
"LINE memberLeft: account=%s group=%s room=%s members=%s",
|
|
account_id, group_id, room_id, member_ids,
|
|
)
|
|
case "postback":
|
|
data = event.get("postback", {}).get("data", "")
|
|
logger.info(
|
|
"LINE postback event: account=%s user=%s data=%s",
|
|
account_id, user_id, data[:200],
|
|
)
|
|
case "beacon":
|
|
logger.info("LINE beacon event: account=%s user=%s", account_id, user_id)
|
|
case _:
|
|
logger.debug(
|
|
"LINE unhandled event type=%s account=%s user=%s",
|
|
event_type, account_id, user_id,
|
|
)
|
|
|
|
|
|
async def _read_body_with_limit(request: Request) -> bytes | None:
|
|
try:
|
|
body = await asyncio.wait_for(
|
|
request.body(),
|
|
timeout=LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_S,
|
|
)
|
|
except TimeoutError:
|
|
logger.warning("LINE webhook: body read timeout")
|
|
return None
|
|
|
|
if len(body) > LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES:
|
|
logger.warning("LINE webhook: body too large (%d bytes)", len(body))
|
|
return None
|
|
|
|
return body
|
|
|
|
|
|
class LineWebhookHandler:
|
|
|
|
def __init__(self):
|
|
self._config = LineConfigAdapter()
|
|
self._monitor = LineMonitor()
|
|
self._dedupe = LineEventDeduplicator()
|
|
self._running = False
|
|
self._in_flight_lock = asyncio.Lock()
|
|
|
|
async def handle_webhook(
|
|
self,
|
|
request: Any,
|
|
account_id: str = "default",
|
|
) -> dict:
|
|
if not self._running:
|
|
return {"status": "error", "message": "Channel not running"}
|
|
|
|
body = await _read_body_with_limit(request)
|
|
if body is None:
|
|
return {"status": "error", "message": "Body too large or timeout"}
|
|
|
|
signature = request.headers.get("X-Line-Signature", "")
|
|
|
|
account = await self._config.resolve_account(account_id)
|
|
if not account:
|
|
return {"status": "error", "message": "Account not configured"}
|
|
|
|
secret = account.get("channel_secret", "")
|
|
if not secret:
|
|
return {"status": "error", "message": "Channel secret not set"}
|
|
|
|
if not validate_line_signature(body, signature, secret):
|
|
return {"status": "error", "message": "Invalid signature"}
|
|
|
|
payload = self._monitor.parse_webhook_body(body)
|
|
events = self._monitor.parse_events(payload)
|
|
|
|
token = account.get("channel_access_token", "")
|
|
|
|
async with self._in_flight_lock:
|
|
unified_messages: list = []
|
|
for event in events:
|
|
event_type = event.get("type", "")
|
|
|
|
if event_type == "postback":
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if self._dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
um = self._monitor.parse_postback_to_unified(event, account_id, token)
|
|
if um:
|
|
unified_messages.append(um)
|
|
continue
|
|
|
|
if event_type == "follow":
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if self._dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
um = self._monitor.parse_follow_to_unified(event, account_id, token)
|
|
if um:
|
|
unified_messages.append(um)
|
|
continue
|
|
|
|
if event_type != "message":
|
|
_log_non_message_event(account_id, event, event_type)
|
|
continue
|
|
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if self._dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
|
|
um = self._monitor.parse_event_to_unified(event, account_id, token)
|
|
if um:
|
|
unified_messages.append(um)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"messages": unified_messages,
|
|
"account": {
|
|
"account_id": account.get("account_id", ""),
|
|
"name": account.get("name", ""),
|
|
},
|
|
}
|
|
|
|
async def handle_webhook_multi_account(
|
|
self,
|
|
request: Any,
|
|
accounts: list[dict],
|
|
) -> dict:
|
|
if not self._running:
|
|
return {"status": "error", "message": "Channel not running"}
|
|
|
|
body = await _read_body_with_limit(request)
|
|
if body is None:
|
|
return {"status": "error", "message": "Body too large or timeout"}
|
|
|
|
signature = request.headers.get("X-Line-Signature", "")
|
|
matched = match_signature_against_accounts(body, signature, accounts)
|
|
|
|
if not matched:
|
|
return {"status": "error", "message": "No matching account or ambiguous signature"}
|
|
|
|
account_id = matched.get("account_id", "default")
|
|
token = matched.get("channel_access_token", "")
|
|
|
|
payload = self._monitor.parse_webhook_body(body)
|
|
events = self._monitor.parse_events(payload)
|
|
|
|
async with self._in_flight_lock:
|
|
unified_messages: list = []
|
|
for event in events:
|
|
event_type = event.get("type", "")
|
|
|
|
if event_type == "postback":
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if self._dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
um = self._monitor.parse_postback_to_unified(event, account_id, token)
|
|
if um:
|
|
unified_messages.append(um)
|
|
continue
|
|
|
|
if event_type == "follow":
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if self._dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
um = self._monitor.parse_follow_to_unified(event, account_id, token)
|
|
if um:
|
|
unified_messages.append(um)
|
|
continue
|
|
|
|
if event_type != "message":
|
|
_log_non_message_event(account_id, event, event_type)
|
|
continue
|
|
|
|
dedupe_key = build_event_dedupe_key(account_id, event)
|
|
if self._dedupe.is_duplicate(dedupe_key):
|
|
continue
|
|
|
|
um = self._monitor.parse_event_to_unified(event, account_id, token)
|
|
if um:
|
|
unified_messages.append(um)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"messages": unified_messages,
|
|
"account": {
|
|
"account_id": account_id,
|
|
"name": matched.get("name", ""),
|
|
},
|
|
}
|
|
|
|
def start(self) -> None:
|
|
self._running = True
|
|
set_webhook_running(True)
|
|
logger.info("LINE webhook handler started")
|
|
|
|
def stop(self) -> None:
|
|
self._running = False
|
|
set_webhook_running(False)
|
|
self._dedupe.reset()
|
|
logger.info("LINE webhook handler stopped")
|