159 lines
5.4 KiB
Python
159 lines
5.4 KiB
Python
|
|
import asyncio
|
|||
|
|
import json
|
|||
|
|
import logging
|
|||
|
|
import time
|
|||
|
|
|
|||
|
|
from fastapi import APIRouter, Request
|
|||
|
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|||
|
|
|
|||
|
|
from yuxi.channel.extensions.generic_webhook.auth_adapter import get_auth_adapter
|
|||
|
|
from yuxi.channel.extensions.generic_webhook.security import GenericWebhookSecurity
|
|||
|
|
from yuxi.channel.extensions.generic_webhook.types import InboundPayload
|
|||
|
|
|
|||
|
|
logger = logging.getLogger("yuxi.channel.generic_webhook")
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/webhook/generic", tags=["generic-webhook"])
|
|||
|
|
|
|||
|
|
_config_mgr = None
|
|||
|
|
_deduplicator = None
|
|||
|
|
_mapping_engine = None
|
|||
|
|
|
|||
|
|
MAX_BODY_SIZE = 1024 * 1024
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_runtime_state(*, config_mgr, deduplicator, mapping_engine):
|
|||
|
|
global _config_mgr, _deduplicator, _mapping_engine
|
|||
|
|
_config_mgr = config_mgr
|
|||
|
|
_deduplicator = deduplicator
|
|||
|
|
_mapping_engine = mapping_engine
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _process_inbound(inbound: InboundPayload, endpoint) -> dict:
|
|||
|
|
result = _mapping_engine.apply(inbound, endpoint.mapping)
|
|||
|
|
if result is None:
|
|||
|
|
logger.debug("端点 %s: 事件被过滤规则丢弃", endpoint.endpoint_id)
|
|||
|
|
return {"status": "ok", "filtered": True}
|
|||
|
|
|
|||
|
|
msg_id = result.unified.msg_id
|
|||
|
|
if _deduplicator.is_duplicate(endpoint.endpoint_id, msg_id):
|
|||
|
|
logger.debug("端点 %s: 重复事件 %s 已忽略", endpoint.endpoint_id, msg_id)
|
|||
|
|
return {"status": "ok", "deduplicated": True}
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
"端点 %s: 事件 %s 已接收 (extracted=%d, missing=%d, defaulted=%d)",
|
|||
|
|
endpoint.endpoint_id,
|
|||
|
|
msg_id,
|
|||
|
|
len(result.fields_extracted),
|
|||
|
|
len(result.fields_missing),
|
|||
|
|
len(result.fields_defaulted),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
security = GenericWebhookSecurity(endpoint)
|
|||
|
|
if not security.authorize(result.unified.sender.id):
|
|||
|
|
logger.debug("端点 %s: 发送者 %s 未通过 DM 安全策略", endpoint.endpoint_id, result.unified.sender.id)
|
|||
|
|
return {"status": "ok", "unauthorized": True}
|
|||
|
|
|
|||
|
|
asyncio.create_task(
|
|||
|
|
_dispatch_to_agent(result.unified, endpoint),
|
|||
|
|
name=f"generic-dispatch-{endpoint.endpoint_id}-{msg_id}",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {"status": "ok", "msg_id": msg_id}
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def generic_webhook_handler(payload: dict) -> dict:
|
|||
|
|
endpoint_id = payload.get("endpoint_id", "")
|
|||
|
|
if not endpoint_id:
|
|||
|
|
return {"error": "缺少 endpoint_id"}
|
|||
|
|
|
|||
|
|
endpoint = _config_mgr.get_endpoint(endpoint_id)
|
|||
|
|
if not endpoint:
|
|||
|
|
return {"error": f"端点 '{endpoint_id}' 不存在", "status": 404}
|
|||
|
|
|
|||
|
|
if not endpoint.enabled:
|
|||
|
|
return {"error": "端点已禁用", "status": 503}
|
|||
|
|
|
|||
|
|
body = payload.get("_raw_body", b"")
|
|||
|
|
payload_data = payload.get("body") or {}
|
|||
|
|
headers = payload.get("_headers", {})
|
|||
|
|
|
|||
|
|
inbound = InboundPayload(
|
|||
|
|
endpoint_id=endpoint_id,
|
|||
|
|
body=payload_data,
|
|||
|
|
headers=headers,
|
|||
|
|
raw_body=body if isinstance(body, bytes) else json.dumps(payload_data).encode(),
|
|||
|
|
received_at=time.monotonic(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return _process_inbound(inbound, endpoint)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/{endpoint_id:path}")
|
|||
|
|
async def generic_webhook_receive(endpoint_id: str, request: Request):
|
|||
|
|
start_time = time.monotonic()
|
|||
|
|
|
|||
|
|
endpoint = _config_mgr.get_endpoint(endpoint_id)
|
|||
|
|
if not endpoint:
|
|||
|
|
return JSONResponse({"error": f"端点 '{endpoint_id}' 不存在"}, status_code=404)
|
|||
|
|
|
|||
|
|
if not endpoint.enabled:
|
|||
|
|
return JSONResponse({"error": "端点已禁用"}, status_code=503)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
body = await asyncio.wait_for(request.body(), timeout=5.0)
|
|||
|
|
except TimeoutError:
|
|||
|
|
return JSONResponse({"error": "读取请求体超时"}, status_code=408)
|
|||
|
|
|
|||
|
|
if len(body) > MAX_BODY_SIZE:
|
|||
|
|
return JSONResponse({"error": f"请求体超过 {MAX_BODY_SIZE // 1024}KB 限制"}, status_code=413)
|
|||
|
|
|
|||
|
|
adapter = get_auth_adapter(endpoint.auth.type)
|
|||
|
|
auth_result = await adapter.verify(request, body, endpoint.auth)
|
|||
|
|
if not auth_result.allowed:
|
|||
|
|
logger.warning("端点 %s: 认证失败 - %s", endpoint_id, auth_result.reason)
|
|||
|
|
return JSONResponse({"error": auth_result.reason}, status_code=auth_result.error_code)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
payload_data = json.loads(body)
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
return JSONResponse({"error": "请求体不是有效的 JSON"}, status_code=400)
|
|||
|
|
|
|||
|
|
if not isinstance(payload_data, dict):
|
|||
|
|
return JSONResponse({"error": "请求体必须是 JSON 对象"}, status_code=400)
|
|||
|
|
|
|||
|
|
inbound = InboundPayload(
|
|||
|
|
endpoint_id=endpoint_id,
|
|||
|
|
body=payload_data,
|
|||
|
|
headers=dict(request.headers),
|
|||
|
|
raw_body=body,
|
|||
|
|
received_at=start_time,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result = _process_inbound(inbound, endpoint)
|
|||
|
|
|
|||
|
|
if result.get("filtered") or result.get("deduplicated") or result.get("unauthorized"):
|
|||
|
|
return PlainTextResponse("ok")
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
"端点 %s: 延迟 %.0fms",
|
|||
|
|
endpoint_id,
|
|||
|
|
(time.monotonic() - start_time) * 1000,
|
|||
|
|
)
|
|||
|
|
return PlainTextResponse("ok")
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _dispatch_to_agent(msg, endpoint) -> None:
|
|||
|
|
from yuxi.channel.runtime.manager import gateway
|
|||
|
|
|
|||
|
|
processor = gateway._processor
|
|||
|
|
if processor is None:
|
|||
|
|
logger.error("Message processor 不可用,无法分发端点 %s 的消息", endpoint.endpoint_id)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
await asyncio.wait_for(processor.process(msg), timeout=120.0)
|
|||
|
|
except TimeoutError:
|
|||
|
|
logger.error("Agent 回复超时,端点 %s,sender %s", endpoint.endpoint_id, msg.sender.id)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception("处理端点 %s 消息失败,sender %s", endpoint.endpoint_id, msg.sender.id)
|