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