diff --git a/app.py b/app.py index 1bd6dad..19acdcd 100644 --- a/app.py +++ b/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 diff --git a/channel/channel_factory.py b/channel/channel_factory.py index 8c45045..7044b9a 100644 --- a/channel/channel_factory.py +++ b/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 diff --git a/channel/chat_channel.py b/channel/chat_channel.py index 8ed5f4f..c511b7e 100644 --- a/channel/chat_channel.py +++ b/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", "") diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py new file mode 100644 index 0000000..aed9436 --- /dev/null +++ b/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 diff --git a/channel/feishu/feishu_message.py b/channel/feishu/feishu_message.py new file mode 100644 index 0000000..fa6057e --- /dev/null +++ b/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 diff --git a/common/const.py b/common/const.py index a46765e..b8d701a 100644 --- a/common/const.py +++ b/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" diff --git a/config.py b/config.py index 8db08cd..fd6bb93 100644 --- a/config.py +++ b/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配置 diff --git a/utils/file_util.py b/utils/file_util.py new file mode 100644 index 0000000..6db659c --- /dev/null +++ b/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('.')