Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

255 linhas
9.5KB

  1. """
  2. 飞书通道接入
  3. @author Saboteur7
  4. @Date 2023/11/19
  5. """
  6. # -*- coding=utf-8 -*-
  7. import uuid
  8. import requests
  9. import web
  10. from channel.feishu.feishu_message import FeishuMessage
  11. from bridge.context import Context
  12. from bridge.reply import Reply, ReplyType
  13. from common.log import logger
  14. from common.singleton import singleton
  15. from config import conf
  16. from common.expired_dict import ExpiredDict
  17. from bridge.context import ContextType
  18. from channel.chat_channel import ChatChannel, check_prefix
  19. from common import utils
  20. import json
  21. import os
  22. URL_VERIFICATION = "url_verification"
  23. @singleton
  24. class FeiShuChanel(ChatChannel):
  25. feishu_app_id = conf().get('feishu_app_id')
  26. feishu_app_secret = conf().get('feishu_app_secret')
  27. feishu_token = conf().get('feishu_token')
  28. def __init__(self):
  29. super().__init__()
  30. # 历史消息id暂存,用于幂等控制
  31. self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
  32. logger.info("[FeiShu] app_id={}, app_secret={} verification_token={}".format(
  33. self.feishu_app_id, self.feishu_app_secret, self.feishu_token))
  34. # 无需群校验和前缀
  35. conf()["group_name_white_list"] = ["ALL_GROUP"]
  36. conf()["single_chat_prefix"] = []
  37. def startup(self):
  38. urls = (
  39. '/', 'channel.feishu.feishu_channel.FeishuController'
  40. )
  41. app = web.application(urls, globals(), autoreload=False)
  42. port = conf().get("feishu_port", 9891)
  43. web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
  44. def send(self, reply: Reply, context: Context):
  45. msg = context.get("msg")
  46. is_group = context["isgroup"]
  47. if msg:
  48. access_token = msg.access_token
  49. else:
  50. access_token = self.fetch_access_token()
  51. headers = {
  52. "Authorization": "Bearer " + access_token,
  53. "Content-Type": "application/json",
  54. }
  55. msg_type = "text"
  56. logger.info(f"[FeiShu] start send reply message, type={context.type}, content={reply.content}")
  57. reply_content = reply.content
  58. content_key = "text"
  59. if reply.type == ReplyType.IMAGE_URL:
  60. # 图片上传
  61. reply_content = self._upload_image_url(reply.content, access_token)
  62. if not reply_content:
  63. logger.warning("[FeiShu] upload file failed")
  64. return
  65. msg_type = "image"
  66. content_key = "image_key"
  67. if is_group:
  68. # 群聊中直接回复
  69. url = f"https://open.feishu.cn/open-apis/im/v1/messages/{msg.msg_id}/reply"
  70. data = {
  71. "msg_type": msg_type,
  72. "content": json.dumps({content_key: reply_content})
  73. }
  74. res = requests.post(url=url, headers=headers, json=data, timeout=(5, 10))
  75. else:
  76. url = "https://open.feishu.cn/open-apis/im/v1/messages"
  77. params = {"receive_id_type": context.get("receive_id_type") or "open_id"}
  78. data = {
  79. "receive_id": context.get("receiver"),
  80. "msg_type": msg_type,
  81. "content": json.dumps({content_key: reply_content})
  82. }
  83. res = requests.post(url=url, headers=headers, params=params, json=data, timeout=(5, 10))
  84. res = res.json()
  85. if res.get("code") == 0:
  86. logger.info(f"[FeiShu] send message success")
  87. else:
  88. logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
  89. def fetch_access_token(self) -> str:
  90. url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
  91. headers = {
  92. "Content-Type": "application/json"
  93. }
  94. req_body = {
  95. "app_id": self.feishu_app_id,
  96. "app_secret": self.feishu_app_secret
  97. }
  98. data = bytes(json.dumps(req_body), encoding='utf8')
  99. response = requests.post(url=url, data=data, headers=headers)
  100. if response.status_code == 200:
  101. res = response.json()
  102. if res.get("code") != 0:
  103. logger.error(f"[FeiShu] get tenant_access_token error, code={res.get('code')}, msg={res.get('msg')}")
  104. return ""
  105. else:
  106. return res.get("tenant_access_token")
  107. else:
  108. logger.error(f"[FeiShu] fetch token error, res={response}")
  109. def _upload_image_url(self, img_url, access_token):
  110. logger.debug(f"[WX] start download image, img_url={img_url}")
  111. response = requests.get(img_url)
  112. suffix = utils.get_path_suffix(img_url)
  113. temp_name = str(uuid.uuid4()) + "." + suffix
  114. if response.status_code == 200:
  115. # 将图片内容保存为临时文件
  116. with open(temp_name, "wb") as file:
  117. file.write(response.content)
  118. # upload
  119. upload_url = "https://open.feishu.cn/open-apis/im/v1/images"
  120. data = {
  121. 'image_type': 'message'
  122. }
  123. headers = {
  124. 'Authorization': f'Bearer {access_token}',
  125. }
  126. with open(temp_name, "rb") as file:
  127. upload_response = requests.post(upload_url, files={"image": file}, data=data, headers=headers)
  128. logger.info(f"[FeiShu] upload file, res={upload_response.content}")
  129. os.remove(temp_name)
  130. return upload_response.json().get("data").get("image_key")
  131. class FeishuController:
  132. # 类常量
  133. FAILED_MSG = '{"success": false}'
  134. SUCCESS_MSG = '{"success": true}'
  135. MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
  136. def GET(self):
  137. return "Feishu service start success!"
  138. def POST(self):
  139. try:
  140. channel = FeiShuChanel()
  141. request = json.loads(web.data().decode("utf-8"))
  142. logger.debug(f"[FeiShu] receive request: {request}")
  143. # 1.事件订阅回调验证
  144. if request.get("type") == URL_VERIFICATION:
  145. varify_res = {"challenge": request.get("challenge")}
  146. return json.dumps(varify_res)
  147. # 2.消息接收处理
  148. # token 校验
  149. header = request.get("header")
  150. if not header or header.get("token") != channel.feishu_token:
  151. return self.FAILED_MSG
  152. # 处理消息事件
  153. event = request.get("event")
  154. if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
  155. if not event.get("message") or not event.get("sender"):
  156. logger.warning(f"[FeiShu] invalid message, msg={request}")
  157. return self.FAILED_MSG
  158. msg = event.get("message")
  159. # 幂等判断
  160. if channel.receivedMsgs.get(msg.get("message_id")):
  161. logger.warning(f"[FeiShu] repeat msg filtered, event_id={header.get('event_id')}")
  162. return self.SUCCESS_MSG
  163. channel.receivedMsgs[msg.get("message_id")] = True
  164. is_group = False
  165. chat_type = msg.get("chat_type")
  166. if chat_type == "group":
  167. if not msg.get("mentions") and msg.get("message_type") == "text":
  168. # 群聊中未@不响应
  169. return self.SUCCESS_MSG
  170. if msg.get("mentions")[0].get("name") != conf().get("feishu_bot_name") and msg.get("message_type") == "text":
  171. # 不是@机器人,不响应
  172. return self.SUCCESS_MSG
  173. # 群聊
  174. is_group = True
  175. receive_id_type = "chat_id"
  176. elif chat_type == "p2p":
  177. receive_id_type = "open_id"
  178. else:
  179. logger.warning("[FeiShu] message ignore")
  180. return self.SUCCESS_MSG
  181. # 构造飞书消息对象
  182. feishu_msg = FeishuMessage(event, is_group=is_group, access_token=channel.fetch_access_token())
  183. if not feishu_msg:
  184. return self.SUCCESS_MSG
  185. context = self._compose_context(
  186. feishu_msg.ctype,
  187. feishu_msg.content,
  188. isgroup=is_group,
  189. msg=feishu_msg,
  190. receive_id_type=receive_id_type,
  191. no_need_at=True
  192. )
  193. if context:
  194. channel.produce(context)
  195. logger.info(f"[FeiShu] query={feishu_msg.content}, type={feishu_msg.ctype}")
  196. return self.SUCCESS_MSG
  197. except Exception as e:
  198. logger.error(e)
  199. return self.FAILED_MSG
  200. def _compose_context(self, ctype: ContextType, content, **kwargs):
  201. context = Context(ctype, content)
  202. context.kwargs = kwargs
  203. if "origin_ctype" not in context:
  204. context["origin_ctype"] = ctype
  205. cmsg = context["msg"]
  206. context["session_id"] = cmsg.from_user_id
  207. context["receiver"] = cmsg.other_user_id
  208. if ctype == ContextType.TEXT:
  209. # 1.文本请求
  210. # 图片生成处理
  211. img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
  212. if img_match_prefix:
  213. content = content.replace(img_match_prefix, "", 1)
  214. context.type = ContextType.IMAGE_CREATE
  215. else:
  216. context.type = ContextType.TEXT
  217. context.content = content.strip()
  218. elif context.type == ContextType.VOICE:
  219. # 2.语音请求
  220. if "desire_rtype" not in context and conf().get("voice_reply_voice"):
  221. context["desire_rtype"] = ReplyType.VOICE
  222. return context