ForcePilot/backend/package/yuxi/channels/adapters/dingding/webhook_server.py
Kris 9c01d480cc feat(dingding): 完整实现钉钉适配器功能模块
- 新增钉钉工具调用审计日志功能
- 新增参数校验装饰器与基础工具集(文档、审批、多维表格)
- 完善事件类型映射与会话ID生成逻辑
- 新增消息去重、访问策略匹配、配置校验与UI提示
- 新增酷应用卡片、目录管理、表情反应支持
- 优化消息发送逻辑,添加401重试机制
- 新增配置化的权限控制与流式输出支持
2026-05-13 16:06:10 +08:00

281 lines
9.0 KiB
Python

"""DingDing Webhook HTTP 服务器。
接收钉钉开放平台推送的事件回调,支持签名验证、事件分发。
为 Webhook 模式的部署场景提供独立的 HTTP Server。
"""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8080
DEFAULT_PATH = "/api/channels/dingding/events"
class DingDingWebhookServer:
def __init__(
self,
adapter: DingDingChannelAdapter,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
max_body_bytes: int = DEFAULT_MAX_BODY_BYTES,
):
self._adapter = adapter
self._host = host
self._port = port
self._path = path
self._max_body_bytes = max_body_bytes
self._server: asyncio.AbstractServer | None = None
self._running = False
self._request_count = 0
self._error_count = 0
@property
def is_running(self) -> bool:
return self._running
@property
def request_count(self) -> int:
return self._request_count
@property
def error_count(self) -> int:
return self._error_count
async def start(self) -> None:
if self._running:
return
self._server = await asyncio.start_server(
self._handle_connection,
host=self._host,
port=self._port,
)
self._running = True
logger.info(f"[DingDingWebhook] Server started on {self._host}:{self._port}{self._path}")
async def stop(self) -> None:
if not self._running or self._server is None:
return
self._running = False
self._server.close()
await self._server.wait_closed()
self._server = None
logger.info("[DingDingWebhook] Server stopped")
async def _handle_connection(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
self._request_count += 1
try:
request_data = await asyncio.wait_for(reader.read(65536), timeout=30)
except TimeoutError:
self._error_count += 1
await self._send_response(writer, 408, "Request Timeout")
return
if not request_data:
await self._send_response(writer, 400, "Bad Request")
return
try:
method, url_path, headers, body = self._parse_http_request(request_data, reader)
except Exception:
self._error_count += 1
await self._send_response(writer, 400, "Bad Request")
return
if method != "POST":
await self._send_response(writer, 405, "Method Not Allowed")
return
if url_path != self._path:
await self._send_response(writer, 404, "Not Found")
return
if len(body) > self._max_body_bytes:
self._error_count += 1
await self._send_response(writer, 413, "Body too large")
return
signature_valid = True
if "timestamp" in headers and "sign" in headers:
try:
signature_valid = await self._adapter.verify_webhook_signature(headers, body)
except Exception:
self._error_count += 1
await self._send_response(writer, 403, "Verification error")
return
if not signature_valid:
self._error_count += 1
logger.warning("[DingDingWebhook] Signature verification failed")
await self._send_response(writer, 403, "Signature verification failed")
return
try:
payload = json.loads(body)
except json.JSONDecodeError:
self._error_count += 1
await self._send_response(writer, 400, "Invalid JSON")
return
await self._dispatch_event(payload)
await self._send_json_response(writer, 200, {"code": 0, "message": "ok"})
async def _parse_http_request(
self, data: bytes, reader: asyncio.StreamReader
) -> tuple[str, str, dict[str, str], bytes]:
header_end = data.find(b"\r\n\r\n")
if header_end == -1:
raise ValueError("Invalid HTTP request")
header_section = data[:header_end]
body_start = header_end + 4
body_data = data[body_start:]
request_line, *header_lines = header_section.decode("utf-8", errors="replace").split("\r\n")
parts = request_line.split(" ")
method = parts[0].upper() if len(parts) >= 1 else "GET"
url_path = parts[1] if len(parts) >= 2 else "/"
headers: dict[str, str] = {}
for line in header_lines:
if ":" in line:
key, _, value = line.partition(":")
headers[key.strip().lower()] = value.strip()
content_length = int(headers.get("content-length", "0"))
if content_length > len(body_data):
remaining = content_length - len(body_data)
try:
more = await asyncio.wait_for(reader.read(remaining), timeout=10)
except Exception:
more = b""
body_data = body_data + more
return method, url_path, headers, body_data
async def _dispatch_event(self, payload: dict[str, Any]) -> None:
try:
msg = self._adapter.normalize_inbound(payload)
await self._adapter._handle_message(msg)
except Exception as e:
logger.error(f"[DingDingWebhook] Event dispatch error: {e}")
@staticmethod
async def _send_response(writer: asyncio.StreamWriter, status: int, message: str) -> None:
body = message.encode("utf-8")
response = (
f"HTTP/1.1 {status} {message}\r\n"
f"Content-Type: text/plain\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode() + body
try:
writer.write(response)
await writer.drain()
except Exception:
pass
finally:
try:
writer.close()
except Exception:
pass
@staticmethod
async def _send_json_response(writer: asyncio.StreamWriter, status: int, data: dict[str, Any]) -> None:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
response = (
f"HTTP/1.1 {status} OK\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode() + body
try:
writer.write(response)
await writer.drain()
except Exception:
pass
finally:
try:
writer.close()
except Exception:
pass
async def handle_dingding_webhook(
body: bytes,
headers: dict[str, str],
adapter: DingDingChannelAdapter,
) -> dict[str, Any]:
if len(body) > DEFAULT_MAX_BODY_BYTES:
logger.warning("[DingDingWebhook] Body too large: %d bytes", len(body))
return {"code": 413, "message": "Body too large"}
if "timestamp" in headers and "sign" in headers:
try:
verified = await adapter.verify_webhook_signature(headers, body)
except Exception as e:
logger.warning(f"[DingDingWebhook] Verification error: {e}")
return {"code": 403, "message": "Verification error"}
if not verified:
logger.warning("[DingDingWebhook] Signature verification failed")
return {"code": 403, "message": "Signature verification failed"}
try:
payload = json.loads(body)
except json.JSONDecodeError as e:
logger.warning(f"[DingDingWebhook] Invalid JSON: {e}")
return {"code": 400, "message": "Invalid JSON"}
try:
msg = adapter.normalize_inbound(payload)
await adapter._handle_message(msg)
except Exception as e:
logger.error(f"[DingDingWebhook] Event dispatch error: {e}")
return {"code": 500, "message": f"Dispatch error: {e}"}
return {"code": 0, "message": "ok"}
def create_webhook_server(
adapter: DingDingChannelAdapter,
*,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
max_body_bytes: int = DEFAULT_MAX_BODY_BYTES,
) -> DingDingWebhookServer:
config = getattr(adapter, "config", {})
webhook_config = config.get("webhook_server", {})
if isinstance(webhook_config, dict):
host = webhook_config.get("host", host)
port = webhook_config.get("port", port)
path = webhook_config.get("path", path)
max_body_bytes = webhook_config.get("max_body_bytes", max_body_bytes)
return DingDingWebhookServer(
adapter=adapter,
host=host,
port=port,
path=path,
max_body_bytes=max_body_bytes,
)