from __future__ import annotations import asyncio import json from typing import Any from yuxi.utils.logging_config import logger from .normalizer import normalize_inbound from .verify import verify_and_decrypt_webhook DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024 DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 8080 class FeishuWebhookServer: """飞书 Webhook HTTP 服务器。 接收飞书开放平台推送的事件回调,支持签名验证、URL 挑战响应、事件分发。 """ def __init__( self, adapter: Any, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = "/api/channels/feishu/events", 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._recent_event_ids: set[str] = set() self._event_id_ttl_s = 300 self._event_id_max = 10000 @property def is_running(self) -> bool: return self._running 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"[FeishuWebhook] 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("[FeishuWebhook] Server stopped") async def _handle_connection( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ) -> None: try: request_data = await asyncio.wait_for(reader.read(65536), timeout=30) except TimeoutError: 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 = await self._parse_http_request(request_data, reader) except Exception: 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: await self._send_response(writer, 413, "Body too large") return source_ip = writer.get_extra_info("peername") source_ip_str = source_ip[0] if source_ip else "default" try: verified, decrypted, status = verify_and_decrypt_webhook( headers, body, self._adapter._encrypt_key, source_ip_str ) except Exception: await self._send_response(writer, 403, "Verification error") return if not verified: logger.warning(f"[FeishuWebhook] Verification failed: {status} from {source_ip_str}") await self._send_response(writer, 403, f"Verification failed: {status}") return payload_bytes = decrypted if decrypted else body try: payload = json.loads(payload_bytes) except json.JSONDecodeError: await self._send_response(writer, 400, "Invalid JSON") return event_type = payload.get("type", "") if event_type == "url_verification": challenge = payload.get("challenge", "") await self._send_json_response(writer, 200, {"challenge": challenge}) 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: event = payload.get("event", {}) event_type = payload.get("event_type", "") header = payload.get("header", {}) event_id = header.get("event_id", "") or event.get("event_id", "") if event_id: if event_id in self._recent_event_ids: logger.debug(f"[FeishuWebhook] Duplicate event_id: {event_id}") return self._recent_event_ids.add(event_id) if len(self._recent_event_ids) > self._event_id_max: self._recent_event_ids.clear() raw_payload = {"event": event, "event_type": event_type, "event_id": event_id} channel_msg = normalize_inbound( self._adapter.channel_id, self._adapter.channel_type, raw_payload, self._adapter._bot_open_id, ) try: loop = asyncio.get_running_loop() except RuntimeError: loop = asyncio.get_event_loop() asyncio.run_coroutine_threadsafe(self._adapter._handle_message(channel_msg), loop) @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_feishu_webhook( body: bytes, headers: dict[str, str], adapter: Any, source_ip: str = "default", ) -> dict[str, Any]: if len(body) > DEFAULT_MAX_BODY_BYTES: logger.warning("[FeishuWebhook] Body too large: %d bytes", len(body)) return {"code": 413, "message": "Body too large"} verified, decrypted, status = verify_and_decrypt_webhook(headers, body, adapter._encrypt_key, source_ip) if not verified: logger.warning("[FeishuWebhook] Verification failed: %s", status) return {"code": 403, "message": f"Verification failed: {status}"} payload_bytes = decrypted if decrypted else body try: payload = json.loads(payload_bytes) except json.JSONDecodeError as e: logger.warning("[FeishuWebhook] Invalid JSON: %s", e) return {"code": 400, "message": "Invalid JSON"} event_type = payload.get("type", "") if event_type == "url_verification": challenge = payload.get("challenge", "") logger.info("[FeishuWebhook] URL verification challenge received") return {"code": 0, "message": "ok", "challenge": challenge} return await _handle_event(payload, adapter) async def _handle_event(payload: dict[str, Any], adapter: Any) -> dict[str, Any]: event = payload.get("event", {}) event_type = payload.get("event_type", "") header = payload.get("header", {}) event_id = header.get("event_id", "") or event.get("event_id", "") raw_payload = { "event": event, "event_type": event_type, "event_id": event_id, } channel_msg = normalize_inbound( adapter.channel_id, adapter.channel_type, raw_payload, adapter._bot_open_id, ) try: loop = asyncio.get_running_loop() except RuntimeError: loop = asyncio.get_event_loop() asyncio.run_coroutine_threadsafe(adapter._handle_message(channel_msg), loop) return {"code": 0, "message": "ok"}