connector.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. from __future__ import annotations
  2. import logging
  3. from collections.abc import Mapping, Sequence
  4. from typing import Any, Literal, assert_never
  5. import httpx
  6. from gateway.core.channels.feishu.types import (
  7. FeishuReplyContext,
  8. IncomingFeishuEvent,
  9. mapping_to_feishu_event,
  10. )
  11. logger = logging.getLogger(__name__)
  12. # 飞书开放平台 reaction emoji_type,与 openclaw-lark FeishuEmoji.TYPING 一致
  13. TYPING_REACTION_EMOJI = "Typing"
  14. class WebhookParseError(ValueError):
  15. pass
  16. class FeishuHttpAdapterClient:
  17. """调用本仓库 Feishu 独立 HTTP 适配服务(默认端口 4380)。"""
  18. def __init__(self, base_url: str, *, timeout: float = 120.0) -> None:
  19. self._base = base_url.rstrip("/")
  20. self._timeout = timeout
  21. async def post_json(self, path: str, body: Mapping[str, Any]) -> dict[str, Any]:
  22. url = f"{self._base}{path if path.startswith('/') else '/' + path}"
  23. async with httpx.AsyncClient(timeout=self._timeout) as client:
  24. resp = await client.post(url, json=dict(body))
  25. try:
  26. data = resp.json()
  27. except Exception:
  28. data = {"ok": False, "error": "invalid_json", "status_code": resp.status_code}
  29. if not isinstance(data, dict):
  30. return {"ok": False, "error": "unexpected_response_shape"}
  31. if resp.status_code >= 400 and "ok" not in data:
  32. data = {**data, "ok": False, "status_code": resp.status_code}
  33. return data
  34. async def get_json(self, path: str) -> dict[str, Any]:
  35. url = f"{self._base}{path if path.startswith('/') else '/' + path}"
  36. async with httpx.AsyncClient(timeout=self._timeout) as client:
  37. resp = await client.get(url)
  38. try:
  39. data = resp.json()
  40. except Exception:
  41. return {"ok": False, "error": "invalid_json", "status_code": resp.status_code}
  42. return data if isinstance(data, dict) else {"ok": False, "error": "unexpected_response_shape"}
  43. FeishuHttpMessageKind = Literal["text", "card", "media", "raw"]
  44. class FeishuConnector:
  45. """
  46. 飞书连接器:解析 Feishu HTTP 适配层规范化入站事件,经适配服务发送消息。
  47. 入站 JSON 校验/解析见 `parse_feishu_inbound_event`(不依赖 HTTP 客户端,可按类调用)。
  48. 内部使用 ``FeishuHttpAdapterClient`` 访问 Node 适配器;不持有 app_secret,鉴权与 token 均在 Node 侧完成。
  49. 发送能力与适配器 POST ``/feishu/send-message`` 一致:
  50. ``text``(富文本 post)、``card``(交互卡片)、``media``(URL 上传后按类型发送)、
  51. ``raw``(显式 ``msg_type`` + JSON ``content``,覆盖 text/post/image/分享名片/贴纸等开放平台类型)。
  52. """
  53. @staticmethod
  54. def parse_feishu_inbound_event(body: Mapping[str, Any]) -> IncomingFeishuEvent:
  55. """
  56. 校验并解析 Feishu HTTP 适配层 ``forwardEventToGateway`` 写入的 JSON。
  57. 支持 event_type: message | reaction | card_action(与适配服务 server.ts 一致)。
  58. """
  59. if not body:
  60. raise WebhookParseError("empty body")
  61. event_type = body.get("event_type")
  62. if not isinstance(event_type, str) or not event_type:
  63. raise WebhookParseError("missing or invalid event_type")
  64. app_id = body.get("app_id")
  65. if not isinstance(app_id, str) or not app_id:
  66. raise WebhookParseError("missing or invalid app_id")
  67. event = mapping_to_feishu_event(body)
  68. if event_type == "message":
  69. if not event.chat_id:
  70. raise WebhookParseError("message event requires chat_id")
  71. if event.message_id is None or event.message_id == "":
  72. raise WebhookParseError("message event requires message_id")
  73. elif event_type == "reaction":
  74. if not event.chat_id:
  75. raise WebhookParseError("reaction event requires chat_id")
  76. if not event.message_id:
  77. raise WebhookParseError("reaction event requires message_id")
  78. elif event_type == "card_action":
  79. pass
  80. else:
  81. pass
  82. return event
  83. def __init__(
  84. self,
  85. *,
  86. feishu_http_base_url: str = "http://127.0.0.1:4380",
  87. timeout: float = 120.0,
  88. ) -> None:
  89. self._http = FeishuHttpAdapterClient(feishu_http_base_url, timeout=timeout)
  90. def handle_webhook(self, event: Mapping[str, Any]) -> dict[str, Any]:
  91. """校验 payload(对应 channels.md 的 handle_webhook)。"""
  92. try:
  93. ev: IncomingFeishuEvent = self.parse_feishu_inbound_event(event)
  94. return {"ok": True, "parsed": True, "event_type": ev.event_type}
  95. except WebhookParseError as e:
  96. return {"ok": False, "error": str(e)}
  97. def parse_normalized_event(self, payload: Mapping[str, Any]) -> IncomingFeishuEvent:
  98. return self.parse_feishu_inbound_event(payload)
  99. async def send_message(
  100. self,
  101. user_id: str,
  102. text: str,
  103. *,
  104. reply_context: FeishuReplyContext | None = None,
  105. ) -> dict[str, Any]:
  106. """
  107. channels.md 签名兼容:user_id 未使用时需传入 reply_context。
  108. 实际发送依赖 chat_id(与 server.ts /feishu/send-message 一致)。
  109. """
  110. if reply_context is None:
  111. return {"ok": False, "error": "reply_context_required_for_feishu_adapter"}
  112. return await self.send_text(reply_context, text)
  113. def _send_message_base(
  114. self,
  115. ctx: FeishuReplyContext,
  116. *,
  117. reply_in_thread: bool | None = None,
  118. ) -> dict[str, Any]:
  119. body: dict[str, Any] = {"chat_id": ctx.chat_id}
  120. if ctx.account_id:
  121. body["account_id"] = ctx.account_id
  122. if ctx.message_id:
  123. body["reply_to_message_id"] = ctx.message_id
  124. if reply_in_thread is not None:
  125. body["reply_in_thread"] = reply_in_thread
  126. return body
  127. async def post_send_message(self, body: Mapping[str, Any]) -> dict[str, Any]:
  128. """POST ``/feishu/send-message``;``body`` 需含 ``kind`` 及对应字段(与适配器一致)。"""
  129. payload = dict(body)
  130. result = await self._http.post_json("/feishu/send-message", payload)
  131. if not result.get("ok"):
  132. logger.warning("feishu send-message failed: %s", result)
  133. return result
  134. async def send_text(
  135. self,
  136. ctx: FeishuReplyContext,
  137. text: str,
  138. *,
  139. reply_in_thread: bool | None = None,
  140. ) -> dict[str, Any]:
  141. body = self._send_message_base(ctx, reply_in_thread=reply_in_thread)
  142. body["kind"] = "text"
  143. body["text"] = text
  144. return await self.post_send_message(body)
  145. async def send_card(
  146. self,
  147. ctx: FeishuReplyContext,
  148. card: Mapping[str, Any],
  149. *,
  150. reply_in_thread: bool | None = None,
  151. ) -> dict[str, Any]:
  152. """发送交互卡片(``msg_type: interactive``)。"""
  153. body = self._send_message_base(ctx, reply_in_thread=reply_in_thread)
  154. body["kind"] = "card"
  155. body["card"] = dict(card)
  156. return await self.post_send_message(body)
  157. async def send_media(
  158. self,
  159. ctx: FeishuReplyContext,
  160. media_url: str,
  161. *,
  162. reply_in_thread: bool | None = None,
  163. media_local_roots: Sequence[str] | None = None,
  164. file_name: str | None = None,
  165. ) -> dict[str, Any]:
  166. """
  167. 发送图片 / 视频 / 普通文件 / 音频(opus/mp4 等由适配器根据扩展名与内容判定)。
  168. ``media_url`` 可为 http(s) 或适配器允许的本地路径;本地路径需配合服务端
  169. ``media_local_roots`` 白名单。
  170. """
  171. body = self._send_message_base(ctx, reply_in_thread=reply_in_thread)
  172. body["kind"] = "media"
  173. body["media_url"] = media_url
  174. if media_local_roots is not None:
  175. body["media_local_roots"] = list(media_local_roots)
  176. if file_name:
  177. body["file_name"] = file_name
  178. return await self.post_send_message(body)
  179. async def send_raw_im(
  180. self,
  181. ctx: FeishuReplyContext,
  182. msg_type: str,
  183. content: str | Mapping[str, Any],
  184. *,
  185. reply_in_thread: bool | None = None,
  186. uuid: str | None = None,
  187. ) -> dict[str, Any]:
  188. """
  189. 使用机器人身份发送任意已支持的 ``msg_type``,``content`` 为合法 JSON 字符串或对象。
  190. 常用示例:``text`` → ``{"text": "你好"}``;``share_chat`` → ``{"chat_id": "oc_xxx"}``;
  191. ``image`` → ``{"image_key": "img_xxx"}``(需先上传)。具体格式以飞书 IM 开放文档为准。
  192. """
  193. body = self._send_message_base(ctx, reply_in_thread=reply_in_thread)
  194. body["kind"] = "raw"
  195. body["msg_type"] = msg_type
  196. body["content"] = content if isinstance(content, str) else dict(content)
  197. if uuid:
  198. body["uuid"] = uuid
  199. return await self.post_send_message(body)
  200. async def send(
  201. self,
  202. ctx: FeishuReplyContext,
  203. *,
  204. kind: FeishuHttpMessageKind = "text",
  205. reply_in_thread: bool | None = None,
  206. text: str | None = None,
  207. card: Mapping[str, Any] | None = None,
  208. media_url: str | None = None,
  209. media_local_roots: Sequence[str] | None = None,
  210. file_name: str | None = None,
  211. msg_type: str | None = None,
  212. content: str | Mapping[str, Any] | None = None,
  213. uuid: str | None = None,
  214. ) -> dict[str, Any]:
  215. """与适配器 ``kind`` 对齐的统一发送入口。"""
  216. if kind == "text":
  217. if not text:
  218. return {"ok": False, "error": "missing_text"}
  219. return await self.send_text(ctx, text, reply_in_thread=reply_in_thread)
  220. if kind == "card":
  221. if card is None:
  222. return {"ok": False, "error": "missing_card"}
  223. return await self.send_card(ctx, card, reply_in_thread=reply_in_thread)
  224. if kind == "media":
  225. if not media_url:
  226. return {"ok": False, "error": "missing_media_url"}
  227. return await self.send_media(
  228. ctx,
  229. media_url,
  230. reply_in_thread=reply_in_thread,
  231. media_local_roots=media_local_roots,
  232. file_name=file_name,
  233. )
  234. if kind == "raw":
  235. if not msg_type or content is None:
  236. return {"ok": False, "error": "raw_requires_msg_type_and_content"}
  237. return await self.send_raw_im(
  238. ctx,
  239. msg_type,
  240. content,
  241. reply_in_thread=reply_in_thread,
  242. uuid=uuid,
  243. )
  244. assert_never(kind)
  245. async def list_feishu_app_accounts(self) -> dict[str, Any]:
  246. """
  247. 拉取适配器 GET `/accounts`:已启用、已配置的飞书应用(机器人)列表。
  248. 与终端用户 open_id 无关;若要用户姓名/头像等需走开放平台 contact 等接口。
  249. """
  250. try:
  251. data = await self._http.get_json("/accounts")
  252. if data.get("ok") and "accounts" in data:
  253. return {"ok": True, "accounts": data.get("accounts")}
  254. except Exception as e:
  255. logger.debug("list_feishu_app_accounts: %s", e)
  256. return {"ok": True, "accounts": []}
  257. async def get_user_info(self, user_id: str) -> dict[str, Any]:
  258. """
  259. 飞书「用户」个人资料:当前 HTTP 适配器未暴露联系人查询,仅回传占位。
  260. 已配置应用列表请用 `list_feishu_app_accounts()`(勿与 `/accounts` 语义混淆)。
  261. """
  262. return {"ok": True, "user_id": user_id, "profile": None}
  263. async def add_message_reaction(
  264. self,
  265. message_id: str,
  266. emoji: str,
  267. *,
  268. account_id: str | None = None,
  269. ) -> dict[str, Any]:
  270. """POST ``/feishu/react``,为消息添加机器人表情(``emoji`` 为开放平台 emoji_type,如 ``Typing``)。"""
  271. body: dict[str, Any] = {
  272. "action": "add",
  273. "message_id": message_id,
  274. "emoji": emoji,
  275. }
  276. if account_id:
  277. body["account_id"] = account_id
  278. return await self._http.post_json("/feishu/react", body)
  279. async def remove_bot_reactions_for_emoji(
  280. self,
  281. message_id: str,
  282. emoji: str,
  283. *,
  284. account_id: str | None = None,
  285. ) -> dict[str, Any]:
  286. """移除该消息上本机器人添加的指定 ``emoji`` 表情(适配器 ``action: remove``)。"""
  287. body: dict[str, Any] = {
  288. "action": "remove",
  289. "message_id": message_id,
  290. "emoji": emoji,
  291. }
  292. if account_id:
  293. body["account_id"] = account_id
  294. return await self._http.post_json("/feishu/react", body)