You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

251 satır
9.4KB

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