这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
299 lines
9.6 KiB
Python
299 lines
9.6 KiB
Python
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"}
|