mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-03-19 21:38:18 +08:00
新增飞书应用通道
- 支持自建机器人的私聊和群聊 - 支持图片生成 - 支持文件总结
This commit is contained in:
6
app.py
6
app.py
@@ -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 common import const
|
||||||
from config import conf, load_config
|
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", "")
|
||||||
|
|||||||
250
channel/feishu/feishu_channel.py
Normal file
250
channel/feishu/feishu_channel.py
Normal file
@@ -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
channel/feishu/feishu_message.py
Normal file
92
channel/feishu/feishu_message.py
Normal file
@@ -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配置
|
||||||
|
|||||||
8
utils/file_util.py
Normal file
8
utils/file_util.py
Normal file
@@ -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('.')
|
||||||
Reference in New Issue
Block a user