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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """
  2. 飞书通道接入
  3. @author Saboteur7
  4. @Date 2023/11/19
  5. """
  6. # -*- coding=utf-8 -*-
  7. import io
  8. import os
  9. import time
  10. import uuid
  11. import requests
  12. import web
  13. from channel.feishu.feishu_message import FeishuMessage
  14. from bridge.context import Context
  15. from bridge.reply import Reply, ReplyType
  16. from common.log import logger
  17. from common.singleton import singleton
  18. from config import conf
  19. from common.expired_dict import ExpiredDict
  20. from bridge.context import ContextType
  21. from channel.chat_channel import ChatChannel, check_prefix
  22. from utils import file_util
  23. import json
  24. import os
  25. URL_VERIFICATION = "url_verification"
  26. @singleton
  27. class FeiShuChanel(ChatChannel):
  28. feishu_app_id = conf().get('feishu_app_id')
  29. feishu_app_secret = conf().get('feishu_app_secret')
  30. feishu_token = conf().get('feishu_token')
  31. def __init__(self):
  32. super().__init__()
  33. # 历史消息id暂存,用于幂等控制
  34. self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
  35. logger.info("[FeiShu] app_id={}, app_secret={} verification_token={}".format(
  36. self.feishu_app_id, self.feishu_app_secret, self.feishu_token))
  37. # 无需群校验和前缀
  38. conf()["group_name_white_list"] = ["ALL_GROUP"]
  39. conf()["single_chat_prefix"] = []
  40. def startup(self):
  41. urls = (
  42. '/', 'channel.feishu.feishu_channel.FeishuController'
  43. )
  44. app = web.application(urls, globals(), autoreload=False)
  45. port = conf().get("feishu_port", 9891)
  46. web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
  47. def send(self, reply: Reply, context: Context):
  48. msg = context["msg"]
  49. is_group = context["isgroup"]
  50. headers = {
  51. "Authorization": "Bearer " + msg.access_token,
  52. "Content-Type": "application/json",
  53. }
  54. msg_type = "text"
  55. logger.info(f"[FeiShu] start send reply message, type={context.type}, content={reply.content}")
  56. reply_content = reply.content
  57. content_key = "text"
  58. if reply.type == ReplyType.IMAGE_URL:
  59. # 图片上传
  60. reply_content = self._upload_image_url(reply.content, msg.access_token)
  61. if not reply_content:
  62. logger.warning("[FeiShu] upload file failed")
  63. return
  64. msg_type = "image"
  65. content_key = "image_key"
  66. if is_group:
  67. # 群聊中直接回复
  68. url = f"https://open.feishu.cn/open-apis/im/v1/messages/{msg.msg_id}/reply"
  69. data = {
  70. "msg_type": msg_type,
  71. "content": json.dumps({content_key: reply_content})
  72. }
  73. res = requests.post(url=url, headers=headers, json=data, timeout=(5, 10))
  74. else:
  75. url = "https://open.feishu.cn/open-apis/im/v1/messages"
  76. params = {"receive_id_type": context.get("receive_id_type")}
  77. data = {
  78. "receive_id": context.get("receiver"),
  79. "msg_type": msg_type,
  80. "content": json.dumps({content_key: reply_content})
  81. }
  82. res = requests.post(url=url, headers=headers, params=params, json=data, timeout=(5, 10))
  83. res = res.json()
  84. if res.get("code") == 0:
  85. logger.info(f"[FeiShu] send message success")
  86. else:
  87. logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
  88. def fetch_access_token(self) -> str:
  89. url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
  90. headers = {
  91. "Content-Type": "application/json"
  92. }
  93. req_body = {
  94. "app_id": self.feishu_app_id,
  95. "app_secret": self.feishu_app_secret
  96. }
  97. data = bytes(json.dumps(req_body), encoding='utf8')
  98. response = requests.post(url=url, data=data, headers=headers)
  99. if response.status_code == 200:
  100. res = response.json()
  101. if res.get("code") != 0:
  102. logger.error(f"[FeiShu] get tenant_access_token error, code={res.get('code')}, msg={res.get('msg')}")
  103. return ""
  104. else:
  105. return res.get("tenant_access_token")
  106. else:
  107. logger.error(f"[FeiShu] fetch token error, res={response}")
  108. def _upload_image_url(self, img_url, access_token):
  109. logger.debug(f"[WX] start download image, img_url={img_url}")
  110. response = requests.get(img_url)
  111. suffix = file_util.get_path_suffix(img_url)
  112. temp_name = str(uuid.uuid4()) + "." + suffix
  113. if response.status_code == 200:
  114. # 将图片内容保存为临时文件
  115. with open(temp_name, "wb") as file:
  116. file.write(response.content)
  117. # upload
  118. upload_url = "https://open.feishu.cn/open-apis/im/v1/images"
  119. data = {
  120. 'image_type': 'message'
  121. }
  122. headers = {
  123. 'Authorization': f'Bearer {access_token}',
  124. }
  125. with open(temp_name, "rb") as file:
  126. upload_response = requests.post(upload_url, files={"image": file}, data=data, headers=headers)
  127. logger.info(f"[FeiShu] upload file, res={upload_response.content}")
  128. os.remove(temp_name)
  129. return upload_response.json().get("data").get("image_key")
  130. class FeishuController:
  131. # 类常量
  132. FAILED_MSG = '{"success": false}'
  133. SUCCESS_MSG = '{"success": true}'
  134. MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
  135. def GET(self):
  136. return "Feishu service start success!"
  137. def POST(self):
  138. try:
  139. channel = FeiShuChanel()
  140. request = json.loads(web.data().decode("utf-8"))
  141. logger.debug(f"[FeiShu] receive request: {request}")
  142. # 1.事件订阅回调验证
  143. if request.get("type") == URL_VERIFICATION:
  144. varify_res = {"challenge": request.get("challenge")}
  145. return json.dumps(varify_res)
  146. # 2.消息接收处理
  147. # token 校验
  148. header = request.get("header")
  149. if not header or header.get("token") != channel.feishu_token:
  150. return self.FAILED_MSG
  151. # 处理消息事件
  152. event = request.get("event")
  153. if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
  154. if not event.get("message") or not event.get("sender"):
  155. logger.warning(f"[FeiShu] invalid message, msg={request}")
  156. return self.FAILED_MSG
  157. msg = event.get("message")
  158. # 幂等判断
  159. if channel.receivedMsgs.get(msg.get("message_id")):
  160. logger.warning(f"[FeiShu] repeat msg filtered, event_id={header.get('event_id')}")
  161. return self.SUCCESS_MSG
  162. channel.receivedMsgs[msg.get("message_id")] = True
  163. is_group = False
  164. chat_type = msg.get("chat_type")
  165. if chat_type == "group":
  166. if not msg.get("mentions"):
  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