新增 Synology Chat 渠道扩展,支持在 Yuxi 平台中集成群晖 Synology Chat 即时通讯渠道。 包含以下功能模块: - client: Synology Chat API 客户端封装 - accounts: 账户管理 - webhook: Webhook 事件处理 - security: 安全校验 - dedupe: 消息去重 - status: 会话状态管理 - session: 会话管理 - types: 类型定义
435 lines
14 KiB
Python
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()
|