Procházet zdrojové kódy

新增飞书应用通道

 - 支持自建机器人的私聊和群聊
 - 支持图片生成
 - 支持文件总结
Saboteur7 před 2 roky
rodič
revize
86a58c3d80

+ 3 - 3
app.py

@@ -5,8 +5,8 @@ import signal
 import sys
 
 from channel import channel_factory
-from common.log import logger
-from config import conf, load_config
+from common import const
+from config import load_config
 from plugins import *
 
 
@@ -43,7 +43,7 @@ def run():
             # os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001'
 
         channel = channel_factory.create_channel(channel_name)
-        if channel_name in ["wx", "wxy", "terminal", "wechatmp", "wechatmp_service", "wechatcom_app", "wework"]:
+        if channel_name in ["wx", "wxy", "terminal", "wechatmp", "wechatmp_service", "wechatcom_app", "wework", const.FEISHU]:
             PluginManager().load_plugins()
 
         # startup channel

+ 6 - 2
channel/channel_factory.py

@@ -1,7 +1,7 @@
 """
 channel factory
 """
-
+from common import const
 
 def create_channel(channel_type):
     """
@@ -35,6 +35,10 @@ def create_channel(channel_type):
         return WechatComAppChannel()
     elif channel_type == "wework":
         from channel.wework.wework_channel import WeworkChannel
-
         return WeworkChannel()
+
+    elif channel_type == const.FEISHU:
+        from channel.feishu.feishu_channel import FeiShuChanel
+        return FeiShuChanel()
+
     raise RuntimeError

+ 2 - 1
channel/chat_channel.py

@@ -238,7 +238,8 @@ class ChatChannel(Channel):
                         reply = super().build_text_to_voice(reply.content)
                         return self._decorate_reply(context, reply)
                     if context.get("isgroup", False):
-                        reply_text = "@" + context["msg"].actual_user_nickname + "\n" + reply_text.strip()
+                        if not context.get("no_need_at", False):
+                            reply_text = "@" + context["msg"].actual_user_nickname + "\n" + reply_text.strip()
                         reply_text = conf().get("group_chat_reply_prefix", "") + reply_text + conf().get("group_chat_reply_suffix", "")
                     else:
                         reply_text = conf().get("single_chat_reply_prefix", "") + reply_text + conf().get("single_chat_reply_suffix", "")

+ 250 - 0
channel/feishu/feishu_channel.py

@@ -0,0 +1,250 @@
+"""
+飞书通道接入
+
+@author Saboteur7
+@Date 2023/11/19
+"""
+
+# -*- coding=utf-8 -*-
+import io
+import os
+import time
+import uuid
+
+import requests
+import web
+from channel.feishu.feishu_message import FeishuMessage
+from bridge.context import Context
+from bridge.reply import Reply, ReplyType
+from common.log import logger
+from common.singleton import singleton
+from config import conf
+from common.expired_dict import ExpiredDict
+from bridge.context import ContextType
+from channel.chat_channel import ChatChannel, check_prefix
+from utils import file_util
+import json
+import os
+
+URL_VERIFICATION = "url_verification"
+
+
+@singleton
+class FeiShuChanel(ChatChannel):
+    feishu_app_id = conf().get('feishu_app_id')
+    feishu_app_secret = conf().get('feishu_app_secret')
+    feishu_token = conf().get('feishu_token')
+
+    def __init__(self):
+        super().__init__()
+        # 历史消息id暂存,用于幂等控制
+        self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
+        logger.info("[FeiShu] app_id={}, app_secret={} verification_token={}".format(
+            self.feishu_app_id, self.feishu_app_secret, self.feishu_token))
+        # 无需群校验和前缀
+        conf()["group_name_white_list"] = ["ALL_GROUP"]
+        conf()["single_chat_prefix"] = []
+
+    def startup(self):
+        urls = (
+            '/', 'channel.feishu.feishu_channel.FeishuController'
+        )
+        app = web.application(urls, globals(), autoreload=False)
+        port = conf().get("feishu_port", 9891)
+        web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
+
+    def send(self, reply: Reply, context: Context):
+        msg = context["msg"]
+        is_group = context["isgroup"]
+        headers = {
+            "Authorization": "Bearer " + msg.access_token,
+            "Content-Type": "application/json",
+        }
+        msg_type = "text"
+        logger.info(f"[FeiShu] start send reply message, type={context.type}, content={reply.content}")
+        reply_content = reply.content
+        content_key = "text"
+        if reply.type == ReplyType.IMAGE_URL:
+            # 图片上传
+            reply_content = self._upload_image_url(reply.content, msg.access_token)
+            if not reply_content:
+                logger.warning("[FeiShu] upload file failed")
+                return
+            msg_type = "image"
+            content_key = "image_key"
+        if is_group:
+            # 群聊中直接回复
+            url = f"https://open.feishu.cn/open-apis/im/v1/messages/{msg.msg_id}/reply"
+            data = {
+                "msg_type": msg_type,
+                "content": json.dumps({content_key: reply_content})
+            }
+            res = requests.post(url=url, headers=headers, json=data, timeout=(5, 10))
+        else:
+            url = "https://open.feishu.cn/open-apis/im/v1/messages"
+            params = {"receive_id_type": context.get("receive_id_type")}
+            data = {
+                "receive_id": context.get("receiver"),
+                "msg_type": msg_type,
+                "content": json.dumps({content_key: reply_content})
+            }
+            res = requests.post(url=url, headers=headers, params=params, json=data, timeout=(5, 10))
+        res = res.json()
+        if res.get("code") == 0:
+            logger.info(f"[FeiShu] send message success")
+        else:
+            logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
+
+
+    def fetch_access_token(self) -> str:
+        url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
+        headers = {
+            "Content-Type": "application/json"
+        }
+        req_body = {
+            "app_id": self.feishu_app_id,
+            "app_secret": self.feishu_app_secret
+        }
+        data = bytes(json.dumps(req_body), encoding='utf8')
+        response = requests.post(url=url, data=data, headers=headers)
+        if response.status_code == 200:
+            res = response.json()
+            if res.get("code") != 0:
+                logger.error(f"[FeiShu] get tenant_access_token error, code={res.get('code')}, msg={res.get('msg')}")
+                return ""
+            else:
+                return res.get("tenant_access_token")
+        else:
+            logger.error(f"[FeiShu] fetch token error, res={response}")
+
+
+    def _upload_image_url(self, img_url, access_token):
+        logger.debug(f"[WX] start download image, img_url={img_url}")
+        response = requests.get(img_url)
+        suffix = file_util.get_path_suffix(img_url)
+        temp_name = str(uuid.uuid4()) + "." + suffix
+        if response.status_code == 200:
+            # 将图片内容保存为临时文件
+            with open(temp_name, "wb") as file:
+                file.write(response.content)
+
+        # upload
+        upload_url = "https://open.feishu.cn/open-apis/im/v1/images"
+        data = {
+            'image_type': 'message'
+        }
+        headers = {
+            'Authorization': f'Bearer {access_token}',
+        }
+        with open(temp_name, "rb") as file:
+            upload_response = requests.post(upload_url, files={"image": file}, data=data, headers=headers)
+            logger.info(f"[FeiShu] upload file, res={upload_response.content}")
+            os.remove(temp_name)
+            return upload_response.json().get("data").get("image_key")
+
+
+
+class FeishuController:
+    # 类常量
+    FAILED_MSG = '{"success": false}'
+    SUCCESS_MSG = '{"success": true}'
+    MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
+
+    def GET(self):
+        return "Feishu service start success!"
+
+    def POST(self):
+        try:
+            channel = FeiShuChanel()
+
+            request = json.loads(web.data().decode("utf-8"))
+            logger.debug(f"[FeiShu] receive request: {request}")
+
+            # 1.事件订阅回调验证
+            if request.get("type") == URL_VERIFICATION:
+                varify_res = {"challenge": request.get("challenge")}
+                return json.dumps(varify_res)
+
+            # 2.消息接收处理
+            # token 校验
+            header = request.get("header")
+            if not header or header.get("token") != channel.feishu_token:
+                return self.FAILED_MSG
+
+            # 处理消息事件
+            event = request.get("event")
+            if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
+                if not event.get("message") or not event.get("sender"):
+                    logger.warning(f"[FeiShu] invalid message, msg={request}")
+                    return self.FAILED_MSG
+                msg = event.get("message")
+
+                # 幂等判断
+                if channel.receivedMsgs.get(msg.get("message_id")):
+                    logger.warning(f"[FeiShu] repeat msg filtered, event_id={header.get('event_id')}")
+                    return self.SUCCESS_MSG
+                channel.receivedMsgs[msg.get("message_id")] = True
+
+                is_group = False
+                chat_type = msg.get("chat_type")
+                if chat_type == "group":
+                    if not msg.get("mentions"):
+                        # 群聊中未@不响应
+                        return self.SUCCESS_MSG
+                    # 群聊
+                    is_group = True
+                    receive_id_type = "chat_id"
+                elif chat_type == "p2p":
+                    receive_id_type = "open_id"
+                else:
+                    logger.warning("[FeiShu] message ignore")
+                    return self.SUCCESS_MSG
+                # 构造飞书消息对象
+                feishu_msg = FeishuMessage(event, is_group=is_group, access_token=channel.fetch_access_token())
+                if not feishu_msg:
+                    return self.SUCCESS_MSG
+
+                context = self._compose_context(
+                    feishu_msg.ctype,
+                    feishu_msg.content,
+                    isgroup=is_group,
+                    msg=feishu_msg,
+                    receive_id_type=receive_id_type,
+                    no_need_at=True
+                )
+                if context:
+                    channel.produce(context)
+                logger.info(f"[FeiShu] query={feishu_msg.content}, type={feishu_msg.ctype}")
+            return self.SUCCESS_MSG
+
+        except Exception as e:
+            logger.error(e)
+            return self.FAILED_MSG
+
+    def _compose_context(self, ctype: ContextType, content, **kwargs):
+        context = Context(ctype, content)
+        context.kwargs = kwargs
+        if "origin_ctype" not in context:
+            context["origin_ctype"] = ctype
+
+        cmsg = context["msg"]
+        context["session_id"] = cmsg.from_user_id
+        context["receiver"] = cmsg.other_user_id
+
+        if ctype == ContextType.TEXT:
+            # 1.文本请求
+            # 图片生成处理
+            img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
+            if img_match_prefix:
+                content = content.replace(img_match_prefix, "", 1)
+                context.type = ContextType.IMAGE_CREATE
+            else:
+                context.type = ContextType.TEXT
+            context.content = content.strip()
+
+        elif context.type == ContextType.VOICE:
+            # 2.语音请求
+            if "desire_rtype" not in context and conf().get("voice_reply_voice"):
+                context["desire_rtype"] = ReplyType.VOICE
+
+        return context

+ 92 - 0
channel/feishu/feishu_message.py

@@ -0,0 +1,92 @@
+from bridge.context import ContextType
+from channel.chat_message import ChatMessage
+import json
+import requests
+from common.log import logger
+from common.tmp_dir import TmpDir
+from utils import file_util
+
+
+class FeishuMessage(ChatMessage):
+    def __init__(self, event: dict, is_group=False, access_token=None):
+        super().__init__(event)
+        msg = event.get("message")
+        sender = event.get("sender")
+        self.access_token = access_token
+        self.msg_id = msg.get("message_id")
+        self.create_time = msg.get("create_time")
+        self.is_group = is_group
+        msg_type = msg.get("message_type")
+
+        if msg_type == "text":
+            self.ctype = ContextType.TEXT
+            content = json.loads(msg.get('content'))
+            self.content = content.get("text").strip()
+        elif msg_type == "file":
+            self.ctype = ContextType.FILE
+            content = json.loads(msg.get("content"))
+            file_key = content.get("file_key")
+            file_name = content.get("file_name")
+
+            self.content = TmpDir().path() + file_key + "." + file_util.get_path_suffix(file_name)
+
+            def _download_file():
+                # 如果响应状态码是200,则将响应内容写入本地文件
+                url = f"https://open.feishu.cn/open-apis/im/v1/messages/{self.msg_id}/resources/{file_key}"
+                headers = {
+                    "Authorization": "Bearer " + access_token,
+                }
+                params = {
+                    "type": "file"
+                }
+                response = requests.get(url=url, headers=headers, params=params)
+                if response.status_code == 200:
+                    with open(self.content, "wb") as f:
+                        f.write(response.content)
+                else:
+                    logger.info(f"[FeiShu] Failed to download file, key={file_key}, res={response.text}")
+            self._prepare_fn = _download_file
+
+        # elif msg.type == "voice":
+        #     self.ctype = ContextType.VOICE
+        #     self.content = TmpDir().path() + msg.media_id + "." + msg.format  # content直接存临时目录路径
+        #
+        #     def download_voice():
+        #         # 如果响应状态码是200,则将响应内容写入本地文件
+        #         response = client.media.download(msg.media_id)
+        #         if response.status_code == 200:
+        #             with open(self.content, "wb") as f:
+        #                 f.write(response.content)
+        #         else:
+        #             logger.info(f"[wechatcom] Failed to download voice file, {response.content}")
+        #
+        #     self._prepare_fn = download_voice
+        # elif msg.type == "image":
+        #     self.ctype = ContextType.IMAGE
+        #     self.content = TmpDir().path() + msg.media_id + ".png"  # content直接存临时目录路径
+        #
+        #     def download_image():
+        #         # 如果响应状态码是200,则将响应内容写入本地文件
+        #         response = client.media.download(msg.media_id)
+        #         if response.status_code == 200:
+        #             with open(self.content, "wb") as f:
+        #                 f.write(response.content)
+        #         else:
+        #             logger.info(f"[wechatcom] Failed to download image file, {response.content}")
+        #
+        #     self._prepare_fn = download_image
+        else:
+            raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
+
+        self.from_user_id = sender.get("sender_id").get("open_id")
+        self.to_user_id = event.get("app_id")
+        if is_group:
+            # 群聊
+            self.other_user_id = msg.get("chat_id")
+            self.actual_user_id = self.from_user_id
+            self.content = self.content.replace("@_user_1", "").strip()
+            self.actual_user_nickname = ""
+        else:
+            # 私聊
+            self.other_user_id = self.from_user_id
+            self.actual_user_id = self.from_user_id

+ 3 - 0
common/const.py

@@ -17,3 +17,6 @@ TTS_1 = "tts-1"
 TTS_1_HD = "tts-1-hd"
 
 MODEL_LIST = ["gpt-3.5-turbo", "gpt-3.5-turbo-16k", "gpt-4", "wenxin", "wenxin-4", "xunfei", "claude", "gpt-4-turbo", GPT4_TURBO_PREVIEW]
+
+# channel
+FEISHU = "feishu"

+ 7 - 0
config.py

@@ -115,6 +115,13 @@ available_setting = {
     "wechatcomapp_secret": "",  # 企业微信app的secret
     "wechatcomapp_agent_id": "",  # 企业微信app的agent_id
     "wechatcomapp_aes_key": "",  # 企业微信app的aes_key
+
+    # 飞书配置
+    "feishu_port": 80,  # 飞书bot监听端口
+    "feishu_app_id": "",
+    "feishu_app_secret": "",
+    "feishu_token": "",
+
     # chatgpt指令自定义触发词
     "clear_memory_commands": ["#清除记忆"],  # 重置会话指令,必须以#开头
     # channel配置

+ 8 - 0
utils/file_util.py

@@ -0,0 +1,8 @@
+from urllib.parse import urlparse
+import os
+
+
+# 获取url后缀
+def get_path_suffix(path):
+    path = urlparse(path).path
+    return os.path.splitext(path)[-1].lstrip('.')