"""飞书 IM 适配器 (U11)。 实现 :class:`MessageAdapter` 协议,对接飞书开放平台事件订阅 webhook。 关键设计决策: - 事件加密使用 AES-256-CBC(飞书官方协议),与 secrets store 的 AES-256-GCM 不同。 - 签名校验 fail-closed:``encrypt_key`` 缺失或签名头缺失一律返回 False。 - ``tenant_access_token`` 简单 TTL 缓存(5 分钟);过期后重新拉取。 - httpx 客户端懒构造,避免未使用的适配器持有连接池。 """ from __future__ import annotations import base64 import hashlib import hmac import json import logging import re import time from datetime import datetime, timezone import httpx from agentkit.channels.base import ( ChannelType, IncomingMessage, MessageAdapter, OutgoingMessage, URLVerificationChallenge, header_get, ) logger = logging.getLogger(__name__) # 签名时间戳允许的最大偏移(秒)— 与飞书官方文档保持一致 _SIGNATURE_MAX_AGE_SECONDS = 300 # tenant_access_token 缓存 TTL(秒)— 飞书 token 实际有效期 2h(7200s),留 5min 余量 _TOKEN_CACHE_TTL = 6900.0 # 飞书开放平台 API 端点 _TENANT_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" _SEND_MESSAGE_URL = "https://open.feishu.cn/open-apis/im/v1/messages" # @用户 提及标记正则 — 飞书文本消息中提及用户格式为 "@_user_N" _MENTION_RE = re.compile(r"@_user_\d+\s*") class SignatureVerificationError(Exception): """事件 ``verification_token`` 校验失败 — 拒绝处理。""" class FeishuMessageAdapter(MessageAdapter): """飞书 IM 适配器。 生命周期: ``__init__`` → :meth:`verify_signature` → :meth:`receive_message` → :meth:`send_message` → :meth:`close` Args: app_id: 飞书应用 App ID。 app_secret: 飞书应用 App Secret。 encrypt_key: 事件订阅加密密钥(可选 — 启用加密订阅时必填)。 verification_token: 事件订阅 Verification Token(可选 — 用于校验事件来源)。 """ def __init__( self, app_id: str, app_secret: str, encrypt_key: str | None = None, verification_token: str | None = None, ) -> None: super().__init__() self.app_id = app_id self.app_secret = app_secret self.encrypt_key = encrypt_key self.verification_token = verification_token # ponytail: 简单 TTL 缓存 (token, expiry)。天花板:单实例内存; # 升级路径:Redis 缓存共享给多实例。 self._token_cache: tuple[str, float] | None = None # ------------------------------------------------------------------ # 签名验证 # ------------------------------------------------------------------ async def verify_signature(self, headers: dict[str, str], body: bytes) -> bool: """验证飞书 webhook 签名。 fail-closed:``encrypt_key`` 缺失或签名头缺失一律返回 False。 时间戳超过 5 分钟视为重放攻击,拒绝。 Args: headers: HTTP 请求头(键大小写不敏感查找)。 body: 原始请求体字节。 Returns: True 表示签名校验通过。 """ if not self.encrypt_key: logger.warning("飞书适配器未配置 encrypt_key — 拒绝所有 webhook 请求") return False signature = header_get(headers, "X-Lark-Signature") if not signature: return False timestamp_str = header_get(headers, "X-Lark-Request-Timestamp") nonce = header_get(headers, "X-Lark-Request-Nonce") if not timestamp_str or not nonce: return False # 时间戳重放保护 try: ts = int(timestamp_str) except ValueError: return False now = datetime.now(timezone.utc).timestamp() if abs(now - ts) > _SIGNATURE_MAX_AGE_SECONDS: logger.warning("飞书 webhook 时间戳超出 %ds 窗口 — 拒绝", _SIGNATURE_MAX_AGE_SECONDS) return False # 计算签名:sha256(timestamp + nonce + encrypt_key + body) body_str = body.decode("utf-8") expected = hashlib.sha256( f"{timestamp_str}{nonce}{self.encrypt_key}{body_str}".encode("utf-8") ).hexdigest() return hmac.compare_digest(signature, expected) # ------------------------------------------------------------------ # 消息接收 / 解析 # ------------------------------------------------------------------ async def receive_message(self, headers: dict[str, str], body: bytes) -> IncomingMessage: """解析飞书 webhook 事件为标准化 :class:`IncomingMessage`。 Raises: URLVerificationChallenge: 事件为 URL 验证请求。 SignatureVerificationError: ``verification_token`` 不匹配。 ValueError: 事件结构无法解析。 """ try: data: dict[str, object] = json.loads(body) except json.JSONDecodeError as exc: raise ValueError(f"飞书事件 body 不是合法 JSON: {exc}") from exc # URL 验证流程 — 飞书配置 webhook 时发送 if "url_verification" in data or "challenge" in data: raise URLVerificationChallenge(data.get("challenge", "")) # 加密事件 — AES-256-CBC 解密 if "encrypt" in data: data = self._decrypt_event(data["encrypt"]) # 校验 verification_token if self.verification_token is not None: token = data.get("verification_token") or data.get("header", {}).get("token") if not token: raise SignatureVerificationError("事件缺少 verification_token 字段") if not hmac.compare_digest(token, self.verification_token): raise SignatureVerificationError("verification_token 不匹配") event_id = data.get("event_id") or data.get("header", {}).get("event_id", "") event = data.get("event", {}) message = event.get("message", {}) sender = event.get("sender", {}).get("sender_id", {}) chat_id = message.get("chat_id", "") open_id = sender.get("open_id", "") create_time = message.get("create_time", "") timestamp = str(create_time) if create_time else "" content = self._extract_content(message) return IncomingMessage( channel=ChannelType.FEISHU, platform_message_id=event_id, user_id=open_id, chat_id=chat_id, content=content, raw_event=data, timestamp=timestamp, ) def _decrypt_event(self, encrypt_b64: str) -> dict[str, object]: """AES-256-CBC 解密飞书加密事件。 飞书协议: key = sha256(encrypt_key).digest()[:32] ciphertext = IV(16B) + 密文 plaintext = AES-256-CBC 解密后去除 PKCS7 padding """ if not self.encrypt_key: raise SignatureVerificationError("加密事件但未配置 encrypt_key") from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.padding import PKCS7 key = hashlib.sha256(self.encrypt_key.encode("utf-8")).digest() ciphertext = base64.b64decode(encrypt_b64) if len(ciphertext) < 17: # IV(16) + 至少 1 字节密文 raise ValueError("加密事件密文长度不足") iv = ciphertext[:16] encrypted = ciphertext[16:] cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) decryptor = cipher.decryptor() padded = decryptor.update(encrypted) + decryptor.finalize() unpadder = PKCS7(algorithms.AES.block_size).unpadder() plaintext = unpadder.update(padded) + unpadder.finalize() return json.loads(plaintext.decode("utf-8")) def _extract_content(self, message: dict[str, object]) -> str: """从飞书 message 字段提取文本内容。 - text 类型:解析 ``content`` JSON 中的 ``text`` 字段,剥离 @ 提及标记。 - 其他类型:返回 ``[unsupported message type: {type}]``。 """ message_type = message.get("message_type", "") content_raw = message.get("content", "{}") if message_type == "text": try: content_obj = ( json.loads(content_raw) if isinstance(content_raw, str) else content_raw ) text = content_obj.get("text", "") if isinstance(content_obj, dict) else "" except (json.JSONDecodeError, TypeError): return "" # 剥离 @ 提及标记(如 "@_user_1 hello" → "hello") return _MENTION_RE.sub("", text).strip() return f"[unsupported message type: {message_type}]" # ------------------------------------------------------------------ # 消息发送 # ------------------------------------------------------------------ async def send_message(self, message: OutgoingMessage) -> bool: """向飞书发送文本消息。 Returns: True 表示 HTTP 200 且响应 ``code == 0``。 """ try: token = await self._get_tenant_access_token() if not token: return False client = self._get_client() payload = { "receive_id": message.chat_id, "msg_type": "text", "content": json.dumps({"text": message.content}), } resp = await client.post( _SEND_MESSAGE_URL, params={"receive_id_type": "chat_id"}, json=payload, headers={"Authorization": f"Bearer {token}"}, ) if resp.status_code != 200: logger.error("飞书 send_message HTTP %d: %s", resp.status_code, resp.text[:200]) return False data = resp.json() if data.get("code") != 0: logger.error( "飞书 send_message 业务失败 code=%s msg=%s", data.get("code"), data.get("msg", "")[:200], ) return False return True except httpx.HTTPError as exc: logger.error("飞书 send_message 网络错误: %s", exc) return False async def _get_tenant_access_token(self) -> str | None: """获取并缓存 ``tenant_access_token``。""" # 命中缓存 if self._token_cache is not None: token, expiry = self._token_cache if time.monotonic() < expiry: return token try: client = self._get_client() resp = await client.post( _TENANT_TOKEN_URL, json={"app_id": self.app_id, "app_secret": self.app_secret}, ) if resp.status_code != 200: logger.error("飞书 tenant_token HTTP %d: %s", resp.status_code, resp.text[:200]) return None data = resp.json() if data.get("code") != 0: logger.error( "飞书 tenant_token 业务失败 code=%s msg=%s", data.get("code"), data.get("msg", "")[:200], ) return None token = data.get("tenant_access_token", "") if not token: return None self._token_cache = (token, time.monotonic() + _TOKEN_CACHE_TTL) return token except httpx.HTTPError as exc: logger.error("飞书 tenant_token 网络错误: %s", exc) return None