2026-05-12 00:43:59 +08:00
|
|
|
|
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
|
2026-05-12 14:51:53 +08:00
|
|
|
|
self._recent_event_ids: set[str] = set()
|
|
|
|
|
|
self._event_id_ttl_s = 300
|
|
|
|
|
|
self._event_id_max = 10000
|
2026-05-12 00:43:59 +08:00
|
|
|
|
|
|
|
|
|
|
@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", "")
|
|
|
|
|
|
|
2026-05-12 14:51:53 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-05-12 00:43:59 +08:00
|
|
|
|
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"}
|