ForcePilot/backend/package/yuxi/channels/adapters/feishu/webhook_server.py
Kris a6fa7245e5 feat(feishu): 完整实现飞书适配器核心模块
新增飞书机器人适配器全套功能,包括:
- 基础适配器入口与工具导出
- 消息格式化、卡片渲染、回复调度逻辑
- 会话ID生成、模型覆盖策略
- 消息发送缓存、顺序队列管理
- 飞书签名验证、加解密webhook请求
- 审批权限校验、机器人菜单事件处理
- 文档评论、钉消息、语音转码处理
- 静态/动态目录管理、子代理生命周期管理
- 各类工具集:聊天、云盘、文档、知识库API封装
2026-05-12 00:43:59 +08:00

288 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"}