本次提交包含多项改进: 1. 修复钉钉、WhatsApp、Telegram等适配器的线程动作映射名称 2. 为SynologyChat、iMessage、Urbit等多款适配器新增配置Schema 3. 优化日志输出格式,合并多行日志调用为单行 4. 修复指数退避计算中的空格问题 5. 为QQBot凭证备份模块添加弃用警告 6. 新增多款适配器的凭证持久化存储逻辑 7. 优化Matrix、Nostr、DingDing等适配器的状态存储实现 8. 完善Discord、Slack、Signal等适配器的动作注册逻辑 9. 优化WhatsApp桥接器的QR码获取逻辑 10. 修复IRC适配器的配置比对与重连逻辑
282 lines
9.0 KiB
Python
282 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,
|
|
)
|