fischer-agentkit/src/agentkit/channels/feishu.py

317 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""飞书 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