ForcePilot/backend/package/yuxi/channel/extensions/line/webhook.py

415 lines
14 KiB
Python
Raw Normal View History

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