@@ -5,8 +5,8 @@ import signal | |||||
import sys | import sys | ||||
from channel import channel_factory | 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 * | from plugins import * | ||||
@@ -43,7 +43,7 @@ def run(): | |||||
# os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001' | # os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001' | ||||
channel = channel_factory.create_channel(channel_name) | 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() | PluginManager().load_plugins() | ||||
# startup channel | # startup channel | ||||
@@ -1,7 +1,7 @@ | |||||
""" | """ | ||||
channel factory | channel factory | ||||
""" | """ | ||||
from common import const | |||||
def create_channel(channel_type): | def create_channel(channel_type): | ||||
""" | """ | ||||
@@ -35,6 +35,10 @@ def create_channel(channel_type): | |||||
return WechatComAppChannel() | return WechatComAppChannel() | ||||
elif channel_type == "wework": | elif channel_type == "wework": | ||||
from channel.wework.wework_channel import WeworkChannel | from channel.wework.wework_channel import WeworkChannel | ||||
return WeworkChannel() | return WeworkChannel() | ||||
elif channel_type == const.FEISHU: | |||||
from channel.feishu.feishu_channel import FeiShuChanel | |||||
return FeiShuChanel() | |||||
raise RuntimeError | raise RuntimeError |
@@ -238,7 +238,8 @@ class ChatChannel(Channel): | |||||
reply = super().build_text_to_voice(reply.content) | reply = super().build_text_to_voice(reply.content) | ||||
return self._decorate_reply(context, reply) | return self._decorate_reply(context, reply) | ||||
if context.get("isgroup", False): | 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", "") | reply_text = conf().get("group_chat_reply_prefix", "") + reply_text + conf().get("group_chat_reply_suffix", "") | ||||
else: | else: | ||||
reply_text = conf().get("single_chat_reply_prefix", "") + reply_text + conf().get("single_chat_reply_suffix", "") | reply_text = conf().get("single_chat_reply_prefix", "") + reply_text + conf().get("single_chat_reply_suffix", "") | ||||
@@ -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 |
@@ -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 |
@@ -17,3 +17,6 @@ TTS_1 = "tts-1" | |||||
TTS_1_HD = "tts-1-hd" | 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] | 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" |
@@ -115,6 +115,13 @@ available_setting = { | |||||
"wechatcomapp_secret": "", # 企业微信app的secret | "wechatcomapp_secret": "", # 企业微信app的secret | ||||
"wechatcomapp_agent_id": "", # 企业微信app的agent_id | "wechatcomapp_agent_id": "", # 企业微信app的agent_id | ||||
"wechatcomapp_aes_key": "", # 企业微信app的aes_key | "wechatcomapp_aes_key": "", # 企业微信app的aes_key | ||||
# 飞书配置 | |||||
"feishu_port": 80, # 飞书bot监听端口 | |||||
"feishu_app_id": "", | |||||
"feishu_app_secret": "", | |||||
"feishu_token": "", | |||||
# chatgpt指令自定义触发词 | # chatgpt指令自定义触发词 | ||||
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 | "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 | ||||
# channel配置 | # channel配置 | ||||
@@ -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('.') |