feishu_client.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945
  1. """
  2. 飞书消息处理客户端
  3. 基于 OpenClaw 项目的飞书集成代码整理
  4. 依赖安装:
  5. pip install lark-oapi websocket-client requests
  6. 使用示例:
  7. client = FeishuClient(app_id="cli_xxx", app_secret="xxx")
  8. # 发送消息
  9. client.send_message(to="ou_xxx", text="Hello!")
  10. # 监听消息
  11. client.start_websocket(on_message=my_handler)
  12. """
  13. import json
  14. import io
  15. import logging
  16. import os
  17. import tempfile
  18. import threading
  19. from dataclasses import dataclass, field
  20. from enum import Enum
  21. from typing import Any, Callable, Dict, List, Optional, Union
  22. import lark_oapi as lark
  23. from lark_oapi.api.contact.v3 import GetUserRequest, GetUserResponse
  24. from lark_oapi.api.im.v1 import (
  25. CreateMessageRequest, CreateMessageRequestBody,
  26. ReplyMessageRequest, ReplyMessageRequestBody,
  27. GetMessageRequest, GetMessageResponse,
  28. PatchMessageRequest, PatchMessageRequestBody,
  29. CreateImageRequest, CreateImageRequestBody,
  30. GetImageRequest,
  31. CreateFileRequest, CreateFileRequestBody,
  32. GetMessageResourceRequest, GetMessageResourceResponse,
  33. ListMessageRequest, ListMessageResponse
  34. )
  35. logging.basicConfig(level=logging.INFO)
  36. logger = logging.getLogger(__name__)
  37. class FeishuDomain(Enum):
  38. """飞书域名"""
  39. FEISHU = "https://open.feishu.cn" # 中国版
  40. LARK = "https://open.larksuite.com" # 国际版
  41. class ChatType(Enum):
  42. """聊天类型"""
  43. P2P = "p2p" # 私聊
  44. GROUP = "group" # 群聊
  45. class ReceiveIdType(Enum):
  46. """接收者ID类型"""
  47. OPEN_ID = "open_id"
  48. USER_ID = "user_id"
  49. UNION_ID = "union_id"
  50. EMAIL = "email"
  51. CHAT_ID = "chat_id"
  52. @dataclass
  53. class FeishuMessageEvent:
  54. """飞书消息事件"""
  55. message_id: str
  56. chat_id: str
  57. chat_type: ChatType
  58. content: str
  59. content_type: str # text, image, file, post, etc.
  60. sender_open_id: str
  61. sender_user_id: Optional[str] = None
  62. sender_name: Optional[str] = None
  63. root_id: Optional[str] = None # 根消息ID(话题)
  64. parent_id: Optional[str] = None # 父消息ID(回复)
  65. mentions: List[Dict] = field(default_factory=list)
  66. mentioned_bot: bool = False
  67. @dataclass
  68. class SendResult:
  69. """发送结果"""
  70. message_id: str
  71. chat_id: str
  72. class FeishuClient:
  73. """
  74. 飞书客户端
  75. 功能:
  76. - 发送/接收消息
  77. - 上传/下载媒体文件
  78. - WebSocket 实时监听
  79. """
  80. def __init__(
  81. self,
  82. app_id: str,
  83. app_secret: str,
  84. domain: FeishuDomain = FeishuDomain.FEISHU,
  85. encrypt_key: Optional[str] = None,
  86. verification_token: Optional[str] = None,
  87. ):
  88. """
  89. 初始化飞书客户端
  90. Args:
  91. app_id: 飞书应用 App ID
  92. app_secret: 飞书应用 App Secret
  93. domain: 飞书域名 (FEISHU 或 LARK)
  94. encrypt_key: 事件加密密钥 (可选)
  95. verification_token: 事件验证令牌 (可选)
  96. """
  97. self.app_id = app_id
  98. self.app_secret = app_secret
  99. self.domain = domain
  100. self.encrypt_key = encrypt_key
  101. self.verification_token = verification_token
  102. # 创建 Lark 客户端
  103. self.client = lark.Client.builder() \
  104. .app_id(app_id) \
  105. .app_secret(app_secret) \
  106. .domain(domain.value) \
  107. .build()
  108. # 缓存
  109. self._bot_open_id: Optional[str] = None
  110. self._sender_name_cache: Dict[str, str] = {}
  111. # ==================== 消息发送 ====================
  112. def send_message(
  113. self,
  114. to: str,
  115. text: str,
  116. reply_to_message_id: Optional[str] = None,
  117. receive_id_type: Optional[ReceiveIdType] = None,
  118. ) -> SendResult:
  119. """
  120. 发送文本消息
  121. Args:
  122. to: 接收者ID (open_id, user_id, chat_id 等)
  123. text: 消息文本
  124. reply_to_message_id: 回复的消息ID (可选)
  125. receive_id_type: 接收者ID类型 (可选,自动推断)
  126. Returns:
  127. SendResult: 发送结果
  128. """
  129. if receive_id_type is None:
  130. receive_id_type = self._resolve_receive_id_type(to)
  131. # 构建富文本消息 (支持 Markdown)
  132. content = json.dumps({
  133. "zh_cn": {
  134. "content": [[{"tag": "md", "text": text}]]
  135. }
  136. })
  137. if reply_to_message_id:
  138. # 回复消息
  139. request = ReplyMessageRequest.builder() \
  140. .message_id(reply_to_message_id) \
  141. .request_body(ReplyMessageRequestBody.builder()
  142. .content(content)
  143. .msg_type("post")
  144. .build()) \
  145. .build()
  146. response = self.client.im.v1.message.reply(request)
  147. else:
  148. # 新消息
  149. request = CreateMessageRequest.builder() \
  150. .receive_id_type(receive_id_type.value) \
  151. .request_body(CreateMessageRequestBody.builder()
  152. .receive_id(to)
  153. .content(content)
  154. .msg_type("post")
  155. .build()) \
  156. .build()
  157. response = self.client.im.v1.message.create(request)
  158. if not response.success():
  159. raise Exception(f"发送消息失败: {response.msg} (code: {response.code})")
  160. return SendResult(
  161. message_id=response.data.message_id,
  162. chat_id=response.data.chat_id
  163. )
  164. def send_card(
  165. self,
  166. to: str,
  167. card: Dict[str, Any],
  168. reply_to_message_id: Optional[str] = None,
  169. receive_id_type: Optional[ReceiveIdType] = None,
  170. ) -> SendResult:
  171. """
  172. 发送卡片消息 (交互式消息)
  173. Args:
  174. to: 接收者ID
  175. card: 卡片内容 (JSON 结构)
  176. reply_to_message_id: 回复的消息ID (可选)
  177. receive_id_type: 接收者ID类型 (可选)
  178. Returns:
  179. SendResult: 发送结果
  180. """
  181. if receive_id_type is None:
  182. receive_id_type = self._resolve_receive_id_type(to)
  183. content = json.dumps(card)
  184. if reply_to_message_id:
  185. request = ReplyMessageRequest.builder() \
  186. .message_id(reply_to_message_id) \
  187. .request_body(ReplyMessageRequestBody.builder()
  188. .content(content)
  189. .msg_type("interactive")
  190. .build()) \
  191. .build()
  192. response = self.client.im.v1.message.reply(request)
  193. else:
  194. request = CreateMessageRequest.builder() \
  195. .receive_id_type(receive_id_type.value) \
  196. .request_body(CreateMessageRequestBody.builder()
  197. .receive_id(to)
  198. .content(content)
  199. .msg_type("interactive")
  200. .build()) \
  201. .build()
  202. response = self.client.im.v1.message.create(request)
  203. if not response.success():
  204. raise Exception(f"发送卡片失败: {response.msg}")
  205. return SendResult(
  206. message_id=response.data.message_id,
  207. chat_id=response.data.chat_id
  208. )
  209. def send_markdown_card(
  210. self,
  211. to: str,
  212. text: str,
  213. reply_to_message_id: Optional[str] = None,
  214. ) -> SendResult:
  215. """
  216. 发送 Markdown 卡片 (更好的格式渲染)
  217. Args:
  218. to: 接收者ID
  219. text: Markdown 文本
  220. reply_to_message_id: 回复的消息ID (可选)
  221. Returns:
  222. SendResult: 发送结果
  223. """
  224. card = {
  225. "config": {"wide_screen_mode": True},
  226. "elements": [{"tag": "markdown", "content": text}]
  227. }
  228. return self.send_card(to, card, reply_to_message_id)
  229. # ==================== 媒体处理 ====================
  230. def upload_image(
  231. self,
  232. image: Union[bytes, str],
  233. image_type: str = "message"
  234. ) -> str:
  235. """
  236. 上传图片
  237. """
  238. file_obj = None
  239. try:
  240. # 1. 准备文件对象
  241. if isinstance(image, str):
  242. # 如果是路径,直接打开
  243. file_obj = open(image, "rb")
  244. else:
  245. # 如果是二进制数据,使用内存文件 (避免写磁盘)
  246. file_obj = io.BytesIO(image)
  247. # 某些 SDK/API 依赖文件名来判断 Content-Type,我们手动给一个名字
  248. # 如果知道真实格式更好,不知道则默认 .png 或 .bin
  249. file_obj.name = "upload.png"
  250. # 2. 构建请求
  251. # 注意:这里直接传入 file_obj
  252. request = CreateImageRequest.builder() \
  253. .request_body(CreateImageRequestBody.builder()
  254. .image_type(image_type)
  255. .image(file_obj)
  256. .build()) \
  257. .build()
  258. # 3. 发起请求
  259. response = self.client.im.v1.image.create(request)
  260. if not response.success():
  261. raise Exception(f"上传图片失败: {response.msg}")
  262. return response.data.image_key
  263. finally:
  264. # 4. 显式关闭文件句柄
  265. if file_obj and not isinstance(file_obj, io.BytesIO):
  266. file_obj.close()
  267. def download_image(self, image_key: str) -> bytes:
  268. """
  269. 下载图片
  270. Args:
  271. image_key: 图片 key
  272. Returns:
  273. bytes: 图片数据
  274. """
  275. request = GetImageRequest.builder() \
  276. .image_key(image_key) \
  277. .build()
  278. response = self.client.im.v1.image.get(request)
  279. if not response.success():
  280. raise Exception(f"下载图片失败: {response.msg}")
  281. return response.file.read()
  282. def send_image(
  283. self,
  284. to: str,
  285. image: Union[bytes, str],
  286. reply_to_message_id: Optional[str] = None,
  287. ) -> SendResult:
  288. """
  289. 发送图片消息
  290. Args:
  291. to: 接收者ID
  292. image: 图片数据或文件路径
  293. reply_to_message_id: 回复的消息ID (可选)
  294. Returns:
  295. SendResult: 发送结果
  296. """
  297. image_key = self.upload_image(image)
  298. content = json.dumps({"image_key": image_key})
  299. receive_id_type = self._resolve_receive_id_type(to)
  300. if reply_to_message_id:
  301. request = ReplyMessageRequest.builder() \
  302. .message_id(reply_to_message_id) \
  303. .request_body(ReplyMessageRequestBody.builder()
  304. .content(content)
  305. .msg_type("image")
  306. .build()) \
  307. .build()
  308. response = self.client.im.v1.message.reply(request)
  309. else:
  310. request = CreateMessageRequest.builder() \
  311. .receive_id_type(receive_id_type.value) \
  312. .request_body(CreateMessageRequestBody.builder()
  313. .receive_id(to)
  314. .content(content)
  315. .msg_type("image")
  316. .build()) \
  317. .build()
  318. response = self.client.im.v1.message.create(request)
  319. if not response.success():
  320. raise Exception(f"发送图片失败: {response.msg}")
  321. return SendResult(
  322. message_id=response.data.message_id,
  323. chat_id=response.data.chat_id
  324. )
  325. def upload_file(
  326. self,
  327. file: Union[bytes, str],
  328. file_name: str,
  329. file_type: str = "stream",
  330. ) -> str:
  331. """
  332. 上传文件
  333. Args:
  334. file: 文件数据或路径
  335. file_name: 文件名
  336. file_type: 文件类型 (opus/mp4/pdf/doc/xls/ppt/stream)
  337. Returns:
  338. str: file_key
  339. """
  340. if isinstance(file, str):
  341. with open(file, "rb") as f:
  342. file_data = f.read()
  343. if not file_name:
  344. file_name = os.path.basename(file)
  345. else:
  346. file_data = file
  347. with tempfile.NamedTemporaryFile(delete=False) as tmp:
  348. tmp.write(file_data)
  349. tmp_path = tmp.name
  350. try:
  351. request = CreateFileRequest.builder() \
  352. .request_body(CreateFileRequestBody.builder()
  353. .file_type(file_type)
  354. .file_name(file_name)
  355. .file(open(tmp_path, "rb"))
  356. .build()) \
  357. .build()
  358. response = self.client.im.v1.file.create(request)
  359. if not response.success():
  360. raise Exception(f"上传文件失败: {response.msg}")
  361. return response.data.file_key
  362. finally:
  363. os.unlink(tmp_path)
  364. def send_file(
  365. self,
  366. to: str,
  367. file: Union[bytes, str],
  368. file_name: str,
  369. reply_to_message_id: Optional[str] = None,
  370. ) -> SendResult:
  371. """
  372. 发送文件消息
  373. Args:
  374. to: 接收者ID
  375. file: 文件数据或路径
  376. file_name: 文件名
  377. reply_to_message_id: 回复的消息ID (可选)
  378. Returns:
  379. SendResult: 发送结果
  380. """
  381. file_type = self._detect_file_type(file_name)
  382. file_key = self.upload_file(file, file_name, file_type)
  383. content = json.dumps({"file_key": file_key})
  384. receive_id_type = self._resolve_receive_id_type(to)
  385. if reply_to_message_id:
  386. request = ReplyMessageRequest.builder() \
  387. .message_id(reply_to_message_id) \
  388. .request_body(ReplyMessageRequestBody.builder()
  389. .content(content)
  390. .msg_type("file")
  391. .build()) \
  392. .build()
  393. response = self.client.im.v1.message.reply(request)
  394. else:
  395. request = CreateMessageRequest.builder() \
  396. .receive_id_type(receive_id_type.value) \
  397. .request_body(CreateMessageRequestBody.builder()
  398. .receive_id(to)
  399. .content(content)
  400. .msg_type("file")
  401. .build()) \
  402. .build()
  403. response = self.client.im.v1.message.create(request)
  404. if not response.success():
  405. raise Exception(f"发送文件失败: {response.msg}")
  406. return SendResult(
  407. message_id=response.data.message_id,
  408. chat_id=response.data.chat_id
  409. )
  410. def download_message_resource(
  411. self,
  412. message_id: str,
  413. file_key: str,
  414. resource_type: str = "file"
  415. ) -> bytes:
  416. """
  417. 下载消息中的资源文件
  418. Args:
  419. message_id: 消息ID
  420. file_key: 文件 key
  421. resource_type: 资源类型 ("image" 或 "file")
  422. Returns:
  423. bytes: 文件数据
  424. """
  425. request = GetMessageResourceRequest.builder() \
  426. .message_id(message_id) \
  427. .file_key(file_key) \
  428. .type(resource_type) \
  429. .build()
  430. response = self.client.im.v1.message_resource.get(request)
  431. if not response.success():
  432. raise Exception(f"下载资源失败: {response.msg}")
  433. return response.file.read()
  434. # ==================== 消息获取 ====================
  435. def get_message(self, message_id: str) -> Optional[Dict]:
  436. """
  437. 获取消息详情
  438. Args:
  439. message_id: 消息ID
  440. Returns:
  441. Dict: 消息详情,失败返回 None
  442. """
  443. request = GetMessageRequest.builder() \
  444. .message_id(message_id) \
  445. .build()
  446. response = self.client.im.v1.message.get(request)
  447. if not response.success():
  448. return None
  449. items = response.data.items
  450. if not items:
  451. return None
  452. item = items[0]
  453. content = item.body.content if item.body else ""
  454. # 解析文本内容
  455. try:
  456. parsed = json.loads(content)
  457. if item.msg_type == "text" and "text" in parsed:
  458. content = parsed["text"]
  459. except:
  460. pass
  461. return {
  462. "message_id": item.message_id,
  463. "chat_id": item.chat_id,
  464. "sender_id": item.sender.id if item.sender else None,
  465. "sender_type": item.sender.sender_type if item.sender else None,
  466. "content": content,
  467. "content_type": item.msg_type,
  468. "create_time": item.create_time,
  469. }
  470. def get_message_list(self, chat_id: str, start_time: Optional[Union[str, int]] = None, end_time: Optional[Union[str, int]] = None, page_size: int = 20, page_token: Optional[str] = None) -> Optional[Dict]:
  471. """
  472. 获取消息列表
  473. Args:
  474. chat_id: 会话 ID
  475. start_time: 起始时间 (可选)
  476. end_time: 结束时间 (可选)
  477. page_size: 分页大小 (默认 20)
  478. page_token: 分页令牌 (可选)
  479. Returns:
  480. Dict: 包含消息列表和分页信息,失败返回 None
  481. """
  482. builder = ListMessageRequest.builder() \
  483. .container_id_type("chat") \
  484. .container_id(chat_id) \
  485. .sort_type("ByCreateTimeDesc") \
  486. .page_size(page_size)
  487. if start_time is not None:
  488. builder.start_time(str(start_time))
  489. if end_time is not None:
  490. builder.end_time(str(end_time))
  491. if page_token:
  492. builder.page_token(page_token)
  493. request = builder.build()
  494. # 发起请求
  495. response: ListMessageResponse = self.client.im.v1.message.list(request)
  496. # 处理失败返回
  497. if not response.success():
  498. logger.error(
  499. f"client.im.v1.message.list failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}")
  500. return None
  501. # 构建返回结果
  502. messages = []
  503. if response.data.items:
  504. for item in response.data.items:
  505. content = item.body.content if item.body else ""
  506. # 解析文本内容
  507. try:
  508. parsed = json.loads(content)
  509. if item.msg_type == "text" and "text" in parsed:
  510. content = parsed["text"]
  511. except:
  512. pass
  513. messages.append({
  514. "message_id": item.message_id,
  515. "chat_id": item.chat_id,
  516. "sender_id": item.sender.id if item.sender else None,
  517. "sender_type": item.sender.sender_type if item.sender else None,
  518. "content": content,
  519. "content_type": item.msg_type,
  520. "create_time": item.create_time,
  521. })
  522. return {
  523. "items": messages,
  524. "page_token": response.data.page_token,
  525. "has_more": response.data.has_more
  526. }
  527. # ==================== 用户信息 ====================
  528. def get_user_info(self, open_id: str) -> Optional[Dict]:
  529. """
  530. 获取用户信息
  531. Args:
  532. open_id: 用户 open_id
  533. Returns:
  534. Dict: 用户信息,失败返回 None
  535. """
  536. # 检查缓存
  537. if open_id in self._sender_name_cache:
  538. return {"name": self._sender_name_cache[open_id]}
  539. request = GetUserRequest.builder() \
  540. .user_id(open_id) \
  541. .user_id_type("open_id") \
  542. .build()
  543. response = self.client.contact.v3.user.get(request)
  544. if not response.success():
  545. return None
  546. user = response.data.user
  547. name = user.name or user.en_name or user.nickname
  548. if name:
  549. self._sender_name_cache[open_id] = name
  550. return {
  551. "open_id": user.open_id,
  552. "user_id": user.user_id,
  553. "name": name,
  554. "en_name": user.en_name,
  555. "nickname": user.nickname,
  556. "email": user.email,
  557. "mobile": user.mobile,
  558. "avatar": user.avatar.avatar_origin if user.avatar else None,
  559. }
  560. # ==================== WebSocket 监听 ====================
  561. def start_websocket(
  562. self,
  563. on_message: Callable[[FeishuMessageEvent], None],
  564. on_bot_added: Optional[Callable[[str], None]] = None,
  565. on_bot_removed: Optional[Callable[[str], None]] = None,
  566. blocking: bool = True,
  567. ):
  568. """
  569. 启动 WebSocket 监听消息
  570. Args:
  571. on_message: 消息回调函数
  572. on_bot_added: 机器人被添加到群的回调 (可选)
  573. on_bot_removed: 机器人被移出群的回调 (可选)
  574. blocking: 是否阻塞当前线程
  575. """
  576. # 创建事件处理器
  577. # 注意: lark-oapi SDK 的回调函数只接受一个参数 (data)
  578. event_handler = lark.EventDispatcherHandler.builder(
  579. self.encrypt_key or "",
  580. self.verification_token or ""
  581. ).register_p2_im_message_receive_v1(
  582. lambda data: self._handle_message_event(data, on_message)
  583. )
  584. if on_bot_added:
  585. event_handler = event_handler.register_p2_im_chat_member_bot_added_v1(
  586. lambda data: on_bot_added(data.event.chat_id)
  587. )
  588. if on_bot_removed:
  589. event_handler = event_handler.register_p2_im_chat_member_bot_deleted_v1(
  590. lambda data: on_bot_removed(data.event.chat_id)
  591. )
  592. handler = event_handler.build()
  593. # 创建 WebSocket 客户端
  594. ws_client = lark.ws.Client(
  595. self.app_id,
  596. self.app_secret,
  597. event_handler=handler,
  598. domain=lark.FEISHU_DOMAIN if self.domain == FeishuDomain.FEISHU else lark.LARK_DOMAIN,
  599. log_level=lark.LogLevel.INFO,
  600. )
  601. logger.info("启动飞书 WebSocket 监听...")
  602. if blocking:
  603. ws_client.start()
  604. else:
  605. thread = threading.Thread(target=ws_client.start, daemon=True)
  606. thread.start()
  607. return thread
  608. def _handle_message_event(
  609. self,
  610. data,
  611. callback: Callable[[FeishuMessageEvent], None]
  612. ):
  613. """处理消息事件"""
  614. try:
  615. # data 结构: P2ImMessageReceiveV1 对象
  616. # data.event 包含实际的事件数据
  617. event = data.event
  618. msg = event.message
  619. sender = event.sender
  620. # 解析消息内容
  621. content = self._parse_message_content(msg.content, msg.message_type)
  622. # 检查是否 @了机器人
  623. mentioned_bot = self._check_bot_mentioned(msg.mentions)
  624. # 去除 @机器人 的文本
  625. if msg.mentions:
  626. content = self._strip_bot_mention(content, msg.mentions)
  627. # 构建事件对象
  628. message_event = FeishuMessageEvent(
  629. message_id=msg.message_id,
  630. chat_id=msg.chat_id,
  631. chat_type=ChatType(msg.chat_type),
  632. content=content,
  633. content_type=msg.message_type,
  634. sender_open_id=sender.sender_id.open_id if sender.sender_id else "",
  635. sender_user_id=sender.sender_id.user_id if sender.sender_id else None,
  636. root_id=msg.root_id,
  637. parent_id=msg.parent_id,
  638. mentions=[], # 简化处理
  639. mentioned_bot=mentioned_bot,
  640. )
  641. # 尝试获取发送者名称 (可能会失败,不影响主流程)
  642. try:
  643. if message_event.sender_open_id:
  644. user_info = self.get_user_info(message_event.sender_open_id)
  645. if user_info:
  646. message_event.sender_name = user_info.get("name")
  647. except Exception as e:
  648. logger.debug(f"获取用户信息失败: {e}")
  649. callback(message_event)
  650. except Exception as e:
  651. logger.error(f"处理消息事件失败: {e}", exc_info=True)
  652. # ==================== 辅助方法 ====================
  653. def _resolve_receive_id_type(self, receive_id: str) -> ReceiveIdType:
  654. """推断接收者ID类型"""
  655. if receive_id.startswith("ou_"):
  656. return ReceiveIdType.OPEN_ID
  657. elif receive_id.startswith("on_"):
  658. return ReceiveIdType.UNION_ID
  659. elif receive_id.startswith("oc_"):
  660. return ReceiveIdType.CHAT_ID
  661. elif "@" in receive_id:
  662. return ReceiveIdType.EMAIL
  663. else:
  664. return ReceiveIdType.USER_ID
  665. def _parse_message_content(self, content: str, message_type: str) -> str:
  666. """解析消息内容"""
  667. try:
  668. parsed = json.loads(content)
  669. if message_type == "text":
  670. return parsed.get("text", "")
  671. elif message_type == "post":
  672. return self._parse_post_content(parsed)
  673. return content
  674. except:
  675. return content
  676. def _parse_post_content(self, parsed: Dict) -> str:
  677. """解析富文本消息"""
  678. title = parsed.get("title", "")
  679. content_blocks = parsed.get("content", [])
  680. text_parts = [title] if title else []
  681. for paragraph in content_blocks:
  682. if isinstance(paragraph, list):
  683. for element in paragraph:
  684. if element.get("tag") == "text":
  685. text_parts.append(element.get("text", ""))
  686. elif element.get("tag") == "a":
  687. text_parts.append(element.get("text", element.get("href", "")))
  688. elif element.get("tag") == "at":
  689. text_parts.append(f"@{element.get('user_name', '')}")
  690. return "\n".join(text_parts).strip() or "[富文本消息]"
  691. def _check_bot_mentioned(self, mentions: Optional[List]) -> bool:
  692. """检查是否 @了机器人"""
  693. if not mentions:
  694. return False
  695. if not self._bot_open_id:
  696. # 如果没有缓存机器人 open_id,假设有 mention 就是 @了机器人
  697. return len(mentions) > 0
  698. return any(m.id.open_id == self._bot_open_id for m in mentions)
  699. def _strip_bot_mention(self, text: str, mentions: List) -> str:
  700. """去除 @机器人 的文本"""
  701. result = text
  702. for mention in mentions:
  703. name = mention.name if hasattr(mention, 'name') else ""
  704. key = mention.key if hasattr(mention, 'key') else ""
  705. if name:
  706. result = result.replace(f"@{name}", "").strip()
  707. if key:
  708. result = result.replace(key, "").strip()
  709. return result
  710. def _detect_file_type(self, file_name: str) -> str:
  711. """检测文件类型"""
  712. ext = os.path.splitext(file_name)[1].lower()
  713. type_map = {
  714. ".opus": "opus", ".ogg": "opus",
  715. ".mp4": "mp4", ".mov": "mp4", ".avi": "mp4",
  716. ".pdf": "pdf",
  717. ".doc": "doc", ".docx": "doc",
  718. ".xls": "xls", ".xlsx": "xls",
  719. ".ppt": "ppt", ".pptx": "ppt",
  720. }
  721. return type_map.get(ext, "stream")
  722. # ==================== 使用示例 ====================
  723. if __name__ == "__main__":
  724. # 从环境变量获取配置
  725. APP_ID = os.getenv("FEISHU_APP_ID", "cli_a90fe317987a9cc9")
  726. APP_SECRET = os.getenv("FEISHU_APP_SECRET", "nn2dWuXTiRA2N6xodbm4g0qz1AfM2ayi")
  727. if not APP_ID or not APP_SECRET:
  728. print("请设置环境变量 FEISHU_APP_ID 和 FEISHU_APP_SECRET")
  729. exit(1)
  730. # 创建客户端
  731. client = FeishuClient(
  732. app_id=APP_ID,
  733. app_secret=APP_SECRET,
  734. domain=FeishuDomain.FEISHU,
  735. )
  736. # 消息处理回调
  737. def handle_message(event: FeishuMessageEvent):
  738. print(f"\n收到消息:")
  739. print(f" 发送者: {event.sender_name or event.sender_open_id}")
  740. print(f" 类型: {event.chat_type.value}")
  741. print(f" 内容: {event.content}")
  742. print(f" @机器人: {event.mentioned_bot}")
  743. # 自动回复示例
  744. if event.chat_type == ChatType.P2P or event.mentioned_bot:
  745. # 先回复文字
  746. reply_text = f"收到你的消息: {event.content}"
  747. chat_id = event.chat_id
  748. content = event.content
  749. content_type = event.content_type # image、text等
  750. open_id = event.sender_open_id
  751. client.send_message(
  752. to=event.chat_id,
  753. text=reply_text,
  754. reply_to_message_id=event.message_id
  755. )
  756. print(f" 已回复文字: {reply_text}")
  757. # 再回复一张图片 (读取当前目录下的 hanli.png)
  758. try:
  759. image_path = os.path.join(os.path.dirname(__file__) or ".", "hanli.png")
  760. if os.path.exists(image_path):
  761. client.send_image(
  762. to=event.chat_id,
  763. image=image_path,
  764. )
  765. print(f" 已回复图片: {image_path}")
  766. else:
  767. print(f" 图片不存在: {image_path}")
  768. except Exception as e:
  769. print(f" 回复图片失败: {e}")
  770. # 启动 WebSocket 监听
  771. print("启动飞书消息监听...")
  772. print("按 Ctrl+C 退出")
  773. try:
  774. client.start_websocket(
  775. on_message=handle_message,
  776. on_bot_added=lambda chat_id: print(f"机器人被添加到群: {chat_id}"),
  777. on_bot_removed=lambda chat_id: print(f"机器人被移出群: {chat_id}"),
  778. blocking=True
  779. )
  780. # res = client.get_message_list(chat_id='oc_56e85f0e2c97405d176729b62d8f56e5', start_time=0, end_time=1770623620)
  781. # print(f"获取消息列表结果: {json.dumps(res, indent=4, ensure_ascii=False)}")
  782. except KeyboardInterrupt:
  783. print("\n退出")