ForcePilot/backend/package/yuxi/channel/extensions/generic_webhook/webhook.py
Kris 71e90075e2 feat(generic-webhook): 新增通用Webhook管道插件
实现了完整的通用Webhook通道插件,支持入站请求接收、JSONPath字段映射、多种认证方式、出站Webhook推送,以及端点配置管理、去重、安全校验等功能
2026-05-21 10:47:58 +08:00

159 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 回复超时,端点 %ssender %s", endpoint.endpoint_id, msg.sender.id)
except Exception:
logger.exception("处理端点 %s 消息失败sender %s", endpoint.endpoint_id, msg.sender.id)