ForcePilot/backend/package/yuxi/channel/extensions/synology_chat/webhook.py
Kris 156f716600 feat(channel): 添加 Synology Chat 渠道扩展
新增 Synology Chat 渠道扩展,支持在 Yuxi 平台中集成群晖 Synology Chat 即时通讯渠道。

包含以下功能模块:
- client: Synology Chat API 客户端封装
- accounts: 账户管理
- webhook: Webhook 事件处理
- security: 安全校验
- dedupe: 消息去重
- status: 会话状态管理
- session: 会话管理
- types: 类型定义
2026-05-21 11:44:45 +08:00

435 lines
14 KiB
Python

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