Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

语音回复后增加文本回复(目前在公众号和企业微信应用支持) #2065

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
12 changes: 9 additions & 3 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@
from plugins import *
import threading

shutdown = False

def sigterm_handler_wrap(_signo):
old_handler = signal.getsignal(_signo)

def func(_signo, _stack_frame):
global shutdown
logger.info("signal {} received, exiting...".format(_signo))
conf().save_user_datas()
if callable(old_handler): # check old_handler
return old_handler(_signo, _stack_frame)

# if callable(old_handler): # check old_handler
# return old_handler(_signo, _stack_frame)
shutdown = True
sys.exit(0)

signal.signal(_signo, func)
Expand Down Expand Up @@ -60,8 +64,10 @@ def run():

start_channel(channel_name)

while True:
while not shutdown:
time.sleep(1)

logger.info("exited in run()")
except Exception as e:
logger.error("App startup failed!")
logger.exception(e)
Expand Down
3 changes: 2 additions & 1 deletion bot/zhipuai/zhipu_ai_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
class ZhipuAISession(Session):
def __init__(self, session_id, system_prompt=None, model="glm-4"):
super().__init__(session_id, system_prompt)

self.model = model
self.reset()
if not system_prompt:
if not self.system_prompt:
logger.warn("[ZhiPu] `character_desc` can not be empty")

def discard_exceeding(self, max_tokens, cur_tokens=None):
Expand Down
2 changes: 1 addition & 1 deletion bridge/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def __init__(self):
self.btype["chat"] = const.QWEN_DASHSCOPE
if model_type in [const.GEMINI]:
self.btype["chat"] = const.GEMINI
if model_type in [const.ZHIPU_AI]:
if model_type in [const.ZHIPU_AI,const.ZHIPU_AI_GLM4_FLASH,const.ZHIPU_AI_GLM4_AIR]:
self.btype["chat"] = const.ZHIPU_AI
if model_type and model_type.startswith("claude-3"):
self.btype["chat"] = const.CLAUDEAPI
Expand Down
2 changes: 1 addition & 1 deletion bridge/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class ContextType(Enum):
PATPAT = 21 # 拍了拍
FUNCTION = 22 # 函数调用
EXIT_GROUP = 23 #退出

RETELL = 30 # 文本朗读命令

def __str__(self):
return self.name
Expand Down
3 changes: 2 additions & 1 deletion bridge/reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Reply:
def __init__(self, type: ReplyType = None, content=None):
self.type = type
self.content = content
self.orig_content = None

def __str__(self):
return "Reply(type={}, content={})".format(self.type, self.content)
return "Reply(type={}, content={}, orig_content={})".format(self.type, self.content, self.orig_content)
12 changes: 11 additions & 1 deletion channel/chat_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,14 @@ def _compose_context(self, ctype: ContextType, content, **kwargs):
content = content.replace(img_match_prefix, "", 1)
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
retell_match_prefix = check_prefix(content, conf().get("retell_prefix"))
if retell_match_prefix:
content = content.replace(retell_match_prefix, "", 1)
context.type = ContextType.RETELL
context["desire_rtype"] = ReplyType.VOICE
else:
context.type = ContextType.TEXT

context.content = content.strip()
if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
context["desire_rtype"] = ReplyType.VOICE
Expand Down Expand Up @@ -190,6 +197,8 @@ def _generate_reply(self, context: Context, reply: Reply = Reply()) -> Reply:
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE: # 文字和图片消息
context["channel"] = e_context["channel"]
reply = super().build_reply_content(context.content, context)
elif context.type == ContextType.RETELL: # 朗读命令
reply = super().build_text_to_voice(context.content)
elif context.type == ContextType.VOICE: # 语音消息
cmsg = context["msg"]
cmsg.prepare()
Expand Down Expand Up @@ -251,6 +260,7 @@ def _decorate_reply(self, context: Context, reply: Reply) -> Reply:
reply_text = reply.content
if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
reply = super().build_text_to_voice(reply.content)
reply.orig_content = reply_text
return self._decorate_reply(context, reply)
if context.get("isgroup", False):
if not context.get("no_need_at", False):
Expand Down
178 changes: 158 additions & 20 deletions channel/wechatcom/wechatcomapp_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
from config import conf, subscribe_msg
from voice.audio_convert import any_to_amr, split_audio

from wechatpy import events
from wechatpy.fields import IntegerField, StringField
import xmltodict
from wechatpy.utils import to_text

MAX_UTF8_LEN = 2048


Expand All @@ -41,23 +46,32 @@ def __init__(self):
)
self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id)
self.client = WechatComAppClient(self.corp_id, self.secret)
self.text_after_voice = conf().get("text_after_voice", False)

