"""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, )