126 lines
3.8 KiB
Python
126 lines
3.8 KiB
Python
"""消息适配器 ABC — 所有渠道适配器的基类。
|
||
|
||
与 KBAdapter 的 authenticate()/close() 生命周期方法对齐:
|
||
verify_signature() 对应 authenticate()。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import abc
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
|
||
class ChannelType(str, Enum):
|
||
"""支持的消息平台渠道。"""
|
||
|
||
FEISHU = "feishu"
|
||
DINGTALK = "dingtalk"
|
||
WECOM = "wecom"
|
||
SLACK = "slack"
|
||
|
||
|
||
class MessageDirection(str, Enum):
|
||
"""消息流向。"""
|
||
|
||
INBOUND = "inbound"
|
||
OUTBOUND = "outbound"
|
||
|
||
|
||
@dataclass
|
||
class IncomingMessage:
|
||
"""标准化入站消息 — 所有渠道适配器将平台特定格式转换为此结构。"""
|
||
|
||
channel: ChannelType
|
||
platform_message_id: str
|
||
user_id: str # 平台用户 ID
|
||
chat_id: str # 群组/会话 ID
|
||
content: str # 消息文本
|
||
raw_event: dict[str, Any] = field(default_factory=dict) # 原始事件
|
||
timestamp: str = ""
|
||
|
||
|
||
@dataclass
|
||
class OutgoingMessage:
|
||
"""标准化出站消息。"""
|
||
|
||
channel: ChannelType
|
||
chat_id: str
|
||
content: str
|
||
reply_to_message_id: str | None = None
|
||
|
||
|
||
class URLVerificationChallenge(Exception):
|
||
"""URL 验证事件 — webhook 端点需返回 ``{"challenge": ...}`` 响应。
|
||
|
||
飞书 / Slack 在配置 webhook 时发送一次 ``url_verification`` 事件,
|
||
要求服务端原样返回 ``challenge`` 字段以验证 URL 可达。
|
||
"""
|
||
|
||
def __init__(self, challenge: str) -> None:
|
||
super().__init__(f"URL verification challenge: {challenge}")
|
||
self.challenge = challenge
|
||
|
||
|
||
class MessageAdapter(abc.ABC):
|
||
"""消息适配器 ABC。
|
||
|
||
生命周期:
|
||
__init__ → verify_signature() → receive_message() → send_message() → close()
|
||
|
||
子类必须实现 verify_signature / receive_message / send_message 抽象方法。
|
||
``_get_client`` / ``close`` 已在基类提供(懒构造 httpx 客户端 + 释放资源),
|
||
子类只需在 ``__init__`` 中调用 ``super().__init__()``。
|
||
|
||
verify_signature 失败时调用方应拒绝处理(webhook 端点 fail-closed:
|
||
Redis 不可用或签名校验失败均返回 503/401,不可跳过 nonce dedup 直接处理消息)。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
# 懒加载 httpx 客户端 — 避免未使用的适配器持有连接池
|
||
self._client: httpx.AsyncClient | None = None
|
||
|
||
def _get_client(self) -> httpx.AsyncClient:
|
||
"""懒构造 httpx 客户端(子类共享此实现)。"""
|
||
if self._client is None:
|
||
self._client = httpx.AsyncClient(timeout=10.0)
|
||
return self._client
|
||
|
||
@abc.abstractmethod
|
||
async def verify_signature(self, headers: dict[str, str], body: bytes) -> bool:
|
||
"""验证平台签名/token。返回 True 表示请求可信。"""
|
||
|
||
@abc.abstractmethod
|
||
async def receive_message(self, headers: dict[str, str], body: bytes) -> IncomingMessage:
|
||
"""从 webhook 请求中解析标准化消息。"""
|
||
|
||
@abc.abstractmethod
|
||
async def send_message(self, message: OutgoingMessage) -> bool:
|
||
"""向平台发送消息。返回 True 表示发送成功。"""
|
||
|
||
async def close(self) -> None:
|
||
"""关闭 httpx 客户端(如已创建)。子类可覆盖以释放额外资源。"""
|
||
if self._client is not None:
|
||
await self._client.aclose()
|
||
self._client = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 辅助函数
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def header_get(headers: dict[str, str], name: str) -> str | None:
|
||
"""大小写不敏感的 header 查找。"""
|
||
# 直接命中
|
||
if name in headers:
|
||
return headers[name]
|
||
lower = name.lower()
|
||
for k, v in headers.items():
|
||
if k.lower() == lower:
|
||
return v
|
||
return None
|