- 新增钉钉工具调用审计日志功能 - 新增参数校验装饰器与基础工具集(文档、审批、多维表格) - 完善事件类型映射与会话ID生成逻辑 - 新增消息去重、访问策略匹配、配置校验与UI提示 - 新增酷应用卡片、目录管理、表情反应支持 - 优化消息发送逻辑,添加401重试机制 - 新增配置化的权限控制与流式输出支持
281 lines
9.0 KiB
Python
281 lines
9.0 KiB
Python
"""DingDing Webhook HTTP 服务器。
|
|
|
|
接收钉钉开放平台推送的事件回调,支持签名验证、事件分发。
|
|
为 Webhook 模式的部署场景提供独立的 HTTP Server。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|
|
|
DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
DEFAULT_HOST = "0.0.0.0"
|
|
DEFAULT_PORT = 8080
|
|
DEFAULT_PATH = "/api/channels/dingding/events"
|
|
|
|
|
|
class DingDingWebhookServer:
|
|
def __init__(
|
|
self,
|
|
adapter: DingDingChannelAdapter,
|
|
host: str = DEFAULT_HOST,
|
|
port: int = DEFAULT_PORT,
|
|
path: str = DEFAULT_PATH,
|
|
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._request_count = 0
|
|
self._error_count = 0
|
|
|
|
@property
|
|
def is_running(self) -> bool:
|
|
return self._running
|
|
|
|
@property
|
|
def request_count(self) -> int:
|
|
return self._request_count
|
|
|
|
@property
|
|
def error_count(self) -> int:
|
|
return self._error_count
|
|
|
|
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"[DingDingWebhook] 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("[DingDingWebhook] Server stopped")
|
|
|
|
async def _handle_connection(
|
|
self,
|
|
reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter,
|
|
) -> None:
|
|
self._request_count += 1
|
|
|
|
try:
|
|
request_data = await asyncio.wait_for(reader.read(65536), timeout=30)
|
|
except TimeoutError:
|
|
self._error_count += 1
|
|
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 = self._parse_http_request(request_data, reader)
|
|
except Exception:
|
|
self._error_count += 1
|
|
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:
|
|
self._error_count += 1
|
|
await self._send_response(writer, 413, "Body too large")
|
|
return
|
|
|
|
signature_valid = True
|
|
if "timestamp" in headers and "sign" in headers:
|
|
try:
|
|
signature_valid = await self._adapter.verify_webhook_signature(headers, body)
|
|
except Exception:
|
|
self._error_count += 1
|
|
await self._send_response(writer, 403, "Verification error")
|
|
return
|
|
|
|
if not signature_valid:
|
|
self._error_count += 1
|
|
logger.warning("[DingDingWebhook] Signature verification failed")
|
|
await self._send_response(writer, 403, "Signature verification failed")
|
|
return
|
|
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
self._error_count += 1
|
|
await self._send_response(writer, 400, "Invalid JSON")
|
|
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:
|
|
try:
|
|
msg = self._adapter.normalize_inbound(payload)
|
|
await self._adapter._handle_message(msg)
|
|
except Exception as e:
|
|
logger.error(f"[DingDingWebhook] Event dispatch error: {e}")
|
|
|
|
@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_dingding_webhook(
|
|
body: bytes,
|
|
headers: dict[str, str],
|
|
adapter: DingDingChannelAdapter,
|
|
) -> dict[str, Any]:
|
|
if len(body) > DEFAULT_MAX_BODY_BYTES:
|
|
logger.warning("[DingDingWebhook] Body too large: %d bytes", len(body))
|
|
return {"code": 413, "message": "Body too large"}
|
|
|
|
if "timestamp" in headers and "sign" in headers:
|
|
try:
|
|
verified = await adapter.verify_webhook_signature(headers, body)
|
|
except Exception as e:
|
|
logger.warning(f"[DingDingWebhook] Verification error: {e}")
|
|
return {"code": 403, "message": "Verification error"}
|
|
|
|
if not verified:
|
|
logger.warning("[DingDingWebhook] Signature verification failed")
|
|
return {"code": 403, "message": "Signature verification failed"}
|
|
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError as e:
|
|
logger.warning(f"[DingDingWebhook] Invalid JSON: {e}")
|
|
return {"code": 400, "message": "Invalid JSON"}
|
|
|
|
try:
|
|
msg = adapter.normalize_inbound(payload)
|
|
await adapter._handle_message(msg)
|
|
except Exception as e:
|
|
logger.error(f"[DingDingWebhook] Event dispatch error: {e}")
|
|
return {"code": 500, "message": f"Dispatch error: {e}"}
|
|
|
|
return {"code": 0, "message": "ok"}
|
|
|
|
|
|
def create_webhook_server(
|
|
adapter: DingDingChannelAdapter,
|
|
*,
|
|
host: str = DEFAULT_HOST,
|
|
port: int = DEFAULT_PORT,
|
|
path: str = DEFAULT_PATH,
|
|
max_body_bytes: int = DEFAULT_MAX_BODY_BYTES,
|
|
) -> DingDingWebhookServer:
|
|
config = getattr(adapter, "config", {})
|
|
webhook_config = config.get("webhook_server", {})
|
|
if isinstance(webhook_config, dict):
|
|
host = webhook_config.get("host", host)
|
|
port = webhook_config.get("port", port)
|
|
path = webhook_config.get("path", path)
|
|
max_body_bytes = webhook_config.get("max_body_bytes", max_body_bytes)
|
|
|
|
return DingDingWebhookServer(
|
|
adapter=adapter,
|
|
host=host,
|
|
port=port,
|
|
path=path,
|
|
max_body_bytes=max_body_bytes,
|
|
) |