def startup(self):
# start message listener
urls = ("/wxcomapp", "channel.wechatcom.wechatcomapp_channel.Query")
urls = (conf().get("wechatcomapp_url", "/wxcomapp"), "channel.wechatcom.wechatcomapp_channel.Query")
app = web.application(urls, globals(), autoreload=False)
port = conf().get("wechatcomapp_port", 9898)
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))

def send(self, reply: Reply, context: Context):
receiver = context["receiver"]
logger.debug("[wechatcom] context {} ".format(context.kwargs['msg']))
if context.kf_mode:
receiver = context.kwargs['msg'].from_user_id # 客服模式下,external_userid 就是客户id
agent_id = context.kwargs['msg'].to_user_id # 客服模式下,agent_id 就是客服id
else:
agent_id = self.agent_id # 非客服模式下,agent_id 就是应用的 agent_id

if reply.type in [ReplyType.TEXT, ReplyType.ERROR, ReplyType.INFO]:
reply_text = reply.content
texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN)
if len(texts) > 1:
logger.info("[wechatcom] text too long, split into {} parts".format(len(texts)))
for i, text in enumerate(texts):
self.client.message.send_text(self.agent_id, receiver, text)
self.send_text_message(agent_id, receiver, text, context.kf_mode)

if i != len(texts) - 1:
time.sleep(0.5) # 休眠0.5秒,防止发送过快乱序
logger.info("[wechatcom] Do send text to {}: {}".format(receiver, reply_text))
Expand All @@ -84,9 +98,15 @@ def send(self, reply: Reply, context: Context):
except Exception:
pass
for media_id in media_ids:
self.client.message.send_voice(self.agent_id, receiver, media_id)
self.send_voice_message(agent_id, receiver, media_id, context.kf_mode)
time.sleep(1)
logger.info("[wechatcom] sendVoice={}, receiver={}".format(reply.content, receiver))

# if need text_after_voice
if self.text_after_voice and reply.orig_content:
logger.debug("[wechatcom] send text after voice: {}".format(reply.orig_content))
self.send_text_message(agent_id, receiver, reply.orig_content, context.kf_mode)

elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
pic_res = requests.get(img_url, stream=True)
Expand All @@ -106,7 +126,7 @@ def send(self, reply: Reply, context: Context):
logger.error("[wechatcom] upload image failed: {}".format(e))
return

self.client.message.send_image(self.agent_id, receiver, response["media_id"])
self.send_image_message(agent_id, receiver, response["media_id"], context.kf_mode)
logger.info("[wechatcom] sendImage url={}, receiver={}".format(img_url, receiver))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
Expand All @@ -122,9 +142,99 @@ def send(self, reply: Reply, context: Context):
except WeChatClientException as e:
logger.error("[wechatcom] upload image failed: {}".format(e))
return
self.client.message.send_image(self.agent_id, receiver, response["media_id"])
self.send_image_message(agent_id, receiver, response["media_id"], context.kf_mode)
logger.info("[wechatcom] sendImage, receiver={}".format(receiver))

def send_text_message(self, agent_id, receiver, content, kf_mode):
if not kf_mode:
return self.client.message.send_text(agent_id, receiver, content)

url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.client.fetch_access_token_cs()}"
data = {
"touser": receiver,
"open_kfid": agent_id,
"msgtype": "text",
"text": {"content": content}
}

response = requests.post(url, json=data)
return response.json()

def send_image_message(self, agent_id, receiver, media_id, kf_mode):
if not kf_mode:
return self.client.message.send_image(agent_id, receiver, media_id)

url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.client.fetch_access_token_cs()}"
data = {
"touser": receiver,
"open_kfid": agent_id,
"msgtype": "image",
"image": {"media_id": media_id}
}

response = requests.post(url, json=data).json()
if response['errmsg'] == 'ok':
logger.debug(f"Send IMAGE Message Success")
else:
logger.error(f"Something error:{response}")
return response

def send_voice_message(self, agent_id, receiver, media_id, kf_mode):
if not kf_mode:
return self.client.message.send_voice(agent_id, receiver, media_id)

url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.client.fetch_access_token_cs()}"
data = {
"touser": receiver,
"open_kfid": agent_id,
"msgtype": "voice",
"voice": {"media_id": media_id}
}

response = requests.post(url, json=data).json()
if response['errmsg'] == 'ok':
logger.debug(f"Send VOICE Message Success")
else:
logger.error(f"Something error:{response}")
return response

def get_latest_message(self, token, open_kfid, next_cursor=""):
url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token={self.client.fetch_access_token_cs()}"
data = {
"token": token,
"open_kfid": open_kfid,
"limit": 1000
}
if next_cursor:
data["cursor"] = next_cursor

response = requests.post(url, json=data)
response_data = response.json()

