ForcePilot/backend/package/yuxi/channels/adapters/feishu/webhook_server.py

288 lines
9.2 KiB
Python
Raw Normal View History

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