Coverage for agentos/channels/adapters/feishu.py: 0%
83 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +0800
1"""
2AgentOS Channels — 飞书适配器。
4Webhook 规范: https://open.feishu.cn/document/server-docs/im-v1/message-content-description
6特性:
7 - JSON 报文解析
8 - 应用 Token + tenant access token 双 token 管理
9 - 卡片消息支持
10 - 消息回复(被动 + 主动)
11"""
13from __future__ import annotations
15import json
16import time
18import httpx
20from agentos.channels.base import BaseChannelAdapter, ChannelConfig, ReplyResult
21from agentos.channels.message import ChannelMessage, ChannelType, MessageType
24class FeishuAdapter(BaseChannelAdapter):
25 """飞书适配器。"""
27 channel_type = ChannelType.FEISHU
29 def __init__(self, config: ChannelConfig):
30 super().__init__(config)
31 self._app_token: str = ""
32 self._tenant_token: str = ""
33 self._token_expires: float = 0
35 # ── Webhook ──
37 def verify_signature(self, raw_body: bytes, headers: dict) -> bool:
38 """验证飞书事件订阅签名。
40 签名算法: Base64Encode(SHA256(timestamp + nonce + encrypt_key))
41 文档: https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-
42 """
43 import base64
44 import hashlib
46 timestamp = headers.get("X-Lark-Request-Timestamp", "")
47 nonce = headers.get("X-Lark-Request-Nonce", "")
48 signature = headers.get("X-Lark-Signature", "")
50 encrypt_key = self.config.encoding_aes_key or self.config.verify_token
51 if not all([timestamp, nonce, signature, encrypt_key]):
52 return False
54 raw = f"{timestamp}{nonce}{encrypt_key}"
55 computed = base64.b64encode(hashlib.sha256(raw.encode()).digest()).decode()
56 return signature == computed
58 def parse_webhook(self, raw_body: bytes, headers: dict) -> ChannelMessage | list[ChannelMessage]:
59 data = json.loads(raw_body.decode("utf-8"))
60 # 飞书事件格式: {"schema": "2.0", "header": {...}, "event": {...}}
61 event = data.get("event", data)
62 header = data.get("header", {})
64 # 处理 URL 验证
65 if data.get("type") == "url_verification":
66 return ChannelMessage(
67 msg_id="url_verify",
68 channel=ChannelType.FEISHU,
69 msg_type=MessageType.EVENT,
70 content=data.get("challenge", ""),
71 reply_token=data.get("token", ""),
72 extra={"is_challenge": True, "challenge": data.get("challenge", "")},
73 )
75 msg_type_str = event.get("message", {}).get("message_type", "text")
76 msg_type_map = {
77 "text": MessageType.TEXT, "image": MessageType.IMAGE,
78 "audio": MessageType.VOICE, "media": MessageType.FILE,
79 "file": MessageType.FILE, "post": MessageType.TEXT,
80 }
81 msg_type = msg_type_map.get(msg_type_str, MessageType.TEXT)
83 message = event.get("message", {})
84 content = ""
85 if msg_type_str == "text":
86 content = json.loads(message.get("content", "{}")).get("text", "")
87 elif msg_type_str == "post":
88 content = str(message.get("content", ""))[:200]
90 sender = event.get("sender", {})
91 sender_id = sender.get("sender_id", {}).get("open_id", "")
93 return ChannelMessage(
94 msg_id=header.get("event_id", event.get("message", {}).get("message_id", "")),
95 channel=ChannelType.FEISHU,
96 msg_type=msg_type,
97 content=content,
98 sender_id=sender_id,
99 sender_name="",
100 timestamp=float(header.get("create_time", str(int(time.time() * 1000)))) / 1000,
101 conversation_id=event.get("message", {}).get("chat_id", ""),
102 reply_token=event.get("message", {}).get("message_id", ""),
103 media_url=message.get("image_key", ""),
104 extra={
105 "tenant_key": header.get("tenant_key"),
106 "event_type": header.get("event_type"),
107 "chat_type": event.get("message", {}).get("chat_type", "p2p"),
108 "root_id": event.get("message", {}).get("root_id"),
109 "parent_id": event.get("message", {}).get("parent_id"),
110 },
111 )
113 def build_reply(self, msg: ChannelMessage, reply_text: str) -> str:
114 return json.dumps({
115 "msg_type": "text",
116 "content": json.dumps({"text": reply_text}),
117 })
119 # ── 主动推送 ──
121 async def send_message(self, user_id: str, content: str, msg_type: str = "text") -> ReplyResult:
122 token = await self.get_access_token()
123 url = "https://open.feishu.cn/open-apis/im/v1/messages"
124 payload = {
125 "receive_id": user_id,
126 "msg_type": "text",
127 "content": json.dumps({"text": content}),
128 }
129 headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
130 async with httpx.AsyncClient() as client:
131 resp = await client.post(url, params={"receive_id_type": "open_id"}, json=payload, headers=headers, timeout=10)
132 data = resp.json()
133 if data.get("code") == 0:
134 return ReplyResult(success=True, msg_id=data.get("data", {}).get("message_id", ""))
135 return ReplyResult(success=False, error=f"feishu error {data.get('code')}: {data.get('msg')}")
137 async def send_image(self, user_id: str, image_url: str) -> ReplyResult:
138 token = await self.get_access_token()
139 url = "https://open.feishu.cn/open-apis/im/v1/messages"
140 payload = {
141 "receive_id": user_id,
142 "msg_type": "image",
143 "content": json.dumps({"image_key": image_url}),
144 }
145 headers = {"Authorization": f"Bearer {token}"}
146 async with httpx.AsyncClient() as client:
147 resp = await client.post(url, params={"receive_id_type": "open_id"}, json=payload, headers=headers, timeout=10)
148 return ReplyResult(success=resp.json().get("code") == 0)
150 async def send_file(self, user_id: str, file_url: str, filename: str) -> ReplyResult:
151 token = await self.get_access_token()
152 url = "https://open.feishu.cn/open-apis/im/v1/messages"
153 payload = {
154 "receive_id": user_id,
155 "msg_type": "file",
156 "content": json.dumps({"file_key": file_url}),
157 }
158 headers = {"Authorization": f"Bearer {token}"}
159 async with httpx.AsyncClient() as client:
160 resp = await client.post(url, params={"receive_id_type": "open_id"}, json=payload, headers=headers, timeout=10)
161 return ReplyResult(success=resp.json().get("code") == 0)
163 # ── Token ──
165 async def get_access_token(self) -> str:
166 if self._tenant_token and time.time() < self._token_expires - 300:
167 return self._tenant_token
168 url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
169 payload = {"app_id": self.config.app_id, "app_secret": self.config.app_secret}
170 async with httpx.AsyncClient() as client:
171 resp = await client.post(url, json=payload, timeout=10)
172 data = resp.json()
173 self._tenant_token = data["tenant_access_token"]
174 self._token_expires = time.time() + data.get("expire", 7200)
175 return self._tenant_token