317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""飞书 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
|