from __future__ import annotations import asyncio import json import logging import time from urllib.parse import parse_qs from fastapi import APIRouter, Request, Response from fastapi.responses import JSONResponse from yuxi.channel.extensions.synology_chat.accounts import resolve_account from yuxi.channel.extensions.synology_chat.client import ( resolve_legacy_webhook_name_to_chat_user_id, send_message, ) from yuxi.channel.extensions.synology_chat.security import ( authorize_dm, get_invalid_token_limiter, get_rate_limiter, sanitize_input, validate_token, ) from yuxi.channel.extensions.synology_chat.types import ( AGENT_SYNC_TIMEOUT_S, AGENT_TIMEOUT_S, PREAUTH_BODY_MAX_BYTES, PREAUTH_BODY_TIMEOUT_S, ResolvedSynologyChatAccount, SynologyWebhookPayload, WebhookParseError, ) from yuxi.channel.runtime.manager import gateway from yuxi.channel.message.models import PeerInfo, PeerKind, UnifiedMessage logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/synology-chat", tags=["synology-chat"]) _MAX_IN_FLIGHT_PER_ACCOUNT = 10 _in_flight_semaphores: dict[str, asyncio.Semaphore] = {} def _get_in_flight_semaphore(account_id: str) -> asyncio.Semaphore: sem = _in_flight_semaphores.get(account_id) if sem is None: sem = asyncio.Semaphore(_MAX_IN_FLIGHT_PER_ACCOUNT) _in_flight_semaphores[account_id] = sem return sem def _extract_field(body_data: dict, query_params, aliases: list[str]) -> str: for alias in aliases: val = body_data.get(alias, "") if val: return str(val) for alias in aliases: val = query_params.get(alias, "") if val: return val return "" def _extract_token(request: Request, body_data: dict) -> str: token = body_data.get("token", "") if token: return str(token) token = request.query_params.get("token", "") if token: return token for header_name in ["x-synology-token", "x-webhook-token", "x-openclaw-token"]: token = request.headers.get(header_name, "") if token: return token auth = request.headers.get("authorization", "") if auth.lower().startswith("bearer "): return auth[7:] return "" def _is_action_callback(body_data: dict) -> bool: return "actions" in body_data and "callback_id" in body_data and "token" in body_data async def _read_body_data(request: Request) -> dict: content_type = request.headers.get("content-type", "") try: raw_body = await asyncio.wait_for(request.body(), timeout=PREAUTH_BODY_TIMEOUT_S) except TimeoutError: raise WebhookParseError("Body read timeout") if len(raw_body) > PREAUTH_BODY_MAX_BYTES: raise WebhookParseError("Body too large") body_str = raw_body.decode("utf-8", errors="replace") if "application/json" in content_type: body_data = json.loads(body_str) elif "application/x-www-form-urlencoded" in content_type: body_data = {k: v[0] if v else "" for k, v in parse_qs(body_str).items()} else: try: body_data = json.loads(body_str) except json.JSONDecodeError: body_data = {k: v[0] if v else "" for k, v in parse_qs(body_str).items()} return body_data async def parse_payload(request: Request) -> SynologyWebhookPayload: body_data = await _read_body_data(request) token = _extract_token(request, body_data) query_params = request.query_params user_id = _extract_field(body_data, query_params, ["user_id", "userId", "user"]) text = _extract_field(body_data, query_params, ["text", "message", "content"]) username = _extract_field(body_data, query_params, ["username", "user_name", "name"]) if not token: raise WebhookParseError("Missing required field: token") if not user_id: raise WebhookParseError("Missing required field: user_id") chat_type = body_data.get("chat_type", "direct") if chat_type == "direct" and not text: raise WebhookParseError("Missing required field: text") return SynologyWebhookPayload( token=token, user_id=user_id, text=text, username=username, chat_type=chat_type, channel_id=body_data.get("channel_id", ""), channel_name=_extract_field(body_data, query_params, ["channel_name", "channelName"]), post_id=_extract_field(body_data, query_params, ["post_id", "postId"]), timestamp=_extract_field(body_data, query_params, ["timestamp", "ts"]), trigger_word=_extract_field(body_data, query_params, ["trigger_word", "triggerWord"]), ) async def parse_action_callback(request: Request) -> dict: body_data = await _read_body_data(request) token = _extract_token(request, body_data) if not token or not body_data.get("callback_id"): raise WebhookParseError("Missing required fields: token, callback_id") user = body_data.get("user", {}) return { "token": token, "callback_id": body_data.get("callback_id", ""), "post_id": body_data.get("post_id", ""), "actions": body_data.get("actions", []), "user_id": str(user.get("user_id", "")) if isinstance(user, dict) else "", "username": user.get("username", "") if isinstance(user, dict) else "", } async def _resolve_send_user_id( account: ResolvedSynologyChatAccount, payload: SynologyWebhookPayload, ) -> str: if not account.dangerously_allow_name_matching: return payload.user_id resolved = await resolve_legacy_webhook_name_to_chat_user_id( incoming_url=account.incoming_url, webhook_username=payload.username, allow_insecure_ssl=account.allow_insecure_ssl, ) return resolved or payload.user_id async def _handle_action_callback( request: Request, account_id: str, body_data: dict, processor, ) -> Response: try: callback = await parse_action_callback(request) except WebhookParseError as e: return JSONResponse({"error": str(e)}, status_code=400) account = _resolve_account_for_request(account_id) if not validate_token(callback["token"], account.token): return JSONResponse({"error": "Invalid token"}, status_code=401) user_id = callback["user_id"] allowed, reason = authorize_dm(account, user_id) if not allowed: return JSONResponse({"error": reason}, status_code=403) rl = get_rate_limiter(account) if not rl.allow(user_id): return JSONResponse({"error": "Rate limited"}, status_code=429) actions = callback.get("actions", []) action_value = actions[0].get("value", "") if actions else "" callback_id = callback.get("callback_id", "") logger.info( "Action callback: callback_id=%s user_id=%s action=%s", callback_id, user_id, action_value, ) if processor is not None: metadata = { "ChatType": "direct", "CommandAuthorized": True, "InteractionType": "button_callback", "CallbackId": callback_id, "ActionValue": action_value, "PostId": callback.get("post_id", ""), "user_id": user_id, "username": callback.get("username", ""), } asyncio.create_task( _dispatch_to_agent( processor, account, None, f"[callback:{callback_id}]:{action_value}", metadata=metadata, ), name=f"synology-agent-cb-{account.account_id}-{user_id}", ) return Response(status_code=204) def _build_callback_update_response( callback_id: str, text: str, attachments: list[dict] | None = None, ) -> dict: payload: dict = {"text": text} if attachments: payload["attachments"] = attachments return payload @router.post("/{account_id}") async def synology_chat_webhook( request: Request, account_id: str = "default", ) -> Response: processor = gateway._processor sem = _get_in_flight_semaphore(account_id) if sem.locked(): return JSONResponse({"error": "Too many concurrent requests"}, status_code=503) async with sem: try: body_data = await _read_body_data(request) except WebhookParseError as e: return JSONResponse({"error": str(e)}, status_code=400) except Exception: logger.exception("Failed to read webhook body") return JSONResponse({"error": "Bad request"}, status_code=400) if _is_action_callback(body_data): return await _handle_action_callback(request, account_id, body_data, processor) try: payload = await parse_payload(request) except WebhookParseError as e: return JSONResponse({"error": str(e)}, status_code=400) except Exception: logger.exception("Failed to parse webhook payload") return JSONResponse({"error": "Bad request"}, status_code=400) account = _resolve_account_for_request(account_id) if not validate_token(payload.token, account.token): limiter = get_invalid_token_limiter(account.account_id) client_ip = request.client.host if request.client else "unknown" if not limiter.allow(client_ip): return JSONResponse({"error": "Too many invalid token attempts"}, status_code=429) return JSONResponse({"error": "Invalid token"}, status_code=401) allowed, reason = authorize_dm(account, payload.user_id) if not allowed: return JSONResponse({"error": reason}, status_code=403) rl = get_rate_limiter(account) if not rl.allow(payload.user_id): return JSONResponse({"error": "Rate limited"}, status_code=429) sanitized_text = sanitize_input(payload.text) if processor is not None: sync_reply = await _dispatch_to_agent_sync(processor, account, payload, sanitized_text) if sync_reply is not None: return JSONResponse(sync_reply, status_code=200) asyncio.create_task( _dispatch_to_agent(processor, account, payload, sanitized_text), name=f"synology-agent-{account.account_id}-{payload.user_id}", ) else: logger.warning("Message processor not available, cannot dispatch") return Response(status_code=204) async def _dispatch_to_agent( processor, account: ResolvedSynologyChatAccount, payload: SynologyWebhookPayload | None, sanitized_text: str, metadata: dict | None = None, ) -> None: if payload is not None: send_user_id = await _resolve_send_user_id(account, payload) msg_metadata: dict = { "ChatType": payload.chat_type, "CommandAuthorized": True, "webhook_user_id": payload.user_id, } if payload.channel_id: msg_metadata["ChannelId"] = payload.channel_id if payload.channel_name: msg_metadata["ChannelName"] = payload.channel_name display_name = payload.username user_id = payload.user_id else: send_user_id = (metadata or {}).get("user_id", "") msg_metadata = metadata or {} display_name = msg_metadata.get("username", "") user_id = send_user_id try: msg = UnifiedMessage( msg_id=f"sc-{int(time.time() * 1000)}", channel_type="synology-chat", account_id=account.account_id, content=sanitized_text, sender=PeerInfo(id=user_id, kind=PeerKind.DIRECT, display_name=display_name), body_for_agent=sanitized_text, metadata=msg_metadata, ) await asyncio.wait_for(processor.process(msg), timeout=AGENT_TIMEOUT_S) except TimeoutError: logger.error( "Agent response timeout (120s) for %s/%s", account.account_id, user_id, ) if account.incoming_url: await send_message( incoming_url=account.incoming_url, text="Request timed out. Please try again.", user_id=send_user_id, allow_insecure_ssl=account.allow_insecure_ssl, ) except Exception: logger.exception( "Failed to process message for %s/%s", account.account_id, user_id, ) if account.incoming_url: await send_message( incoming_url=account.incoming_url, text="Sorry, an error occurred while processing your message.", user_id=send_user_id, allow_insecure_ssl=account.allow_insecure_ssl, ) async def _dispatch_to_agent_sync( processor, account: ResolvedSynologyChatAccount, payload: SynologyWebhookPayload, sanitized_text: str, ) -> dict | None: msg = UnifiedMessage( msg_id=f"sc-{payload.timestamp or int(time.time() * 1000)}", channel_type="synology-chat", account_id=account.account_id, content=sanitized_text, sender=PeerInfo( id=payload.user_id, kind=PeerKind.DIRECT, display_name=payload.username, ), body_for_agent=sanitized_text, metadata={ "ChatType": payload.chat_type, "CommandAuthorized": True, "webhook_user_id": payload.user_id, "SyncResponse": True, }, ) try: await asyncio.wait_for(processor.process(msg), timeout=AGENT_SYNC_TIMEOUT_S) except TimeoutError: logger.info("Sync response timeout for %s/%s, falling back to async", account.account_id, payload.user_id) return None except Exception: logger.exception("Sync dispatch failed for %s/%s", account.account_id, payload.user_id) return None return None _ACCOUNT_CACHE: dict[str, ResolvedSynologyChatAccount] = {} _ACCOUNT_CACHE_CONFIG_HASH: str = "" def _resolve_account_for_request(account_id: str) -> ResolvedSynologyChatAccount: global _ACCOUNT_CACHE, _ACCOUNT_CACHE_CONFIG_HASH if account_id in _ACCOUNT_CACHE: return _ACCOUNT_CACHE[account_id] account = resolve_account(gateway.global_config, account_id) _ACCOUNT_CACHE[account_id] = account return account def invalidate_account_cache() -> None: global _ACCOUNT_CACHE _ACCOUNT_CACHE.clear()