# 检查是否有错误码并打印相关错误信息
if response_data.get("errcode") != 0:
logger.error(
f"[ERROR][{response_data.get('errcode')}][{response_data.get('errmsg')}] - Failed to fetch messages, more info at {response_data.get('more_info') or 'https://open.work.weixin.qq.com/devtool/query?e=' + str(response_data.get('errcode'))}")
return None

# logger.debug(f"response_data:{response_data}")
if response_data.get("msg_list"):
return response_data["msg_list"][-1] # 返回最新的一条消息
else:
return None

# 扩展客服消息事件类
class CustomServiceEvent(events.BaseEvent):
"""
客服消息或事件
"""
agent = IntegerField('AgentID', 0)
event = 'kf_msg_or_event'
source = StringField('FromUserName')
target = StringField('ToUserName')
time = IntegerField('CreateTime')
token = StringField('Token')
open_kfid = StringField('OpenKfId')

class Query:
def GET(self):
Expand All @@ -138,6 +248,7 @@ def GET(self):
echostr = params.echostr
echostr = channel.crypto.check_signature(signature, timestamp, nonce, echostr)
except InvalidSignatureException:
logger.error("[wechatcom] Invalid signature in GET request")
raise web.Forbidden()
return echostr

Expand All @@ -150,29 +261,56 @@ def POST(self):
timestamp = params.timestamp
nonce = params.nonce
message = channel.crypto.decrypt_message(web.data(), signature, timestamp, nonce)
msg = self.extended_parse_message(message)
logger.debug("[wechatcom] receive message: {}, msg= {}".format(message, msg))

except (InvalidSignatureException, InvalidCorpIdException):
raise web.Forbidden()
msg = parse_message(message)
logger.debug("[wechatcom] receive message: {}, msg= {}".format(message, msg))

kf_msg = None
if msg.type == "event":
if msg.event == "subscribe":
reply_content = subscribe_msg()
if reply_content:
reply = create_reply(reply_content, msg).render()
res = channel.crypto.encrypt_message(reply, nonce, timestamp)
return res
else:
try:
wechatcom_msg = WechatComAppMessage(msg, client=channel.client)
except NotImplementedError as e:
logger.debug("[wechatcom] " + str(e))
elif msg.event == "kf_msg_or_event":
kf_msg = channel.get_latest_message(msg.token, msg.open_kfid)
logger.debug("[wechatcom] latest_message: {}".format(msg))
else:
logger.debug("[wechatcom] receive unsupported event: {}".format(msg.event))
return "success"
context = channel._compose_context(
wechatcom_msg.ctype,
wechatcom_msg.content,
isgroup=False,
msg=wechatcom_msg,
)
if context:
channel.produce(context)

try:
wechatcom_msg = WechatComAppMessage(msg, client=channel.client, kf_msg=kf_msg)
except NotImplementedError as e:
logger.debug("[wechatcom] " + str(e))
return "success"
context = channel._compose_context(
wechatcom_msg.ctype,
wechatcom_msg.content,
isgroup=False,
msg=wechatcom_msg,
)
if context:
context.kf_mode = kf_msg is not None #是否客服模式
channel.produce(context)
return "success"


# 扩展解析消息
def extended_parse_message(self, message):
msg = parse_message(message)
if msg.type != "unknown":
return msg

# 尝试解析客服消息事件
msg_text = xmltodict.parse(to_text(message))['xml']
message_type = msg_text['MsgType'].lower()
if message_type == 'event':
event_type = msg_text['Event'].lower()
if event_type == "kf_msg_or_event":
return CustomServiceEvent(msg_text)

return msg
22 changes: 20 additions & 2 deletions channel/wechatcom/wechatcomapp_client.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import threading
import time

import requests
from wechatpy.enterprise import WeChatClient

from config import conf

class WechatComAppClient(WeChatClient):
def __init__(self, corp_id, secret, access_token=None, session=None, timeout=None, auto_retry=True):
Expand All @@ -19,3 +19,21 @@ def fetch_access_token(self): # 重载父类方法,加锁避免多线程重
if self.expires_at - timestamp > 60:
return access_token
return super().fetch_access_token()

def fetch_access_token_cs(self):
current_time = time.time()
if self.access_token and self.expires_at - current_time > 60:
return self.access_token

corpid = conf().get("wechatcom_corp_id")
corpsecret = conf().get("wechatcomapp_secret")
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpid}&corpsecret={corpsecret}"

response = requests.get(url).json()
if 'access_token' in response:
self.access_token = response['access_token']
self.expires_at = current_time + response['expires_in'] - 60
print(f'access_token:{self.access_token}')
return self.access_token
else:
raise Exception("Failed to retrieve access token")
Loading