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

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